221 lines
8.9 KiB
TypeScript
221 lines
8.9 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||
import type { ReactNode } from 'react'
|
||
import { PasswordInput } from './PasswordInput'
|
||
|
||
export type AuthRole = 'viewer' | 'engineer' | 'admin'
|
||
|
||
export interface AuthIdentity {
|
||
id: string
|
||
username: string
|
||
display_name: string
|
||
role: AuthRole
|
||
must_change_password: boolean
|
||
}
|
||
|
||
export const roleLabels: Record<AuthRole, string> = { viewer: '观察者', engineer: '知识工程师', admin: '管理员' }
|
||
|
||
interface AuthContextValue {
|
||
identity: AuthIdentity | null
|
||
ready: boolean
|
||
login: (username: string, password: string) => Promise<string | null>
|
||
logout: () => Promise<void>
|
||
changePassword: (currentPassword: string, newPassword: string) => Promise<string | null>
|
||
signedOut: number
|
||
notifySignedOut: () => void
|
||
}
|
||
|
||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||
|
||
export function useAuth(): AuthContextValue {
|
||
const value = useContext(AuthContext)
|
||
if (!value) throw new Error('useAuth requires AuthProvider')
|
||
return value
|
||
}
|
||
|
||
// useOptionalAuth never throws: components that also render in static unit
|
||
// tests (workspace shell, settings view) degrade to "signed out" instead.
|
||
export function useOptionalAuth(): AuthContextValue | null {
|
||
return useContext(AuthContext)
|
||
}
|
||
|
||
// signedOutTick lets data layers bounce the whole app back to the login
|
||
// screen when any request observes a 401 (expired or revoked session).
|
||
export function useSignedOutTick(): [number, () => void] {
|
||
const [tick, setTick] = useState(0)
|
||
const notify = useCallback(() => setTick((value) => value + 1), [])
|
||
return [tick, notify]
|
||
}
|
||
|
||
async function readError(response: Response, fallback: string): Promise<string> {
|
||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||
return payload?.error || `${fallback}(HTTP ${response.status})`
|
||
}
|
||
|
||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||
const [identity, setIdentity] = useState<AuthIdentity | null>(null)
|
||
const [ready, setReady] = useState(false)
|
||
const [signedOut, bumpSignedOut] = useSignedOutTick()
|
||
|
||
const probe = useCallback(async () => {
|
||
try {
|
||
const response = await fetch('/api/v1/auth/me', { credentials: 'same-origin' })
|
||
if (response.ok) {
|
||
const value = (await response.json()) as { data?: AuthIdentity }
|
||
setIdentity(value.data ?? null)
|
||
} else {
|
||
setIdentity(null)
|
||
}
|
||
} catch {
|
||
setIdentity(null)
|
||
} finally {
|
||
setReady(true)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
void probe()
|
||
}, [probe])
|
||
|
||
useEffect(() => {
|
||
if (signedOut > 0) setIdentity(null)
|
||
}, [signedOut])
|
||
|
||
// A single global fetch interceptor keeps every existing data-layer fetch
|
||
// honest: any 401 from an authenticated endpoint (e.g. a revoked or expired
|
||
// session, or an engine re-start) bounces the console back to the login
|
||
// screen instead of leaving stale forms on screen. auth/me is excluded to
|
||
// avoid recursion during the probe itself.
|
||
useEffect(() => {
|
||
const original = window.fetch.bind(window)
|
||
window.fetch = async (input, init) => {
|
||
const response = await original(input, init)
|
||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input)
|
||
if (response.status === 401 && url.includes('/api/v1/') && !url.includes('/auth/')) {
|
||
bumpSignedOut()
|
||
}
|
||
return response
|
||
}
|
||
return () => {
|
||
window.fetch = original
|
||
}
|
||
}, [bumpSignedOut])
|
||
|
||
const login = useCallback(async (username: string, password: string) => {
|
||
const response = await fetch('/api/v1/auth/login', {
|
||
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, password }),
|
||
})
|
||
if (!response.ok) return readError(response, '登录失败')
|
||
const value = (await response.json()) as { data: AuthIdentity }
|
||
setIdentity(value.data)
|
||
return null
|
||
}, [])
|
||
|
||
const logout = useCallback(async () => {
|
||
await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => undefined)
|
||
setIdentity(null)
|
||
}, [])
|
||
|
||
const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
|
||
const response = await fetch('/api/v1/auth/change-password', {
|
||
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||
})
|
||
if (!response.ok) return readError(response, '修改密码失败')
|
||
await probe()
|
||
return null
|
||
}, [probe])
|
||
|
||
const value = useMemo<AuthContextValue>(
|
||
() => ({ identity, ready, login, logout, changePassword, signedOut, notifySignedOut: bumpSignedOut }),
|
||
[identity, ready, login, logout, changePassword, signedOut, bumpSignedOut])
|
||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||
}
|
||
|
||
export function LoginScreen() {
|
||
const { login } = useAuth()
|
||
const [username, setUsername] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
const submit = async (event: React.FormEvent) => {
|
||
event.preventDefault()
|
||
setBusy(true)
|
||
const failure = await login(username.trim(), password)
|
||
setBusy(false)
|
||
setError(failure ?? '')
|
||
if (!failure) setPassword('')
|
||
}
|
||
|
||
return (
|
||
<div className="login-shell">
|
||
<form className="login-card" onSubmit={submit} aria-label="登录">
|
||
<h1>知识治理平台</h1>
|
||
<p className="login-subtitle">登录后所有操作将记录到你名下</p>
|
||
<label>用户名<input value={username} autoFocus autoComplete="username" onChange={(event) => setUsername(event.target.value)} /></label>
|
||
<label>密码<PasswordInput value={password} autoComplete="current-password" onChange={(event) => setPassword(event.target.value)} /></label>
|
||
{error ? <p className="login-error" role="alert">{error}</p> : null}
|
||
<button type="submit" disabled={busy || !username || !password}>{busy ? '登录中…' : '登录'}</button>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function MustChangePasswordScreen() {
|
||
const { changePassword, identity, logout } = useAuth()
|
||
const [current, setCurrent] = useState('')
|
||
const [next, setNext] = useState('')
|
||
const [confirm, setConfirm] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
const submit = async (event: React.FormEvent) => {
|
||
event.preventDefault()
|
||
if (next.length < 8) { setError('新密码至少 8 位'); return }
|
||
if (next !== confirm) { setError('两次输入的新密码不一致'); return }
|
||
setBusy(true)
|
||
const failure = await changePassword(current, next)
|
||
setBusy(false)
|
||
setError(failure ?? '')
|
||
if (!failure) { setCurrent(''); setNext(''); setConfirm('') }
|
||
}
|
||
|
||
return (
|
||
<div className="login-shell">
|
||
<form className="login-card" onSubmit={submit} aria-label="设置新密码">
|
||
<h1>首次登录需要设置新密码</h1>
|
||
<p className="login-subtitle">账号 {identity?.username} 的初始密码只能完成这一次登录</p>
|
||
<label>当前密码<PasswordInput value={current} autoFocus autoComplete="current-password" onChange={(event) => setCurrent(event.target.value)} /></label>
|
||
<label>新密码(至少 8 位)<PasswordInput value={next} autoComplete="new-password" onChange={(event) => setNext(event.target.value)} /></label>
|
||
<label>确认新密码<PasswordInput value={confirm} autoComplete="new-password" onChange={(event) => setConfirm(event.target.value)} /></label>
|
||
{error ? <p className="login-error" role="alert">{error}</p> : null}
|
||
<button type="submit" disabled={busy}>{busy ? '保存中…' : '保存并进入'}</button>
|
||
<button type="button" className="login-secondary" onClick={() => void logout()}>退出</button>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function TopBarUser() {
|
||
const value = useOptionalAuth()
|
||
const [menuOpen, setMenuOpen] = useState(false)
|
||
if (!value || !value.identity) return null
|
||
const { identity, logout } = value
|
||
const name = identity.display_name || identity.username
|
||
return (
|
||
<div className="topbar-user" data-testid="topbar-user">
|
||
<button type="button" className="topbar-user-button" aria-haspopup="menu" aria-expanded={menuOpen} onClick={() => setMenuOpen((value) => !value)}>
|
||
<span className="topbar-user-avatar" aria-hidden="true">{name.slice(0, 1)}</span>
|
||
<span className="topbar-user-name">{name}</span>
|
||
<span className={`role-badge role-${identity.role}`}>{roleLabels[identity.role]}</span>
|
||
</button>
|
||
{menuOpen ? (
|
||
<div className="topbar-user-menu" role="menu" onMouseLeave={() => setMenuOpen(false)}>
|
||
<button type="button" role="menuitem" onClick={() => { setMenuOpen(false); void logout() }}>退出登录</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|