summaryrefslogtreecommitdiff
path: root/frontend/src/pages/AccountPage.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/pages/AccountPage.svelte')
-rw-r--r--frontend/src/pages/AccountPage.svelte189
1 files changed, 189 insertions, 0 deletions
diff --git a/frontend/src/pages/AccountPage.svelte b/frontend/src/pages/AccountPage.svelte
new file mode 100644
index 0000000..ef6d1a3
--- /dev/null
+++ b/frontend/src/pages/AccountPage.svelte
@@ -0,0 +1,189 @@
+<script lang="ts">
+ import Page from '../components/Page.svelte';
+ import Status from '../components/Status.svelte';
+ import { session } from '../lib/session.svelte';
+ import { getSessions, patchProfile, postPassword, deleteSession } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import { fmtDateTime } from '../lib/format';
+ import type { SessionInfo } from '../lib/types';
+
+ let nameInput = $state(session.me?.name ?? '');
+ let profileBusy = $state(false);
+ let profileOk = $state<string | null>(null);
+ let profileErr = $state<string | null>(null);
+
+ let curPw = $state('');
+ let newPw = $state('');
+ let newPw2 = $state('');
+ let pwBusy = $state(false);
+ let pwErr = $state<string | null>(null);
+ let pwOk = $state<string | null>(null);
+
+ let sessions: SessionInfo[] = $state([]);
+ let sessBusy = $state(true);
+ let sessErr = $state<string | null>(null);
+ let sessMsg = $state<string | null>(null);
+
+ $effect(() => {
+ nameInput = session.me?.name ?? '';
+ void loadSessions();
+ });
+
+ async function loadSessions() {
+ sessBusy = true;
+ sessErr = null;
+ try {
+ const { items } = await getSessions();
+ sessions = items;
+ } catch (e) {
+ sessErr = extractStatusMessage(e);
+ } finally {
+ sessBusy = false;
+ }
+ }
+
+ async function saveProfile(e: SubmitEvent) {
+ e.preventDefault();
+ profileBusy = true;
+ profileOk = null;
+ profileErr = null;
+ try {
+ await patchProfile(nameInput.trim());
+ profileOk = '资料已更新';
+ await session.refresh();
+ } catch (err) {
+ profileErr = extractStatusMessage(err);
+ } finally {
+ profileBusy = false;
+ }
+ }
+
+ async function changePassword(e: SubmitEvent) {
+ e.preventDefault();
+ pwErr = null;
+ if (newPw.length < 8) {
+ pwErr = '新密码长度需至少 8 位';
+ return;
+ }
+ if (newPw !== newPw2) {
+ pwErr = '两次输入的新密码不一致';
+ return;
+ }
+ pwBusy = true;
+ try {
+ await postPassword(curPw, newPw);
+ pwOk = '密码已更新;其他会话已被登出,请用新密码重新登录本机外的应用。';
+ curPw = '';
+ newPw = '';
+ newPw2 = '';
+ } catch (err) {
+ pwErr = extractStatusMessage(err);
+ } finally {
+ pwBusy = false;
+ }
+ }
+
+ async function revoke(s: SessionInfo) {
+ sessMsg = null;
+ sessErr = null;
+ try {
+ await deleteSession(s.id);
+ sessMsg = s.current ? '该会话已注销;如果你注销的是当前会话,会稍后自动退出。' : '会话已撤销';
+ await loadSessions();
+ await session.refresh();
+ } catch (e) {
+ sessErr = extractStatusMessage(e);
+ }
+ }
+</script>
+
+<Page title="账户与安全" subtitle="资料、密码与个人会话管理">
+ <div class="stack">
+ <section class="card stack">
+ <h2>个人信息</h2>
+ <form onsubmit={saveProfile} class="row">
+ <label style="flex:1 1 200px">
+ 姓名
+ <input bind:value={nameInput} autocomplete="name" />
+ </label>
+ <div>
+ 邮箱
+ <div class="fixed">{session.me?.email ?? '—'}</div>
+ </div>
+ <div>
+ 角色
+ <div>{session.me?.role === 'admin' ? '管理员' : '成员'}{session.me?.active === false ? ' · 已停用' : ''}</div>
+ </div>
+ <button class="btn primary" disabled={profileBusy || nameInput.trim().length === 0}>
+ {profileBusy ? '保存中…' : '保存资料'}
+ </button>
+ </form>
+ <Status busy={false} error={profileErr} empty={profileOk} />
+ </section>
+
+ <section class="card stack">
+ <h2>修改密码</h2>
+ <form onsubmit={changePassword} class="stack">
+ <div class="grid3 wrap-3">
+ <label>
+ 当前密码
+ <input type="password" bind:value={curPw} required autocomplete="current-password" />
+ </label>
+ <label>
+ 新密码
+ <input type="password" bind:value={newPw} required autocomplete="new-password" minlength={8} />
+ </label>
+ <label>
+ 确认新密码
+ <input type="password" bind:value={newPw2} required autocomplete="new-password" minlength={8} />
+ </label>
+ </div>
+ <div class="row">
+ <button class="btn primary" disabled={pwBusy}>
+ {pwBusy ? '更新中…' : '更新密码'}
+ </button>
+ </div>
+ <Status busy={false} error={pwErr} empty={pwOk} />
+ </form>
+ </section>
+
+ <section class="card stack">
+ <h2>登录会话</h2>
+ <Status busy={sessBusy} error={sessErr} busyText="载入会话…" empty={null} />
+ {#if !sessBusy && !sessErr && sessions.length === 0}
+ <Status busy={false} empty="没有其他会话记录" />
+ {/if}
+ {#if sessions.length > 0}
+ <table class="data">
+ <thead>
+ <tr>
+ <th>创建</th>
+ <th>最近活跃</th>
+ <th>设备/UA</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each sessions as s (s.id)}
+ <tr>
+ <td>{fmtDateTime(s.created_at)}</td>
+ <td>{fmtDateTime(s.last_seen_at)}</td>
+ <td class="hint">{s.user_agent?.slice(0, 60) ?? '—'}</td>
+ <td>
+ <div class="row">
+ {#if s.current}<span class="badge teal">当前会话</span>{/if}
+ <button class="btn small danger" disabled={s.current && sessions.length === 1} onclick={() => void revoke(s)}>
+ {s.current ? '注销当前' : '撤销'}
+ </button>
+ </div>
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+ <Status busy={false} error={null} empty={sessMsg} />
+ <p class="hint">更改密码或被管理员停用时,其他会话将自动失效。</p>
+ </section>
+ </div>
+</Page>