diff options
Diffstat (limited to 'frontend/src/pages/AuthPages.svelte')
| -rw-r--r-- | frontend/src/pages/AuthPages.svelte | 187 |
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> |
