summaryrefslogtreecommitdiff
path: root/frontend/src/pages/AdminPage.svelte
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
commit5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch)
tree948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/pages/AdminPage.svelte
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/pages/AdminPage.svelte')
-rw-r--r--frontend/src/pages/AdminPage.svelte371
1 files changed, 371 insertions, 0 deletions
diff --git a/frontend/src/pages/AdminPage.svelte b/frontend/src/pages/AdminPage.svelte
new file mode 100644
index 0000000..984962d
--- /dev/null
+++ b/frontend/src/pages/AdminPage.svelte
@@ -0,0 +1,371 @@
+<script lang="ts">
+ import Page from '../components/Page.svelte';
+ import Status from '../components/Status.svelte';
+ import Modal from '../components/Modal.svelte';
+ import {
+ listAdminUsers,
+ patchAdminUser,
+ createInvitation,
+ listInvitations,
+ deleteInvitation,
+ getAdminAudit,
+ postAdminUserPassword
+ } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import { fmtDateTime } from '../lib/format';
+ import type { User, Invitation } from '../lib/types';
+
+ let tab = $state<'users' | 'invites' | 'audit'>('users');
+
+ let users: User[] = $state([]);
+ let usersBusy = $state(true);
+ let usersErr = $state<string | null>(null);
+ let resetFor = $state<User | null>(null);
+ let resetToken = $state('');
+ let resetting = $state(false);
+ let adminOpsErr = $state<string | null>(null);
+ let inviteCopied = $state(false);
+
+ let invites: Invitation[] = $state([]);
+ let inviteBusy = $state(true);
+ let inviteErr = $state<string | null>(null);
+ let inviteEmail = $state('');
+ let inviteHours = $state(72);
+ let creatingInvite = $state(false);
+ let newInvite = $state<{ token: string; expires_at: string } | null>(null);
+ let gotToken = $state(false);
+
+ let audit: { actor?: string | null; action: string; target?: string | null; status: string; time: string; detail?: string | null }[] = $state([]);
+ let auditBusy = $state(true);
+ let auditErr = $state<string | null>(null);
+
+ $effect(() => {
+ void loadTab(tab);
+ });
+
+ async function loadTab(t: 'users' | 'invites' | 'audit') {
+ if (t === 'users') await loadUsers();
+ else if (t === 'invites') await loadInvites();
+ else await loadAudit();
+ }
+
+ async function loadUsers() {
+ usersBusy = true;
+ usersErr = null;
+ try {
+ const { items } = await listAdminUsers();
+ users = items;
+ } catch (e) {
+ usersErr = extractStatusMessage(e);
+ } finally {
+ usersBusy = false;
+ }
+ }
+
+ async function loadInvites() {
+ inviteBusy = true;
+ inviteErr = null;
+ try {
+ const { items } = await listInvitations();
+ invites = items;
+ } catch (e) {
+ inviteErr = extractStatusMessage(e);
+ } finally {
+ inviteBusy = false;
+ }
+ }
+
+ async function loadAudit() {
+ auditBusy = true;
+ auditErr = null;
+ try {
+ const res = await getAdminAudit();
+ audit = res.items;
+ } catch (e) {
+ auditErr = extractStatusMessage(e);
+ } finally {
+ auditBusy = false;
+ }
+ }
+
+ async function patchUser(u: User, patch: { active?: boolean; role?: 'admin' | 'member'; daily_run_limit?: number | null; ai_enabled?: boolean }) {
+ adminOpsErr = null;
+ try {
+ const fresh = await patchAdminUser(u.id, patch);
+ users = users.map((x) => (x.id === fresh.id ? fresh : x));
+ } catch (e) {
+ adminOpsErr = extractStatusMessage(e);
+ }
+ }
+
+ async function issueReset(u: User) {
+ resetFor = u;
+ resetToken = '';
+ adminOpsErr = null;
+ resetting = true;
+ try {
+ const res = await postAdminUserPassword(u.id);
+ resetToken = res.reset_token;
+ } catch (e) {
+ adminOpsErr = extractStatusMessage(e);
+ resetFor = null;
+ } finally {
+ resetting = false;
+ }
+ }
+
+ async function makeInvite(e: SubmitEvent) {
+ e.preventDefault();
+ creatingInvite = true;
+ inviteErr = null;
+ newInvite = null;
+ try {
+ const res = await createInvitation({
+ email: inviteEmail.trim() || undefined,
+ expires_hours: inviteHours
+ });
+ newInvite = res;
+ inviteEmail = '';
+ await loadInvites();
+ } catch (err) {
+ inviteErr = extractStatusMessage(err);
+ } finally {
+ creatingInvite = false;
+ }
+ }
+
+ async function revokeInvite(i: Invitation) {
+ adminOpsErr = null;
+ try {
+ await deleteInvitation(i.id);
+ await loadInvites();
+ } catch (e) {
+ adminOpsErr = extractStatusMessage(e);
+ }
+ }
+
+ async function copy(text: string) {
+ try {
+ await navigator.clipboard.writeText(text);
+ inviteCopied = true;
+ } catch {
+ /* clipboard unavailable; user will select manually */
+ }
+ }
+
+ const inviteUrl = $derived(
+ newInvite
+ ? `${window.location.origin}${window.location.pathname}#/register?invite=${encodeURIComponent(newInvite.token)}`
+ : ''
+ );
+ const resetUrl = $derived(
+ resetToken
+ ? `${window.location.origin}${window.location.pathname}#/reset?token=${encodeURIComponent(resetToken)}`
+ : ''
+ );
+</script>
+
+<Page title="管理员" subtitle="用户与邀请管理、安全审计。不会直接查看他人策略代码">
+ {#snippet actions()}
+ <div class="tabs" role="tablist">
+ <button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>用户</button>
+ <button role="tab" aria-selected={tab === 'invites'} onclick={() => (tab = 'invites')}>邀请与恢复</button>
+ <button role="tab" aria-selected={tab === 'audit'} onclick={() => (tab = 'audit')}>审计日志</button>
+ </div>
+ {/snippet}
+
+ <div class="stack">
+ <Status busy={false} error={adminOpsErr} empty={null} />
+
+ {#if tab === 'users'}
+ <section class="card stack">
+ <h2>用户</h2>
+ <Status busy={usersBusy} error={usersErr} busyText="载入用户…" empty={null} />
+ {#if !usersBusy && !usersErr && users.length > 0}
+ <table class="data">
+ <thead>
+ <tr>
+ <th>邮箱</th>
+ <th>姓名</th>
+ <th>角色</th>
+ <th>状态</th>
+ <th>AI 权限</th>
+ <th>日回测上限</th>
+ <th>操作</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each users as u (u.id)}
+ <tr>
+ <td>{u.email}</td>
+ <td>{u.name}</td>
+ <td>
+ <select
+ value={u.role}
+ aria-label={`角色 ${u.email}`}
+ onchange={(ev) => void patchUser(u, { role: (ev.target as HTMLSelectElement).value as 'admin' | 'member' })}
+ >
+ <option value="member" selected={u.role === 'member'}>成员</option>
+ <option value="admin" selected={u.role === 'admin'}>管理员</option>
+ </select>
+ </td>
+ <td>
+ <span class="badge {u.active ? 'ok' : 'danger'}">{u.active ? '启用' : '停用'}</span>
+ </td>
+ <td>
+ <label class="checkline">
+ <input type="checkbox" checked={u.ai_enabled ?? false} onchange={(e) => void patchUser(u, { ai_enabled: (e.target as HTMLInputElement).checked })} />
+ </label>
+ </td>
+ <td>
+ <input
+ class="num"
+ style="width:90px"
+ type="number"
+ min="0"
+ max="200"
+ value={u.daily_run_limit ?? 5}
+ onchange={(e) => void patchUser(u, { daily_run_limit: Number((e.target as HTMLInputElement).value) })}
+ />
+ </td>
+ <td>
+ <div class="row">
+ <button
+ class="btn small {u.active ? 'danger' : ''}"
+ onclick={() => void patchUser(u, { active: !u.active })}
+ >
+ {u.active ? '停用' : '启用'}
+ </button>
+ <button class="btn small" disabled={resetting} onclick={() => void issueReset(u)}>生成恢复</button>
+ </div>
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+ <p class="hint">停用账户会立即吊销其会话并阻止其回测与 AI 请求。角色不能作用于他人项目,私阅策略代码不提供。</p>
+ </section>
+ {:else if tab === 'invites'}
+ <section class="card stack">
+ <h2>签发邀请</h2>
+ <form onsubmit={makeInvite} class="row">
+ <label style="flex:1 1 220px">
+ 邮箱(可选备注)
+ <input bind:value={inviteEmail} placeholder="📬 将线下告知受邀人,用于区分" />
+ </label>
+ <label style="flex:0 0 130px">
+ 有效小时
+ <input type="number" bind:value={inviteHours} min="1" max="720" />
+ </label>
+ <button class="btn primary" disabled={creatingInvite}>{creatingInvite ? '生成中…' : '生成邀请'}</button>
+ </form>
+ <Status busy={false} error={inviteErr} empty={null} />
+ {#if newInvite}
+ <div class="banner info">
+ <div>
+ <div class="cov-caption">邀请令牌(只显示这一次,请立即复制):</div>
+ <div class="mono-invite">{newInvite.token}</div>
+ <div class="hint">到期:{fmtDateTime(newInvite.expires_at)} · 单次使用 · 注册页链接:</div>
+ <div class="mono-invite" title="复制该完整链接给对方">{inviteUrl}</div>
+ <button class="btn small" onclick={() => void copy(inviteUrl)}>{inviteUrl && inviteCopied ? '已复制' : '复制链接'}</button>
+ </div>
+ </div>
+ {/if}
+ </section>
+
+ <section class="card stack">
+ <h2>历史邀请</h2>
+ <Status busy={inviteBusy} error={inviteErr} busyText="载入邀请…" empty={null} />
+ {#if !inviteBusy && !inviteErr && invites.length === 0}
+ <Status busy={false} empty="还没有邀请记录" />
+ {/if}
+ {#if !inviteBusy && invites.length > 0}
+ <table class="data">
+ <thead>
+ <tr>
+ <th>邮箱备注</th>
+ <th>角色</th>
+ <th>创建时间</th>
+ <th>过期</th>
+ <th>状态</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each invites as inv (inv.id)}
+ <tr>
+ <td>{inv.email ?? '—'}</td>
+ <td>{inv.role === 'admin' ? '管理员' : '成员'}</td>
+ <td>{fmtDateTime(inv.created_at)}</td>
+ <td>{fmtDateTime(inv.expires_at)}</td>
+ <td>
+ {#if inv.used_at}<span class="badge grey">已使用</span>
+ {:else if inv.revoked}<span class="badge grey">已撤销</span>
+ {:else if inv.expires_at && new Date(inv.expires_at) < new Date()}<span class="badge grey">已过期</span>
+ {:else}<span class="badge teal">有效</span>
+ {/if}
+ </td>
+ <td>
+ {#if !inv.used_at && !inv.revoked}
+ <button class="btn small danger" onclick={() => void revokeInvite(inv)}>撤销</button>
+ {/if}
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+ <p class="hint">恢复链接由“生成恢复”按钮产生,仅出现一次;不会自动发邮件。</p>
+ </section>
+ {:else}
+ <section class="card stack">
+ <h2>安全审计(收尾尾部)</h2>
+ <Status busy={auditBusy} error={auditErr} busyText="载入审计记录…" empty={null} />
+ {#if !auditBusy && !auditErr && audit.length === 0}
+ <Status busy={false} empty="暂无审计事件" />
+ {/if}
+ {#if !auditBusy && audit.length > 0}
+ <table class="data">
+ <thead>
+ <tr>
+ <th>时间</th>
+ <th>行为者</th>
+ <th>事件</th>
+ <th>对象</th>
+ <th>结果</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each audit as e, i (i)}
+ <tr>
+ <td>{fmtDateTime(e.time)}</td>
+ <td>{e.actor ?? '—'}</td>
+ <td>{e.action}</td>
+ <td class="num">{e.target ?? '—'}</td>
+ <td><span class="badge {e.status === 'ok' || e.status === 'success' ? 'ok' : 'danger'}">{e.status}</span></td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+ <p class="hint">审计仅记录操作行为与结果,不含密码/密钥/策略代码内容。</p>
+ </section>
+ {/if}
+ </div>
+
+ {#if resetFor}
+ <Modal title={`为 ${resetFor.email} 生成密码恢复`} onClose={() => (resetFor = null)}>
+ <div class="stack">
+ <div class="banner info">
+ <div>
+ <div>恢复链接(管理员线下告知对方,只出现一次):</div>
+ <div class="mono-invite">{resetUrl}</div>
+ <div class="hint">令牌 {resetToken.slice(0, 8)}… 会即刻失效前提示时间(见链接有效期)。</div>
+ </div>
+ </div>
+ <p class="hint">单次有效、限时;重置会吊销对方的全部历史会话。</p>
+ </div>
+ </Modal>
+ {/if}
+</Page>