formatting and comments
This commit is contained in:
+113
-37
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Passwordless login and one-time TOTP enrollment screens.
|
||||
*
|
||||
* QR material, manual keys and recovery codes exist only in React memory and
|
||||
* are dropped as soon as their enrollment phase completes. PWA activation is
|
||||
* blocked while those values or partially completed forms are visible so an
|
||||
* update cannot erase information that the server will not reveal again.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { FormEvent, ReactNode } from 'react'
|
||||
import {
|
||||
@@ -11,7 +19,12 @@ import {
|
||||
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
|
||||
import type { TotpEnrollment } from './types'
|
||||
|
||||
function AuthShell({ eyebrow, title, children, footer }: {
|
||||
function AuthShell({
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
children: ReactNode
|
||||
@@ -21,7 +34,9 @@ function AuthShell({ eyebrow, title, children, footer }: {
|
||||
<main className="auth-page">
|
||||
<section className="auth-card jade-panel">
|
||||
<PwaControls />
|
||||
<div className="auth-mark" aria-hidden="true">星</div>
|
||||
<div className="auth-mark" aria-hidden="true">
|
||||
星
|
||||
</div>
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
{children}
|
||||
@@ -34,16 +49,19 @@ function AuthShell({ eyebrow, title, children, footer }: {
|
||||
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||
const source = useMemo(() => {
|
||||
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
|
||||
if (enrollment.qrSvg) return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
|
||||
if (enrollment.qrSvg)
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
|
||||
return undefined
|
||||
}, [enrollment.qrDataUrl, enrollment.qrSvg])
|
||||
|
||||
return (
|
||||
<div className="totp-enrollment">
|
||||
<div className="totp-qr">
|
||||
{source
|
||||
? <img src={source} alt="TOTP 验证器绑定二维码" />
|
||||
: <span>二维码暂不可用,请使用右侧密钥手工添加。</span>}
|
||||
{source ? (
|
||||
<img src={source} alt="TOTP 验证器绑定二维码" />
|
||||
) : (
|
||||
<span>二维码暂不可用,请使用右侧密钥手工添加。</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="totp-copy">
|
||||
<h2>绑定动态验证器</h2>
|
||||
@@ -57,7 +75,11 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||
<div className="secret-row">
|
||||
<code>{enrollment.manualKey || '未提供'}</code>
|
||||
{enrollment.manualKey && (
|
||||
<button type="button" className="text-button" onClick={() => void copyToClipboard(enrollment.manualKey)}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => void copyToClipboard(enrollment.manualKey)}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
)}
|
||||
@@ -72,12 +94,10 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const text = codes.join('\n')
|
||||
const download = () => {
|
||||
const blob = new Blob([
|
||||
'洛星瓷直播组件恢复码\n',
|
||||
'每个恢复码只能使用一次,请离线妥善保存。\n\n',
|
||||
text,
|
||||
'\n',
|
||||
], { type: 'text/plain;charset=utf-8' })
|
||||
const blob = new Blob(
|
||||
['洛星瓷直播组件恢复码\n', '每个恢复码只能使用一次,请离线妥善保存。\n\n', text, '\n'],
|
||||
{ type: 'text/plain;charset=utf-8' },
|
||||
)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
@@ -88,26 +108,45 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
|
||||
|
||||
return (
|
||||
<AuthShell eyebrow="安全设置完成" title="保存账户恢复码">
|
||||
<p className="auth-lead">手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。</p>
|
||||
{codes.length > 0
|
||||
? <div className="recovery-grid">{codes.map(code => <code key={code}>{code}</code>)}</div>
|
||||
: <div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>}
|
||||
<p className="auth-lead">
|
||||
手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。
|
||||
</p>
|
||||
{codes.length > 0 ? (
|
||||
<div className="recovery-grid">
|
||||
{codes.map(code => (
|
||||
<code key={code}>{code}</code>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
{codes.length > 0 && (
|
||||
<>
|
||||
<button type="button" className="secondary" onClick={() => void copyToClipboard(text).then(setCopied)}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => void copyToClipboard(text).then(setCopied)}
|
||||
>
|
||||
{copied ? '已复制' : '复制全部'}
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={download}>下载文本</button>
|
||||
<button type="button" className="secondary" onClick={download}>
|
||||
下载文本
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={onContinue}>我已妥善保存</button>
|
||||
<button type="button" onClick={onContinue}>
|
||||
我已妥善保存
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
export function LoginPage({
|
||||
onAuthenticated,
|
||||
setupRequired,
|
||||
}: {
|
||||
onAuthenticated: () => Promise<void>
|
||||
setupRequired: boolean
|
||||
}) {
|
||||
@@ -142,13 +181,19 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
<AuthShell
|
||||
eyebrow="洛星瓷直播组件"
|
||||
title="回到你的云台"
|
||||
footer={(
|
||||
footer={
|
||||
<p>
|
||||
{setupRequired
|
||||
? <>首次部署?<a href={authRoute('setup')}>创建系统管理员</a></>
|
||||
: <>持有邀请码?<a href={authRoute('register')}>注册新账户</a></>}
|
||||
{setupRequired ? (
|
||||
<>
|
||||
首次部署?<a href={authRoute('setup')}>创建系统管理员</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
持有邀请码?<a href={authRoute('register')}>注册新账户</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
}
|
||||
>
|
||||
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
|
||||
<form className="stack-form" onSubmit={submit}>
|
||||
@@ -173,9 +218,13 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
maxLength={useRecoveryCode ? 64 : 6}
|
||||
placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'}
|
||||
value={totpCode}
|
||||
onChange={event => setTotpCode(useRecoveryCode
|
||||
? event.target.value.trimStart().slice(0, 64)
|
||||
: event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
onChange={event =>
|
||||
setTotpCode(
|
||||
useRecoveryCode
|
||||
? event.target.value.trimStart().slice(0, 64)
|
||||
: event.target.value.replace(/\D/g, '').slice(0, 6),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
@@ -188,14 +237,21 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
>
|
||||
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
|
||||
</button>
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
{error && (
|
||||
<div className="notice error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
export function EnrollmentPage({
|
||||
mode,
|
||||
onAuthenticated,
|
||||
}: {
|
||||
mode: 'setup' | 'register'
|
||||
onAuthenticated: () => Promise<void>
|
||||
}) {
|
||||
@@ -214,7 +270,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
enrollment || recoveryCodes
|
||||
? '完成 TOTP 绑定并保存一次性恢复码'
|
||||
: '完成或清空正在填写的注册表单',
|
||||
busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes),
|
||||
busy ||
|
||||
Boolean(
|
||||
inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
|
||||
),
|
||||
)
|
||||
|
||||
const start = async (event: FormEvent) => {
|
||||
@@ -276,7 +335,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
|
||||
if (enrollment) {
|
||||
return (
|
||||
<AuthShell eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'} title="强制绑定 TOTP">
|
||||
<AuthShell
|
||||
eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'}
|
||||
title="强制绑定 TOTP"
|
||||
>
|
||||
<TotpQr enrollment={enrollment} />
|
||||
<form className="stack-form compact-form" onSubmit={confirm}>
|
||||
<label>
|
||||
@@ -294,8 +356,14 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
<button disabled={busy || totpCode.length !== 6}>{busy ? '正在确认…' : '确认绑定并创建账户'}</button>
|
||||
{error && (
|
||||
<div className="notice error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button disabled={busy || totpCode.length !== 6}>
|
||||
{busy ? '正在确认…' : '确认绑定并创建账户'}
|
||||
</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
@@ -305,7 +373,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
<AuthShell
|
||||
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
|
||||
title={isSetup ? '创建系统管理员' : '创建你的账户'}
|
||||
footer={<p>已有账户?<a href={authRoute('login')}>返回登录</a></p>}
|
||||
footer={
|
||||
<p>
|
||||
已有账户?<a href={authRoute('login')}>返回登录</a>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p className="auth-lead">
|
||||
{isSetup
|
||||
@@ -350,7 +422,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
|
||||
</label>
|
||||
)}
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
{error && (
|
||||
<div className="notice error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
|
||||
Reference in New Issue
Block a user