add account localization and switchable rooms
Centralize control and OBS copy in a shared TOML catalog, persist the selected locale per account, and broadcast language changes to component streams. Allow account owners to atomically switch their Bilibili room and restart the shared listener without changing component URLs.
This commit is contained in:
+62
-58
@@ -17,6 +17,7 @@ import {
|
||||
normalizeRecoveryCodes,
|
||||
} from './api'
|
||||
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
|
||||
import { LanguageSelect, useI18n } from './i18n'
|
||||
import type { TotpEnrollment } from './types'
|
||||
|
||||
function AuthShell({
|
||||
@@ -30,12 +31,16 @@ function AuthShell({
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<section className="auth-card jade-panel">
|
||||
<PwaControls />
|
||||
<div className="auth-toolbar">
|
||||
<LanguageSelect />
|
||||
<PwaControls />
|
||||
</div>
|
||||
<div className="auth-mark" aria-hidden="true">
|
||||
星
|
||||
{t('common.star')}
|
||||
</div>
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
@@ -47,6 +52,7 @@ function AuthShell({
|
||||
}
|
||||
|
||||
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||
const { t } = useI18n()
|
||||
const source = useMemo(() => {
|
||||
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
|
||||
if (enrollment.qrSvg)
|
||||
@@ -58,29 +64,29 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||
<div className="totp-enrollment">
|
||||
<div className="totp-qr">
|
||||
{source ? (
|
||||
<img src={source} alt="TOTP 验证器绑定二维码" />
|
||||
<img src={source} alt={t('auth.totp_qr_alt')} />
|
||||
) : (
|
||||
<span>二维码暂不可用,请使用右侧密钥手工添加。</span>
|
||||
<span>{t('auth.qr_unavailable')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="totp-copy">
|
||||
<h2>绑定动态验证器</h2>
|
||||
<h2>{t('auth.bind_authenticator')}</h2>
|
||||
<ol>
|
||||
<li>使用 1Password、Aegis、Microsoft Authenticator 等应用扫描二维码。</li>
|
||||
<li>若无法扫码,选择“输入设置密钥”。</li>
|
||||
<li>输入应用中出现的 6 位动态码完成绑定。</li>
|
||||
<li>{t('auth.totp_step_scan')}</li>
|
||||
<li>{t('auth.totp_step_manual')}</li>
|
||||
<li>{t('auth.totp_step_confirm')}</li>
|
||||
</ol>
|
||||
<label>
|
||||
手工设置密钥
|
||||
{t('auth.manual_key')}
|
||||
<div className="secret-row">
|
||||
<code>{enrollment.manualKey || '未提供'}</code>
|
||||
<code>{enrollment.manualKey || t('common.not_provided')}</code>
|
||||
{enrollment.manualKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => void copyToClipboard(enrollment.manualKey)}
|
||||
>
|
||||
复制
|
||||
{t('common.copy')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -91,11 +97,12 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||
}
|
||||
|
||||
function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const text = codes.join('\n')
|
||||
const download = () => {
|
||||
const blob = new Blob(
|
||||
['洛星瓷直播组件恢复码\n', '每个恢复码只能使用一次,请离线妥善保存。\n\n', text, '\n'],
|
||||
[`${t('auth.recovery_file_title')}\n`, `${t('auth.recovery_file_warning')}\n\n`, text, '\n'],
|
||||
{ type: 'text/plain;charset=utf-8' },
|
||||
)
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -107,10 +114,8 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell eyebrow="安全设置完成" title="保存账户恢复码">
|
||||
<p className="auth-lead">
|
||||
手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。
|
||||
</p>
|
||||
<AuthShell eyebrow={t('auth.security_complete')} title={t('auth.save_recovery_codes')}>
|
||||
<p className="auth-lead">{t('auth.recovery_description')}</p>
|
||||
{codes.length > 0 ? (
|
||||
<div className="recovery-grid">
|
||||
{codes.map(code => (
|
||||
@@ -118,7 +123,7 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>
|
||||
<div className="notice warning">{t('auth.no_recovery_codes')}</div>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
{codes.length > 0 && (
|
||||
@@ -128,15 +133,15 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
|
||||
className="secondary"
|
||||
onClick={() => void copyToClipboard(text).then(setCopied)}
|
||||
>
|
||||
{copied ? '已复制' : '复制全部'}
|
||||
{copied ? t('common.copied') : t('auth.copy_all')}
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={download}>
|
||||
下载文本
|
||||
{t('auth.download_text')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={onContinue}>
|
||||
我已妥善保存
|
||||
{t('auth.recovery_saved')}
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
@@ -150,16 +155,13 @@ export function LoginPage({
|
||||
onAuthenticated: () => Promise<void>
|
||||
setupRequired: boolean
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const [username, setUsername] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [useRecoveryCode, setUseRecoveryCode] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
usePwaUpdateBlocker(
|
||||
'login-form',
|
||||
'完成或清空正在填写的登录表单',
|
||||
busy || Boolean(username || totpCode),
|
||||
)
|
||||
usePwaUpdateBlocker('login-form', t('auth.login_blocker'), busy || Boolean(username || totpCode))
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -171,7 +173,7 @@ export function LoginPage({
|
||||
await onAuthenticated()
|
||||
location.assign('/control/')
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '用户名或动态验证码不正确'))
|
||||
setError(errorMessage(reason, t('auth.login_failed')))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -179,26 +181,27 @@ export function LoginPage({
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
eyebrow="洛星瓷直播组件"
|
||||
title="回到你的云台"
|
||||
eyebrow={t('auth.product_eyebrow')}
|
||||
title={t('auth.login_title')}
|
||||
footer={
|
||||
<p>
|
||||
{setupRequired ? (
|
||||
<>
|
||||
首次部署?<a href={authRoute('setup')}>创建系统管理员</a>
|
||||
{t('auth.first_deployment')} <a href={authRoute('setup')}>{t('auth.create_admin')}</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
持有邀请码?<a href={authRoute('register')}>注册新账户</a>
|
||||
{t('auth.have_invitation')}{' '}
|
||||
<a href={authRoute('register')}>{t('auth.register_account')}</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
|
||||
<p className="auth-lead">{t('auth.passwordless_description')}</p>
|
||||
<form className="stack-form" onSubmit={submit}>
|
||||
<label>
|
||||
用户名
|
||||
{t('auth.username')}
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
@@ -208,7 +211,7 @@ export function LoginPage({
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{useRecoveryCode ? '账户恢复码' : '6 位动态验证码'}
|
||||
{useRecoveryCode ? t('auth.recovery_code') : t('auth.totp_code')}
|
||||
<input
|
||||
required
|
||||
className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
|
||||
@@ -216,7 +219,7 @@ export function LoginPage({
|
||||
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
|
||||
pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
|
||||
maxLength={useRecoveryCode ? 64 : 6}
|
||||
placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'}
|
||||
placeholder={useRecoveryCode ? t('auth.recovery_placeholder') : '000000'}
|
||||
value={totpCode}
|
||||
onChange={event =>
|
||||
setTotpCode(
|
||||
@@ -235,14 +238,14 @@ export function LoginPage({
|
||||
setTotpCode('')
|
||||
}}
|
||||
>
|
||||
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
|
||||
{useRecoveryCode ? t('auth.use_totp') : t('auth.use_recovery')}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="notice error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
|
||||
<button disabled={busy}>{busy ? t('auth.verifying') : t('auth.login')}</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
@@ -255,6 +258,7 @@ export function EnrollmentPage({
|
||||
mode: 'setup' | 'register'
|
||||
onAuthenticated: () => Promise<void>
|
||||
}) {
|
||||
const { language, t } = useI18n()
|
||||
const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? ''
|
||||
const [inviteCode, setInviteCode] = useState(inviteFromFragment)
|
||||
const [username, setUsername] = useState('')
|
||||
@@ -267,9 +271,7 @@ export function EnrollmentPage({
|
||||
const isSetup = mode === 'setup'
|
||||
usePwaUpdateBlocker(
|
||||
'totp-enrollment',
|
||||
enrollment || recoveryCodes
|
||||
? '完成 TOTP 绑定并保存一次性恢复码'
|
||||
: '完成或清空正在填写的注册表单',
|
||||
enrollment || recoveryCodes ? t('auth.enrollment_blocker') : t('auth.registration_blocker'),
|
||||
busy ||
|
||||
Boolean(
|
||||
inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
|
||||
@@ -290,7 +292,7 @@ export function EnrollmentPage({
|
||||
}),
|
||||
)
|
||||
const next = normalizeEnrollment(payload)
|
||||
if (!next.enrollmentToken) throw new Error('服务端未返回注册流程标识')
|
||||
if (!next.enrollmentToken) throw new Error(t('auth.registration_missing_id'))
|
||||
setEnrollment(next)
|
||||
setBootstrapPassword('')
|
||||
if (!isSetup) {
|
||||
@@ -298,7 +300,7 @@ export function EnrollmentPage({
|
||||
history.replaceState(null, '', authRoute('register'))
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '无法开始安全注册流程'))
|
||||
setError(errorMessage(reason, t('auth.registration_start_failed')))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -312,7 +314,11 @@ export function EnrollmentPage({
|
||||
try {
|
||||
const payload = await api<unknown>(
|
||||
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`,
|
||||
json('POST', { enrollmentToken: enrollment.enrollmentToken, code: totpCode }),
|
||||
json('POST', {
|
||||
enrollmentToken: enrollment.enrollmentToken,
|
||||
code: totpCode,
|
||||
language,
|
||||
}),
|
||||
)
|
||||
setTotpCode('')
|
||||
// Drop QR/manual-key material from React state as soon as enrollment is
|
||||
@@ -320,7 +326,7 @@ export function EnrollmentPage({
|
||||
setEnrollment(undefined)
|
||||
setRecoveryCodes(normalizeRecoveryCodes(payload))
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '动态验证码无效或注册流程已过期'))
|
||||
setError(errorMessage(reason, t('auth.registration_confirm_failed')))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -336,13 +342,13 @@ export function EnrollmentPage({
|
||||
if (enrollment) {
|
||||
return (
|
||||
<AuthShell
|
||||
eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'}
|
||||
title="强制绑定 TOTP"
|
||||
eyebrow={isSetup ? t('auth.setup_step_two') : t('auth.register_step_two')}
|
||||
title={t('auth.require_totp')}
|
||||
>
|
||||
<TotpQr enrollment={enrollment} />
|
||||
<form className="stack-form compact-form" onSubmit={confirm}>
|
||||
<label>
|
||||
验证器中的 6 位动态码
|
||||
{t('auth.authenticator_code')}
|
||||
<input
|
||||
required
|
||||
autoFocus
|
||||
@@ -362,7 +368,7 @@ export function EnrollmentPage({
|
||||
</div>
|
||||
)}
|
||||
<button disabled={busy || totpCode.length !== 6}>
|
||||
{busy ? '正在确认…' : '确认绑定并创建账户'}
|
||||
{busy ? t('auth.confirming') : t('auth.confirm_create')}
|
||||
</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
@@ -371,23 +377,21 @@ export function EnrollmentPage({
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
|
||||
title={isSetup ? '创建系统管理员' : '创建你的账户'}
|
||||
eyebrow={isSetup ? t('auth.setup_only') : t('auth.invited_only')}
|
||||
title={isSetup ? t('auth.create_admin') : t('auth.create_your_account')}
|
||||
footer={
|
||||
<p>
|
||||
已有账户?<a href={authRoute('login')}>返回登录</a>
|
||||
{t('auth.already_account')} <a href={authRoute('login')}>{t('auth.back_to_login')}</a>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p className="auth-lead">
|
||||
{isSetup
|
||||
? '首位账户将拥有邀请码管理权限。旧管理员口令只授权这一次初始化,不会成为账户密码。'
|
||||
: '邀请码只用于注册;这是无密码账户,创建后每次登录都必须验证 TOTP。'}
|
||||
{isSetup ? t('auth.setup_description') : t('auth.register_description')}
|
||||
</p>
|
||||
<form className="stack-form" onSubmit={start}>
|
||||
{!isSetup && (
|
||||
<label>
|
||||
邀请码
|
||||
{t('auth.invitation')}
|
||||
<input
|
||||
required
|
||||
autoFocus={!inviteFromFragment}
|
||||
@@ -398,7 +402,7 @@ export function EnrollmentPage({
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
用户名
|
||||
{t('auth.username')}
|
||||
<input
|
||||
required
|
||||
autoFocus={isSetup || Boolean(inviteFromFragment)}
|
||||
@@ -411,7 +415,7 @@ export function EnrollmentPage({
|
||||
</label>
|
||||
{isSetup && (
|
||||
<label>
|
||||
一次性初始化口令
|
||||
{t('auth.bootstrap_password')}
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
@@ -419,7 +423,7 @@ export function EnrollmentPage({
|
||||
value={bootstrapPassword}
|
||||
onChange={event => setBootstrapPassword(event.target.value)}
|
||||
/>
|
||||
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
|
||||
<small>{t('auth.bootstrap_help')}</small>
|
||||
</label>
|
||||
)}
|
||||
{error && (
|
||||
@@ -427,7 +431,7 @@ export function EnrollmentPage({
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
|
||||
<button disabled={busy}>{busy ? t('auth.preparing_totp') : t('auth.next_bind')}</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user