feat(auth): P0 登录与会话(迁移 v16、argon2id、HttpOnly Cookie)
- 迁移机制:schema::migrate 支持 15→16,新增 admin/session/setting 表 - 接口:session/setup/login/logout/password;SameSite=Strict + Origin 校验 + 失败限流 - 会话只存 token 的 sha256;改密清空全部会话 - 前端:/login 独立壳(首次=设置密码),页头登录/退出切换 - 测试:37 passed(迁移 + 登录/会话 4 例) - 文档:README 登录说明、docs/ADMIN-PLAN.md 方案与 P0 记录
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { Button, Card, FormField, Icon, Input, Notice, Space, Text, Title } from '@stash/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchSession, login, setup, type SessionState } from '../../auth'
|
||||
|
||||
/**
|
||||
* 登录页:首次打开是「设置密码」,之后是「登录」。
|
||||
*
|
||||
* 成功后整页跳回首页(而不是 SPA 内跳转),让服务端重新下发 Cookie 后的首帧
|
||||
* 也带上登录态,避免首屏闪一下未登录 UI。
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const [session, setSession] = useState<SessionState | null>(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
fetchSession().then((next) => {
|
||||
if (!alive) return
|
||||
if (next.authenticated) {
|
||||
window.location.assign('/')
|
||||
return
|
||||
}
|
||||
setSession(next)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isSetup = session !== null && !session.has_admin
|
||||
|
||||
const submit = async () => {
|
||||
setError('')
|
||||
|
||||
if (isSetup && password !== confirm) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
const result = isSetup ? await setup(password) : await login(password)
|
||||
setBusy(false)
|
||||
|
||||
if (result.ok) {
|
||||
window.location.assign('/')
|
||||
return
|
||||
}
|
||||
|
||||
setError(result.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<Card padding="lg" className="login-card">
|
||||
<Space gap="sm" className="login-brand">
|
||||
<span className="site-brand-mark" aria-hidden="true">
|
||||
S
|
||||
</span>
|
||||
<span className="site-brand-name">STASH</span>
|
||||
</Space>
|
||||
|
||||
<Title level={2}>{isSetup ? '设置管理员密码' : '登录'}</Title>
|
||||
<Text size="sm" tone="muted">
|
||||
{isSetup
|
||||
? '首次使用:设置一个至少 8 位的密码,之后用它在任意设备上登录。'
|
||||
: '登录后可以编辑侧栏、页头滚动信息与内容。'}
|
||||
</Text>
|
||||
|
||||
{error !== '' && (
|
||||
<Notice tone="error" title="操作失败">
|
||||
{error}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="密码"
|
||||
htmlFor="password"
|
||||
required
|
||||
hint={isSetup ? '至少 8 位,建议混合字母与数字' : undefined}
|
||||
>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
size="lg"
|
||||
icon="lock_line"
|
||||
placeholder="输入密码"
|
||||
value={password}
|
||||
autoFocus
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void submit()
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{isSetup && (
|
||||
<FormField label="确认密码" htmlFor="confirm" required>
|
||||
<Input
|
||||
id="confirm"
|
||||
type="password"
|
||||
size="lg"
|
||||
icon="keyhole_line"
|
||||
placeholder="再输入一次"
|
||||
value={confirm}
|
||||
onChange={(event) => setConfirm(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void submit()
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<Button size="lg" block loading={busy} icon={<Icon name="lock_line" />} onClick={submit}>
|
||||
{isSetup ? '设置并登录' : '登录'}
|
||||
</Button>
|
||||
|
||||
<a className="login-back" href="/">
|
||||
返回首页
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user