diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
| commit | 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch) | |
| tree | 948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/pages/AdminPage.svelte | |
| download | strategy-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.svelte | 371 |
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> |
