summaryrefslogtreecommitdiff
path: root/frontend/src/pages/AuthPages.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/AuthPages.svelte
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/pages/AuthPages.svelte')
-rw-r--r--frontend/src/pages/AuthPages.svelte187
1 files changed, 187 insertions, 0 deletions
diff --git a/frontend/src/pages/AuthPages.svelte b/frontend/src/pages/AuthPages.svelte
new file mode 100644
index 0000000..9e56be2
--- /dev/null
+++ b/frontend/src/pages/AuthPages.svelte
@@ -0,0 +1,187 @@
+<script lang="ts">
+ import type { Route } from '../lib/router';
+ import { session } from '../lib/session.svelte';
+ import { go } from '../lib/router';
+ import { postLogin, postRegister, postResetPassword } from '../lib/client';
+ import { extractStatusMessage, statusByName } from '../lib/state';
+
+ interface Props {
+ route: Route;
+ }
+ let { route }: Props = $props();
+
+ const mode = $derived(route.name);
+
+ let email = $state('');
+ let password = $state('');
+ let name = $state('');
+ // Initial route query is deliberately read once: token is consumed at page load.
+ // svelte-ignore state_referenced_locally
+ let inviteToken = $state(route.query.get('invite') ?? '');
+ // svelte-ignore state_referenced_locally
+ let resetToken = $state(route.query.get('token') ?? '');
+ let busy = $state(false);
+ let resultError = $state<string | null>(null);
+ let done = $state<string | null>(null);
+ let pw2 = $state('');
+ let newPassword = $state('');
+
+ async function login(e: SubmitEvent) {
+ e.preventDefault();
+ busy = true;
+ resultError = null;
+ try {
+ await postLogin(email.trim(), password);
+ const user = await session.refresh();
+ if (user) session.set(user);
+ go('/projects');
+ } catch (err) {
+ if (statusByName(err) === 'unauthorized') resultError = '邮箱或密码不正确,或账户已被停用';
+ else resultError = extractStatusMessage(err);
+ } finally {
+ busy = false;
+ }
+ }
+
+ async function register(e: SubmitEvent) {
+ e.preventDefault();
+ busy = true;
+ resultError = null;
+ try {
+ const { user } = await postRegister(inviteToken.trim(), name.trim(), email.trim(), password);
+ session.set(user);
+ go('/projects');
+ } catch (err) {
+ resultError = extractStatusMessage(err);
+ } finally {
+ busy = false;
+ }
+ }
+
+ async function reset(e: SubmitEvent) {
+ e.preventDefault();
+ busy = true;
+ resultError = null;
+ try {
+ if (!resetToken.trim()) {
+ resultError = '链接中的重置令牌未提供。请使用管理员发给您的完整恢复链接打开本页。';
+ return;
+ }
+ if (newPassword.length < 8) {
+ resultError = '新密码长度需至少 8 位';
+ return;
+ }
+ if (newPassword !== pw2) {
+ resultError = '两次输入的新密码不一致';
+ return;
+ }
+ await postResetPassword(resetToken.trim(), newPassword);
+ done = '密码已重置,现在可以用新密码登录。';
+ } catch (err) {
+ resultError = extractStatusMessage(err);
+ } finally {
+ busy = false;
+ }
+ }
+</script>
+
+<section class="auth-wrap">
+ <div class="auth-card card" role="main">
+ <div class="auth-brand">
+ <span class="mark" aria-hidden="true"></span>
+ <h1>策研 <span class="sub">Strategy Lab</span></h1>
+ <p class="hint">内部策略研究平台 · 仅限受邀成员</p>
+ </div>
+
+ {#if mode === 'register'}
+ <form onsubmit={register}>
+ <div>
+ <label for="inv">邀请令牌</label>
+ <input id="inv" bind:value={inviteToken} required autocomplete="off" />
+ <p class="hint">单次有效,来自管理员邀请</p>
+ </div>
+ <div>
+ <label for="nm">姓名</label>
+ <input id="nm" bind:value={name} required autocomplete="name" />
+ </div>
+ <div>
+ <label for="em">邮箱</label>
+ <input id="em" type="email" bind:value={email} required autocomplete="email" />
+ </div>
+ <div>
+ <label for="pw">密码</label>
+ <input id="pw" type="password" bind:value={password} required autocomplete="new-password" minlength={8} />
+ <div class="hint">至少 8 位</div>
+ </div>
+ {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if}
+ <button type="submit" class="btn primary" disabled={busy}>{busy ? '创建中…' : '开通账户'}</button>
+ <p class="hint">已有账户? <a href="#/login">登录</a></p>
+ </form>
+ {:else if mode === 'reset'}
+ {#if done}
+ <div class="banner info">{done}</div>
+ <p><a href="#/login">前往登录</a></p>
+ {:else}
+ <form onsubmit={reset}>
+ <div>
+ <label for="rt">重置令牌</label>
+ <input id="rt" bind:value={resetToken} autocomplete="off" />
+ <p class="hint">由管理员线下发放的恢复链接自动填入;不支持邮箱自动发送</p>
+ </div>
+ <div>
+ <label for="np">新密码</label>
+ <input id="np" type="password" bind:value={newPassword} required autocomplete="new-password" minlength={8} />
+ </div>
+ <div>
+ <label for="np2">再输入一次新密码</label>
+ <input id="np2" type="password" bind:value={pw2} required autocomplete="new-password" minlength={8} />
+ </div>
+ {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if}
+ <button type="submit" class="btn primary" disabled={busy}>{busy ? '重置中…' : '重置密码'}</button>
+ <p class="hint">想起密码了? <a href="#/login">直接登录</a></p>
+ </form>
+ {/if}
+ {:else}
+ <form onsubmit={login}>
+ <div>
+ <label for="le">邮箱</label>
+ <input id="le" type="email" bind:value={email} required autocomplete="email" />
+ </div>
+ <div>
+ <label for="lp">密码</label>
+ <input id="lp" type="password" bind:value={password} required autocomplete="current-password" />
+ </div>
+ {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if}
+ <button type="submit" class="btn primary" disabled={busy}>{busy ? '登录中…' : '登录'}</button>
+ <p class="hint">
+ 开通新账户需要管理员邀请: <a href="#/register">用邀请令牌注册</a>
+ {#if route.query.get('invite')}<span>(邀请令牌已自动填入)</span>{/if}
+ </p>
+ <p class="hint">忘记密码?请联系管理员获取 <a href="#/reset">恢复链接</a></p>
+ </form>
+ {/if}
+ </div>
+</section>
+
+<style>
+ .auth-wrap {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 1.5rem;
+ background:
+ radial-gradient(700px 350px at 85% -60px, rgba(13, 148, 136, 0.08), transparent),
+ var(--bg);
+ }
+ .auth-card { width: 400px; max-width: 100%; padding: 1.6rem; }
+ .auth-brand { margin-bottom: 1.1rem; }
+ .mark {
+ display: inline-block;
+ width: 34px;
+ height: 34px;
+ border-radius: 9px;
+ background: var(--teal-strong);
+ margin-bottom: 0.6rem;
+ }
+ .sub { color: var(--text-3); font-weight: 500; font-size: 0.8rem; }
+</style>