Add account connections, guild wallets and admin fixes [skip ci]

This commit is contained in:
SCOPEDD committed 2026-09-30 12:25:29 -04:00
1 parent 7076e847ea
commit 004862ea88
38 files changed
+1696 -458

No files matched your search

+1 -1
View File
@@ -62,7 +62,7 @@
{:else if route.name === 'landing' && (landingEnabled || (preview && !!session.user))}
<PublicLanding />
{:else if !session.user}
{#if route.name === 'login' || !landingEnabled}
{#if route.name === 'login' || route.name === 'reset-password' || !landingEnabled}
<Login brandName={brand.name} logo={brand.logo_url} />
{:else}
<PublicLanding />
+1 -1
View File
@@ -1,6 +1,6 @@
// Tiny hash router: #/instances/abc → { name: 'instances', params: ['abc'] }
function parse() {
const parts = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
const parts = location.hash.replace(/^#\/?/, '').split('?')[0].split('/').filter(Boolean).map(decodeURIComponent);
return { name: parts[0] ?? 'dashboard', params: parts.slice(1) };
}
+1
View File
@@ -82,6 +82,7 @@ export interface Settings {
public_url: string | null;
curseforge_api_key?: string | null;
launcher_download_url: string | null;
username_blocklist: string[];
curseforge_key_set: boolean;
curseforge_key_from_env: boolean;
}
+59 -6
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { LogIn, LoaderCircle } from '@lucide/svelte';
import { post } from '../lib/api';
import { get, post } from '../lib/api';
import { setToken } from '../lib/session.svelte';
let { brandName, logo }: { brandName: string; logo: string | null } = $props();
@@ -8,6 +8,49 @@
let password = $state('');
let error = $state('');
let busy = $state(false);
let mode = $state(location.hash.startsWith('#/reset-password') ? 'reset' : 'login');
let email = $state('');
let newPassword = $state('');
let notice = $state('');
let discordEnabled = $state(false);
get<{discord_enabled: boolean}>('/api/v1/auth/connections/config').then(x => discordEnabled = x.discord_enabled).catch(() => {});
async function discordSignIn() {
error = '';
try {
const flow = await get<{url: string; state: string}>('/api/v1/auth/discord/start');
const popup = window.open(flow.url, 'scopenet-discord', 'width=520,height=740');
if (!popup) { error = 'Allow popups to sign in with Discord.'; return; }
for (let i = 0; i < 120; i++) {
await new Promise(resolve => setTimeout(resolve, 1500));
const result = await get<any>(`/api/v1/auth/discord/poll?state=${encodeURIComponent(flow.state)}`);
if (result.pending === true && !result.token) continue;
popup.close();
if (result.token) {
if (result.user.role !== 'admin') throw new Error('This account is not an administrator.');
setToken(result.token);
} else notice = 'Your account is waiting for approval.';
return;
}
error = 'Discord sign-in timed out. Try again.';
} catch (err) { error = err instanceof Error ? err.message : String(err); }
}
async function recover(e: SubmitEvent) {
e.preventDefault(); busy = true; error = ''; notice = '';
try {
if (mode === 'forgot') {
const response = await post<{message: string}>('/api/v1/auth/forgot-password', {email});
notice = response.message;
} else {
const token = new URLSearchParams(location.hash.split('?')[1] || '').get('token') || '';
await post('/api/v1/auth/reset-password', {token, password: newPassword});
notice = 'Password updated. You can sign in now.';
mode = 'login'; location.hash = '#/login';
}
} catch (err) { error = err instanceof Error ? err.message : String(err); }
finally { busy = false; }
}
async function submit(e: SubmitEvent) {
e.preventDefault();
@@ -26,17 +69,26 @@
</script>
<div class="wrap">
<form class="card login" onsubmit={submit}>
<form class="card login" onsubmit={mode === 'login' ? submit : recover}>
<img src={logo ?? '/favicon.svg'} alt="" class="logo" />
<h1>{brandName}</h1>
<p class="muted">Sign in to manage your launcher</p>
<label class="field">Username<input bind:value={username} autocomplete="username" required /></label>
<label class="field">Password<input type="password" bind:value={password} autocomplete="current-password" required /></label>
<p class="muted">{mode === 'forgot' ? 'Request a password reset link' : mode === 'reset' ? 'Set a new password' : 'Sign in to manage your launcher'}</p>
{#if mode === 'login'}
<label class="field">Username<input bind:value={username} autocomplete="username" required /></label>
<label class="field">Password<input type="password" bind:value={password} autocomplete="current-password" required /></label>
{:else if mode === 'forgot'}
<label class="field">Account email<input type="email" bind:value={email} autocomplete="email" required /></label>
{:else}
<label class="field">New password<input type="password" bind:value={newPassword} minlength="8" autocomplete="new-password" required /></label>
{/if}
{#if notice}<div class="notice">{notice}</div>{/if}
{#if error}<div class="error">{error}</div>{/if}
<button class="primary big" disabled={busy}>
{#if busy}<LoaderCircle class="spin" size={18} />{:else}<LogIn size={18} />{/if}
Sign in
{mode === 'login' ? 'Sign in' : mode === 'forgot' ? 'Email reset link' : 'Reset password'}
</button>
{#if mode === 'login' && discordEnabled}<button type="button" onclick={discordSignIn}>Sign in with Discord</button>{/if}
{#if mode === 'login'}<button type="button" class="ghost" onclick={() => mode = 'forgot'}>Forgot password?</button>{:else}<button type="button" class="ghost" onclick={() => { mode = 'login'; location.hash = '#/login'; }}>Back to sign in</button>{/if}
<p class="tiny muted hint">First start? The admin password is in the container logs (or set <code>ADMIN_PASSWORD</code>).</p>
</form>
</div>
@@ -49,5 +101,6 @@
.muted { margin-top: -10px; }
.big { padding: 12px; margin-top: 6px; }
.error { background: rgba(244, 63, 94, 0.1); border: 1px solid rgba(244, 63, 94, 0.3); color: #fda4af; padding: 10px 12px; border-radius: 10px; font-size: 0.88rem; }
.notice { color: var(--good); font-size: 0.88rem; }
.hint { text-align: center; margin-top: 0; line-height: 1.5; }
</style>
+44 -21
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Award, Sparkles, Filter } from '@lucide/svelte';
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Sparkles, Pickaxe, Swords, Blocks, Wheat, Users, Compass, Target } from '@lucide/svelte';
import Modal from '../components/Modal.svelte';
import { get, post, put, del } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
@@ -19,6 +19,19 @@
let deleteOpen = $state(false);
const categories = ['mining', 'combat', 'building', 'farming', 'social', 'exploration'];
const iconChoices = [
{ id: 'pickaxe', label: 'Mining', component: Pickaxe },
{ id: 'swords', label: 'Combat', component: Swords },
{ id: 'blocks', label: 'Building', component: Blocks },
{ id: 'wheat', label: 'Farming', component: Wheat },
{ id: 'users', label: 'Social', component: Users },
{ id: 'compass', label: 'Exploration', component: Compass },
{ id: 'target', label: 'General', component: Target },
];
function iconFor(q: Partial<Quest>) {
const icon = (q.icon || '').toLowerCase();
return iconChoices.find((i) => i.id === icon)?.component || iconChoices.find((i) => i.label.toLowerCase() === q.category)?.component || Target;
}
const statTypes = [
{ id: 'blocks_broken', label: 'Blocks Broken' },
{ id: 'mob_kills', label: 'Mob Kills' },
@@ -44,7 +57,7 @@
xp_reward: q.xp_reward || 0,
stat_type: q.stat_type || q.target_stat || 'blocks_broken',
target_count: q.target_count || 1,
icon: q.icon || '⛏️',
icon: q.icon || 'pickaxe',
active: q.active !== undefined ? q.active : (q.enabled !== undefined ? q.enabled : true),
}));
} catch (e) {
@@ -84,7 +97,7 @@
xp_reward: 150,
stat_type: 'blocks_broken',
target_count: 50,
icon: '⛏️',
icon: 'pickaxe',
active: true,
};
draftOpen = true;
@@ -96,8 +109,8 @@
}
async function save() {
if (!draft || !draft.title || !draft.description) {
toast('Title and description are required', 'error');
if (!draft || !draft.id?.trim() || !draft.title?.trim() || !draft.description?.trim() || !draft.stat_type?.trim() || Number(draft.target_count) < 1 || Number(draft.xp_reward) < 0) {
toast('Enter an ID, title, description, tracked stat, target and reward', 'error');
return;
}
saving = true;
@@ -205,9 +218,10 @@
</thead>
<tbody>
{#each filtered as q}
{@const Icon = iconFor(q)}
<tr>
<td class="icon-cell">
<span class="quest-icon">{q.icon || '🎯'}</span>
<span class="quest-icon" title={q.icon}><Icon size={21} /></span>
</td>
<td class="info-cell">
<strong>{q.title}</strong>
@@ -260,11 +274,11 @@
</div>
{#if draft}
<Modal bind:open={draftOpen} title={draft.id && quests.some((q) => q.id === draft?.id) ? 'Edit Quest' : 'New Quest'}>
<Modal bind:open={draftOpen} width={720} title={draft.id && quests.some((q) => q.id === draft?.id) ? 'Edit Quest' : 'New Quest'}>
<form onsubmit={(e) => { e.preventDefault(); save(); }} class="form">
<div class="field">
<label for="q-id">Quest Identifier</label>
<input id="q-id" type="text" bind:value={draft.id} required placeholder="e.g. daily_mine_iron" />
<input id="q-id" type="text" bind:value={draft.id} required disabled={quests.some((q) => q.id === draft?.id)} placeholder="e.g. daily_mine_iron" />
</div>
<div class="grid-2">
@@ -273,8 +287,10 @@
<input id="q-title" type="text" bind:value={draft.title} required placeholder="e.g. Iron Seeker" />
</div>
<div class="field">
<label for="q-icon">Icon (Emoji / Symbol)</label>
<input id="q-icon" type="text" bind:value={draft.icon} placeholder="⛏️" />
<label for="q-icon">Icon</label>
<select id="q-icon" bind:value={draft.icon}>
{#each iconChoices as icon}<option value={icon.id}>{icon.label}</option>{/each}
</select>
</div>
</div>
@@ -304,11 +320,9 @@
<div class="grid-3">
<div class="field">
<label for="q-stat">Tracked Stat</label>
<select id="q-stat" bind:value={draft.stat_type}>
{#each statTypes as st}
<option value={st.id}>{st.label}</option>
{/each}
</select>
<input id="q-stat" list="quest-stats" bind:value={draft.stat_type} required placeholder="blocks_broken or action:block_broken:DIAMOND_ORE" />
<datalist id="quest-stats">{#each statTypes as st}<option value={st.id}>{st.label}</option>{/each}</datalist>
<small>Use an action target such as <code>action:block_broken:DIAMOND_ORE</code> for a specific block.</small>
</div>
<div class="field">
<label for="q-target">Target Count</label>
@@ -316,7 +330,7 @@
</div>
<div class="field">
<label for="q-xp">XP Reward</label>
<input id="q-xp" type="number" min="10" step="10" bind:value={draft.xp_reward} required />
<input id="q-xp" type="number" min="0" step="1" bind:value={draft.xp_reward} required />
</div>
</div>
@@ -348,7 +362,7 @@
{/if}
<style>
.page { padding: 32px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
.page { padding: clamp(16px, 3vw, 32px); width: 100%; min-width: 0; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 6px; }
header p { color: var(--muted); margin: 0; font-size: 0.95rem; }
@@ -364,21 +378,21 @@
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.search-box { display: flex; align-items: center; gap: 10px; background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; flex: 1; min-width: 260px; max-width: 500px; }
.search-box input { background: transparent; border: none; outline: none; color: var(--text); width: 100%; font-size: 0.9rem; }
.filters { display: flex; align-items: center; gap: 12px; }
.filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-width: 0; }
.segmented { display: flex; background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 3px; gap: 2px; }
.segmented button { padding: 6px 14px; border-radius: 7px; border: none; background: transparent; color: var(--muted); font-size: 0.85rem; font-weight: 500; cursor: pointer; transition: all 0.15s; }
.segmented button.active { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
select { background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; color: var(--text); font-size: 0.85rem; outline: none; }
.table-wrap { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
.table-wrap { width: 100%; min-width: 0; background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; overflow-x: auto; }
table { width: 100%; min-width: 1040px; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
th { padding: 12px 16px; background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--line); color: var(--muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
td { padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.04); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.015); }
.icon-cell { width: 48px; text-align: center; }
.quest-icon { font-size: 1.6rem; display: inline-flex; align-items: center; justify-content: center; }
.quest-icon { display: inline-flex; align-items: center; justify-content: center; color: var(--accent); }
.info-cell strong { font-size: 0.95rem; display: block; margin-bottom: 2px; }
.info-cell p { margin: 0 0 4px; color: var(--muted); font-size: 0.82rem; }
@@ -410,4 +424,13 @@
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
.empty { text-align: center; padding: 60px 20px; color: var(--muted); background: var(--bg-2); border-radius: 12px; border: 1px solid var(--line); }
@media (max-width: 720px) {
header { flex-wrap: wrap; }
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.search-box { min-width: 0; max-width: none; flex-basis: 100%; }
.filters, .segmented { width: 100%; }
.segmented button { flex: 1; padding-inline: 4px; }
.grid-2, .grid-3 { grid-template-columns: minmax(0, 1fr); }
.form .field { min-width: 0; }
}
</style>
+74 -3
View File
@@ -1,19 +1,28 @@
<script lang="ts">
import { Save, LoaderCircle, KeyRound, UserRound, WifiOff, Download, CircleCheck, Copy } from '@lucide/svelte';
import Toggle from '../components/Toggle.svelte';
import { copy, get, put } from '../lib/api';
import { copy, get, post, put } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { AuthServerInfo, Settings } from '../lib/types';
let s = $state<Settings | null>(null);
let cfKey = $state('');
let saving = $state(false);
let usernameRules = $state('');
let connections = $state<any>(null);
let discordSecret = $state('');
let discordBot = $state('');
let resendKey = $state('');
let testAddress = $state('');
let testingEmail = $state(false);
let savingConnections = $state(false);
let info = $state<AuthServerInfo | null>(null);
const loadInfo = () => get<AuthServerInfo>('/api/admin/auth-server').then((x) => (info = x)).catch(toastError);
$effect(() => {
get<Settings>('/api/admin/settings').then((x) => (s = x)).catch(toastError);
get<Settings>('/api/admin/settings').then((x) => { s = x; usernameRules = x.username_blocklist.join('\n'); }).catch(toastError);
get('/api/admin/connections').then((x) => (connections = x)).catch(toastError);
loadInfo();
});
@@ -26,7 +35,7 @@
if (!s) return;
saving = true;
try {
s = await put<Settings>('/api/admin/settings', { ...$state.snapshot(s), curseforge_api_key: cfKey });
s = await put<Settings>('/api/admin/settings', { ...$state.snapshot(s), username_blocklist: usernameRules.split(/[\n,]+/).map(x => x.trim()).filter(Boolean), curseforge_api_key: cfKey });
cfKey = '';
loadInfo();
toast('Settings saved');
@@ -36,6 +45,34 @@
saving = false;
}
}
async function saveConnections() {
if (!connections) return;
savingConnections = true;
try {
connections = await put('/api/admin/connections', {
discord_client_id: connections.discord_client_id,
discord_client_secret: discordSecret,
discord_bot_token: discordBot,
discord_guild_id: connections.discord_guild_id,
resend_api_key: resendKey,
sender_email: connections.sender_email,
sender_name: connections.sender_name,
});
discordSecret = discordBot = resendKey = '';
toast('Connections saved');
} catch (e) { toastError(e); }
finally { savingConnections = false; }
}
async function testEmail() {
testingEmail = true;
try {
const result = await post<{message: string}>('/api/admin/connections/test-email', {email: testAddress});
toast(result.message);
} catch (e) { toastError(e); }
finally { testingEmail = false; }
}
</script>
<div class="page narrow">
@@ -73,6 +110,13 @@
<Toggle bind:checked={s.auth.offline_local} label="Allow local offline accounts" help="Players can type any username and play without an account. They only see public instances." />
</section>
<section class="card col">
<div class="section-title"><UserRound size={18} /><h2>Username blacklist</h2></div>
<p class="help">Blocked for account sign-ups, admin-created accounts, Discord-created accounts and username changes. One name or word per line. An exact entry blocks that name; <code>*word*</code> also blocks it inside longer names. Keep this list short to avoid blocking innocent names.</p>
<textarea rows="9" bind:value={usernameRules} spellcheck="false" aria-label="Blocked usernames and words"></textarea>
<span class="help">Changes apply when you save Settings above. Existing accounts keep their names.</span>
</section>
<section class="card col">
<div class="section-title"><KeyRound size={18} /><h2>Auth server</h2><span class="badge good">Yggdrasil</span></div>
<p class="help">
@@ -119,6 +163,30 @@
<span class="help">Shown on the dashboard so you can share it easily.</span>
</label>
</section>
{#if connections}
<section class="card col">
<div class="section-title"><KeyRound size={18} /><h2>Discord</h2></div>
<p class="help">Create an OAuth2 application in the Discord Developer Portal. Add <code>{s.public_url || info?.public_url || 'https://panel.example.com'}/api/v1/auth/discord/callback</code> as its redirect URL. A bot token and server ID are optional for role sync.</p>
<label class="field">Application client ID<input bind:value={connections.discord_client_id} autocomplete="off" /></label>
<label class="field">Client secret<input type="password" bind:value={discordSecret} placeholder={connections.discord_client_secret_set ? 'Saved — type to replace' : 'Client secret'} autocomplete="off" /></label>
<label class="field">Bot token<input type="password" bind:value={discordBot} placeholder={connections.discord_bot_token_set ? 'Saved — type to replace' : 'Optional bot token'} autocomplete="off" /></label>
<label class="field">Discord server ID<input bind:value={connections.discord_guild_id} placeholder="Optional" autocomplete="off" /></label>
<span class="help">Secrets are never returned to your browser after saving. Type <code>-</code> in a secret field to remove it.</span>
</section>
<section class="card col">
<div class="section-title"><Download size={18} /><h2>Resend SMTP</h2></div>
<p class="help">Verify the sender domain in Resend, then enter an API key. SCOPENET connects to <code>smtp.resend.com</code> over TLS.</p>
<label class="field">Resend API key<input type="password" bind:value={resendKey} placeholder={connections.resend_api_key_set ? 'Saved — type to replace' : 're_...'} autocomplete="off" /></label>
<label class="field">Sender name<input bind:value={connections.sender_name} placeholder="SCOPENET" /></label>
<label class="field">Sender email<input type="email" bind:value={connections.sender_email} placeholder="hello@example.com" /></label>
<button class="primary" disabled={savingConnections} onclick={saveConnections}>{savingConnections ? 'Saving…' : 'Save Discord & email settings'}</button>
<div class="test-row">
<label class="field">Send a test email<input type="email" bind:value={testAddress} placeholder="you@example.com" /></label>
<button disabled={testingEmail || !testAddress || !connections.resend_api_key_set} onclick={testEmail}>{testingEmail ? 'Sending…' : 'Send test'}</button>
</div>
<p class="help">A successful test means Resend accepted the message. Confirm delivery in the destination inbox or Resend activity log.</p>
</section>
{/if}
{/if}
</div>
@@ -136,4 +204,7 @@
details summary { cursor: pointer; font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
.key { margin: 10px 0 0; padding: 12px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-sm); font-family: var(--mono); font-size: 0.72rem; color: var(--muted); overflow-x: auto; }
.set { display: flex; align-items: center; gap: 8px; color: var(--good); font-weight: 400; }
.test-row { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; }
.test-row .field { flex: 1; min-width: 220px; }
code { overflow-wrap: anywhere; }
</style>