Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 46s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 16s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Failing after 5m8s
Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
5.2 KiB
JavaScript
164 lines
5.2 KiB
JavaScript
import { useEffect, useState } from 'react'
|
|
import api from '../utils/api'
|
|
|
|
/**
|
|
* TOTP einrichten: QR-Code und Geheimnis anzeigen, Code bestätigen, Wiederherstellungscodes einmal zeigen.
|
|
*/
|
|
export default function TotpSetupCard({ onEnabled }) {
|
|
const [secret, setSecret] = useState('')
|
|
const [otpauthUri, setOtpauthUri] = useState('')
|
|
const [qrDataUrl, setQrDataUrl] = useState('')
|
|
const [code, setCode] = useState('')
|
|
const [recoveryCodes, setRecoveryCodes] = useState([])
|
|
const [error, setError] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!otpauthUri) {
|
|
setQrDataUrl('')
|
|
return undefined
|
|
}
|
|
let cancelled = false
|
|
import('qrcode')
|
|
.then((mod) => {
|
|
const QRCode = typeof mod.toDataURL === 'function' ? mod : mod.default
|
|
return QRCode.toDataURL(otpauthUri, {
|
|
width: 240,
|
|
margin: 2,
|
|
errorCorrectionLevel: 'M',
|
|
color: { dark: '#000000', light: '#ffffff' },
|
|
})
|
|
})
|
|
.then((url) => {
|
|
if (!cancelled) setQrDataUrl(url)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setQrDataUrl('')
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [otpauthUri])
|
|
|
|
const start = async () => {
|
|
setError('')
|
|
setBusy(true)
|
|
try {
|
|
const data = await api.setupTotp()
|
|
setSecret(data.secret || '')
|
|
setOtpauthUri(data.otpauth_uri || '')
|
|
setRecoveryCodes([])
|
|
} catch (err) {
|
|
setError(err.message || 'Einrichtung fehlgeschlagen')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const confirm = async (e) => {
|
|
e.preventDefault()
|
|
setError('')
|
|
setBusy(true)
|
|
try {
|
|
const data = await api.confirmTotp(code.trim())
|
|
if (data?.token) localStorage.setItem('authToken', data.token)
|
|
setRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
|
|
setSecret('')
|
|
setOtpauthUri('')
|
|
setCode('')
|
|
} catch (err) {
|
|
setError(err.message || 'Code nicht akzeptiert')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="card" style={{ marginBottom: '1rem' }}>
|
|
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor-Anmeldung</h2>
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', lineHeight: 1.5 }}>
|
|
Authenticator-App öffnen, ein Konto hinzufügen und den QR-Code scannen. Wenn das Scannen
|
|
nicht klappt, das Geheimnis darunter manuell eintragen.
|
|
</p>
|
|
{error ? (
|
|
<p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>
|
|
) : null}
|
|
{!secret && recoveryCodes.length === 0 ? (
|
|
<button type="button" className="btn btn-primary" onClick={start} disabled={busy}>
|
|
{busy ? 'Wird erzeugt…' : 'QR-Code erzeugen'}
|
|
</button>
|
|
) : null}
|
|
{secret ? (
|
|
<form onSubmit={confirm}>
|
|
{qrDataUrl ? (
|
|
<div
|
|
style={{
|
|
display: 'inline-block',
|
|
background: '#ffffff',
|
|
padding: 8,
|
|
borderRadius: 8,
|
|
margin: '0.25rem 0 0.85rem',
|
|
}}
|
|
>
|
|
<img
|
|
src={qrDataUrl}
|
|
alt="QR-Code zum Einrichten in der Authenticator-App"
|
|
width={240}
|
|
height={240}
|
|
style={{ display: 'block', width: 240, height: 240 }}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<p style={{ fontFamily: 'ui-monospace, monospace', letterSpacing: '0.04em', wordBreak: 'break-all' }}>{secret}</p>
|
|
<label className="form-label" htmlFor="totp-confirm-code">
|
|
Code aus der App
|
|
</label>
|
|
<input
|
|
id="totp-confirm-code"
|
|
className="form-input"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
/>
|
|
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: '0.75rem' }}>
|
|
{busy ? 'Prüft…' : 'Aktivieren'}
|
|
</button>
|
|
</form>
|
|
) : null}
|
|
{recoveryCodes.length > 0 ? (
|
|
<div>
|
|
<p style={{ fontSize: '0.875rem' }}>
|
|
Diese acht Codes jetzt sichern. Die Seite bleibt stehen, bis du unten bestätigst.
|
|
Ein Neuladen löscht die Anzeige. Neue Codes kannst du danach in den Einstellungen erzeugen.
|
|
</p>
|
|
<textarea
|
|
readOnly
|
|
rows={8}
|
|
className="form-input"
|
|
value={recoveryCodes.join('\n')}
|
|
style={{ fontFamily: 'ui-monospace, monospace', marginBottom: '0.75rem' }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ marginRight: '0.5rem' }}
|
|
onClick={() => navigator.clipboard?.writeText(recoveryCodes.join('\n'))}
|
|
>
|
|
Kopieren
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() => {
|
|
if (onEnabled) onEnabled()
|
|
}}
|
|
>
|
|
Ich habe die Codes gespeichert
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|