Files
knowledge-governance/web/src/auth.tsx
T

221 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}