Show a scannable QR code when setting up two-factor authentication.
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
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>
This commit is contained in:
parent
2e22a7c8e0
commit
5ea225ec6b
|
|
@ -1,12 +1,12 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.240"
|
APP_VERSION = "0.8.241"
|
||||||
BUILD_DATE = "2026-10-13"
|
BUILD_DATE = "2026-10-13"
|
||||||
DB_SCHEMA_VERSION = "20261012096"
|
DB_SCHEMA_VERSION = "20261012096"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
"auth": "1.3.1", # TOTP freiwillig; Wiederherstellungscodes bleiben bis zur Bestätigung stehen
|
"auth": "1.3.2", # TOTP-Einrichtung zeigt einen QR-Code zum Scannen
|
||||||
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
|
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
|
||||||
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
|
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
|
||||||
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
|
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
|
||||||
|
|
@ -53,6 +53,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.241",
|
||||||
|
"date": "2026-10-13",
|
||||||
|
"changes": [
|
||||||
|
"Zwei-Faktor-Einrichtung zeigt einen QR-Code. Die Authenticator-App kann das Konto scannen, das Geheimnis bleibt als Ersatz sichtbar.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.240",
|
"version": "0.8.240",
|
||||||
"date": "2026-10-13",
|
"date": "2026-10-13",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"marked": "^18.0.3",
|
"marked": "^18.0.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,45 @@
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TOTP einrichten: Geheimnis anzeigen, Code bestätigen, Wiederherstellungscodes einmal zeigen.
|
* TOTP einrichten: QR-Code und Geheimnis anzeigen, Code bestätigen, Wiederherstellungscodes einmal zeigen.
|
||||||
*/
|
*/
|
||||||
export default function TotpSetupCard({ onEnabled }) {
|
export default function TotpSetupCard({ onEnabled }) {
|
||||||
const [secret, setSecret] = useState('')
|
const [secret, setSecret] = useState('')
|
||||||
const [otpauthUri, setOtpauthUri] = useState('')
|
const [otpauthUri, setOtpauthUri] = useState('')
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState('')
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
const [recoveryCodes, setRecoveryCodes] = useState([])
|
const [recoveryCodes, setRecoveryCodes] = useState([])
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
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 () => {
|
const start = async () => {
|
||||||
setError('')
|
setError('')
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
|
@ -36,6 +64,7 @@ export default function TotpSetupCard({ onEnabled }) {
|
||||||
if (data?.token) localStorage.setItem('authToken', data.token)
|
if (data?.token) localStorage.setItem('authToken', data.token)
|
||||||
setRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
|
setRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
|
||||||
setSecret('')
|
setSecret('')
|
||||||
|
setOtpauthUri('')
|
||||||
setCode('')
|
setCode('')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Code nicht akzeptiert')
|
setError(err.message || 'Code nicht akzeptiert')
|
||||||
|
|
@ -48,23 +77,39 @@ export default function TotpSetupCard({ onEnabled }) {
|
||||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor-Anmeldung</h2>
|
<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 }}>
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', lineHeight: 1.5 }}>
|
||||||
Authenticator-App (zum Beispiel Aegis, 1Password oder Microsoft Authenticator) öffnen und das
|
Authenticator-App öffnen, ein Konto hinzufügen und den QR-Code scannen. Wenn das Scannen
|
||||||
Geheimnis manuell eintragen. Den otpauth-Link nicht an Dritte geben.
|
nicht klappt, das Geheimnis darunter manuell eintragen.
|
||||||
</p>
|
</p>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>
|
<p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{!secret && recoveryCodes.length === 0 ? (
|
{!secret && recoveryCodes.length === 0 ? (
|
||||||
<button type="button" className="btn btn-primary" onClick={start} disabled={busy}>
|
<button type="button" className="btn btn-primary" onClick={start} disabled={busy}>
|
||||||
{busy ? 'Wird erzeugt…' : 'Geheimnis erzeugen'}
|
{busy ? 'Wird erzeugt…' : 'QR-Code erzeugen'}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{secret ? (
|
{secret ? (
|
||||||
<form onSubmit={confirm}>
|
<form onSubmit={confirm}>
|
||||||
<p style={{ fontFamily: 'ui-monospace, monospace', letterSpacing: '0.04em' }}>{secret}</p>
|
{qrDataUrl ? (
|
||||||
{otpauthUri ? (
|
<div
|
||||||
<p style={{ fontSize: '0.75rem', wordBreak: 'break-all', color: 'var(--text3)' }}>{otpauthUri}</p>
|
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}
|
) : null}
|
||||||
|
<p style={{ fontFamily: 'ui-monospace, monospace', letterSpacing: '0.04em', wordBreak: 'break-all' }}>{secret}</p>
|
||||||
<label className="form-label" htmlFor="totp-confirm-code">
|
<label className="form-label" htmlFor="totp-confirm-code">
|
||||||
Code aus der App
|
Code aus der App
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,90 @@ function AccountSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{user?.totp_enabled ? (
|
||||||
|
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||||
|
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor ist aktiv</h2>
|
||||||
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
|
||||||
|
Beim Anmelden wird zusätzlich der Code aus der Authenticator-App verlangt.
|
||||||
|
Die ersten Codes lassen sich nicht erneut anzeigen. Hier entstehen neue, die alten gelten danach nicht mehr.
|
||||||
|
</p>
|
||||||
|
{freshRecoveryCodes.length > 0 ? (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
rows={8}
|
||||||
|
className="form-input"
|
||||||
|
value={freshRecoveryCodes.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(freshRecoveryCodes.join('\n'))}
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setFreshRecoveryCodes([])}>
|
||||||
|
Ich habe die Codes gespeichert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDisablingTotp(true)
|
||||||
|
try {
|
||||||
|
const data = await api.regenerateRecoveryCodes(disablePw, disableCode.trim())
|
||||||
|
setFreshRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
|
||||||
|
setDisableCode('')
|
||||||
|
showOk('Neue Wiederherstellungscodes sind da. Die bisherigen gelten nicht mehr.')
|
||||||
|
} catch (err) {
|
||||||
|
showErr(err.message || 'Neue Codes konnten nicht erzeugt werden')
|
||||||
|
} finally {
|
||||||
|
setDisablingTotp(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<label className="form-label" htmlFor="totp-off-pw">Aktuelles Passwort</label>
|
||||||
|
<input id="totp-off-pw" type="password" className="form-input" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} autoComplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<label className="form-label" htmlFor="totp-off-code">Aktueller Code aus der App</label>
|
||||||
|
<input id="totp-off-code" className="form-input" value={disableCode} onChange={(e) => setDisableCode(e.target.value)} autoComplete="one-time-code" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-secondary" disabled={disablingTotp} style={{ marginRight: '0.5rem' }}>
|
||||||
|
{disablingTotp ? 'Bitte warten…' : 'Neue Wiederherstellungscodes'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={disablingTotp}
|
||||||
|
onClick={async () => {
|
||||||
|
setDisablingTotp(true)
|
||||||
|
try {
|
||||||
|
await api.disableTotp(disablePw, disableCode.trim())
|
||||||
|
setDisablePw('')
|
||||||
|
setDisableCode('')
|
||||||
|
setFreshRecoveryCodes([])
|
||||||
|
showOk('Zwei-Faktor ist aus. Den Eintrag in der Authenticator-App kannst du löschen.')
|
||||||
|
await checkAuth()
|
||||||
|
} catch (err) {
|
||||||
|
showErr(err.message || 'Abschalten fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setDisablingTotp(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zwei-Faktor ausschalten
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TotpSetupCard onEnabled={() => checkAuth()} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Profil</h2>
|
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Profil</h2>
|
||||||
<div style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
<div style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||||
|
|
@ -469,90 +553,6 @@ function AccountSettingsPage() {
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user?.totp_enabled ? (
|
|
||||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
|
||||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor ist aktiv</h2>
|
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
|
|
||||||
Beim Anmelden wird zusätzlich der Code aus der Authenticator-App verlangt.
|
|
||||||
Die ersten Codes lassen sich nicht erneut anzeigen. Hier entstehen neue, die alten gelten danach nicht mehr.
|
|
||||||
</p>
|
|
||||||
{freshRecoveryCodes.length > 0 ? (
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<textarea
|
|
||||||
readOnly
|
|
||||||
rows={8}
|
|
||||||
className="form-input"
|
|
||||||
value={freshRecoveryCodes.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(freshRecoveryCodes.join('\n'))}
|
|
||||||
>
|
|
||||||
Kopieren
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setFreshRecoveryCodes([])}>
|
|
||||||
Ich habe die Codes gespeichert
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<form
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setDisablingTotp(true)
|
|
||||||
try {
|
|
||||||
const data = await api.regenerateRecoveryCodes(disablePw, disableCode.trim())
|
|
||||||
setFreshRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
|
|
||||||
setDisableCode('')
|
|
||||||
showOk('Neue Wiederherstellungscodes sind da. Die bisherigen gelten nicht mehr.')
|
|
||||||
} catch (err) {
|
|
||||||
showErr(err.message || 'Neue Codes konnten nicht erzeugt werden')
|
|
||||||
} finally {
|
|
||||||
setDisablingTotp(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
|
||||||
<label className="form-label" htmlFor="totp-off-pw">Aktuelles Passwort</label>
|
|
||||||
<input id="totp-off-pw" type="password" className="form-input" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} autoComplete="current-password" />
|
|
||||||
</div>
|
|
||||||
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
|
||||||
<label className="form-label" htmlFor="totp-off-code">Aktueller Code aus der App</label>
|
|
||||||
<input id="totp-off-code" className="form-input" value={disableCode} onChange={(e) => setDisableCode(e.target.value)} autoComplete="one-time-code" />
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="btn btn-secondary" disabled={disablingTotp} style={{ marginRight: '0.5rem' }}>
|
|
||||||
{disablingTotp ? 'Bitte warten…' : 'Neue Wiederherstellungscodes'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={disablingTotp}
|
|
||||||
onClick={async () => {
|
|
||||||
setDisablingTotp(true)
|
|
||||||
try {
|
|
||||||
await api.disableTotp(disablePw, disableCode.trim())
|
|
||||||
setDisablePw('')
|
|
||||||
setDisableCode('')
|
|
||||||
setFreshRecoveryCodes([])
|
|
||||||
showOk('Zwei-Faktor ist aus. Den Eintrag in der Authenticator-App kannst du löschen.')
|
|
||||||
await checkAuth()
|
|
||||||
} catch (err) {
|
|
||||||
showErr(err.message || 'Abschalten fehlgeschlagen')
|
|
||||||
} finally {
|
|
||||||
setDisablingTotp(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Zwei-Faktor ausschalten
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<TotpSetupCard onEnabled={() => checkAuth()} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Passwort ändern</h2>
|
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Passwort ändern</h2>
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user