/**
* 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 {
api,
copyToClipboard,
errorMessage,
json,
normalizeEnrollment,
normalizeRecoveryCodes,
} from './api'
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
import { LanguageSelect, useI18n } from './i18n'
import type { TotpEnrollment } from './types'
function AuthShell({
eyebrow,
title,
children,
footer,
}: {
eyebrow: string
title: string
children: ReactNode
footer?: ReactNode
}) {
const { t } = useI18n()
return (
{t('common.star')}
{eyebrow}
{title}
{children}
{footer && }
)
}
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
const { t } = useI18n()
const source = useMemo(() => {
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
if (enrollment.qrSvg)
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
return undefined
}, [enrollment.qrDataUrl, enrollment.qrSvg])
return (
{source ? (
) : (
{t('auth.qr_unavailable')}
)}
{t('auth.bind_authenticator')}
{t('auth.totp_step_scan')}
{t('auth.totp_step_manual')}
{t('auth.totp_step_confirm')}
{t('auth.manual_key')}
{enrollment.manualKey || t('common.not_provided')}
{enrollment.manualKey && (
void copyToClipboard(enrollment.manualKey)}
>
{t('common.copy')}
)}
)
}
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(
[`${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)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = 'live-component-recovery-codes.txt'
anchor.click()
URL.revokeObjectURL(url)
}
return (
{t('auth.recovery_description')}
{codes.length > 0 ? (
{codes.map(code => (
{code}
))}
) : (
{t('auth.no_recovery_codes')}
)}
{codes.length > 0 && (
<>
void copyToClipboard(text).then(setCopied)}
>
{copied ? t('common.copied') : t('auth.copy_all')}
{t('auth.download_text')}
>
)}
{t('auth.recovery_saved')}
)
}
export function LoginPage({
onAuthenticated,
setupRequired,
}: {
onAuthenticated: () => Promise
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', t('auth.login_blocker'), busy || Boolean(username || totpCode))
const submit = async (event: FormEvent) => {
event.preventDefault()
setBusy(true)
setError('')
try {
await api('/api/v1/auth/login', json('POST', { username: username.trim(), code: totpCode }))
setTotpCode('')
await onAuthenticated()
location.assign('/control/')
} catch (reason) {
setError(errorMessage(reason, t('auth.login_failed')))
} finally {
setBusy(false)
}
}
return (
{setupRequired ? (
<>
{t('auth.first_deployment')} {t('auth.create_admin')}
>
) : (
<>
{t('auth.have_invitation')}{' '}
{t('auth.register_account')}
>
)}
}
>
{t('auth.passwordless_description')}
)
}
export function EnrollmentPage({
mode,
onAuthenticated,
}: {
mode: 'setup' | 'register'
onAuthenticated: () => Promise
}) {
const { language, t } = useI18n()
const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? ''
const [inviteCode, setInviteCode] = useState(inviteFromFragment)
const [username, setUsername] = useState('')
const [bootstrapPassword, setBootstrapPassword] = useState('')
const [enrollment, setEnrollment] = useState()
const [totpCode, setTotpCode] = useState('')
const [recoveryCodes, setRecoveryCodes] = useState()
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const isSetup = mode === 'setup'
usePwaUpdateBlocker(
'totp-enrollment',
enrollment || recoveryCodes ? t('auth.enrollment_blocker') : t('auth.registration_blocker'),
busy ||
Boolean(
inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
),
)
const start = async (event: FormEvent) => {
event.preventDefault()
setBusy(true)
setError('')
try {
const payload = await api(
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/start`,
json('POST', {
...(isSetup ? {} : { inviteCode: inviteCode.trim() }),
username: username.trim(),
...(isSetup ? { bootstrapPassword } : {}),
}),
)
const next = normalizeEnrollment(payload)
if (!next.enrollmentToken) throw new Error(t('auth.registration_missing_id'))
setEnrollment(next)
setBootstrapPassword('')
if (!isSetup) {
setInviteCode('')
history.replaceState(null, '', authRoute('register'))
}
} catch (reason) {
setError(errorMessage(reason, t('auth.registration_start_failed')))
} finally {
setBusy(false)
}
}
const confirm = async (event: FormEvent) => {
event.preventDefault()
if (!enrollment) return
setBusy(true)
setError('')
try {
const payload = await api(
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`,
json('POST', {
enrollmentToken: enrollment.enrollmentToken,
code: totpCode,
language,
}),
)
setTotpCode('')
// Drop QR/manual-key material from React state as soon as enrollment is
// committed; only the one-time recovery codes remain on screen.
setEnrollment(undefined)
setRecoveryCodes(normalizeRecoveryCodes(payload))
} catch (reason) {
setError(errorMessage(reason, t('auth.registration_confirm_failed')))
} finally {
setBusy(false)
}
}
const finish = async () => {
await onAuthenticated()
location.assign('/control/')
}
if (recoveryCodes) return void finish()} />
if (enrollment) {
return (
)
}
return (
{t('auth.already_account')} {t('auth.back_to_login')}
}
>
{isSetup ? t('auth.setup_description') : t('auth.register_description')}
)
}