feat(admin-ui): TOTP MFA enrollment + two-step login; remove stub 2FA toggle

Frontend for #738.
- mfa.service.ts + MfaSettingsCard (Settings → General → Admin Account):
  per-user setup (QR + manual secret + verify), recovery codes shown once
  (copy/download/confirm), status, regenerate, disable. Renders for
  super_admin (closes #735).
- Two-step login in AdminLoginPage: on {mfaRequired,mfaToken} swap to a
  code step (TOTP or recovery), call /auth/admin/login/mfa; handle
  MFA_INVALID / MFA_SESSION_EXPIRED / 423 lockout.
- Removed the non-functional global enable_2fa checkbox from SecurityTab
  (and its persistence) — replaced with a note pointing to per-user setup.
- en + de i18n.

Verified live in-browser: enroll (QR→code→recovery codes), logout, and
the two-step challenge into the dashboard as super_admin.
This commit is contained in:
Paul Nothaft
2026-07-03 11:44:07 +02:00
parent cdbfb514bd
commit 96e3c68b9d
10 changed files with 679 additions and 24 deletions
+13 -4
View File
@@ -1,5 +1,5 @@
import { api } from '../config/api';
import type { LoginResponse, GalleryAuthResponse, AdminUser } from '../types';
import type { LoginResponse, AdminLoginResponse, GalleryAuthResponse, AdminUser } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
@@ -14,9 +14,10 @@ const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthRes
export const authService = {
// Admin authentication
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<LoginResponse> {
// Backend expects 'username' field, but we accept email
const response = await api.post<LoginResponse>('/auth/admin/login', {
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<AdminLoginResponse> {
// Backend expects 'username' field, but we accept email.
// Returns either { user } (session set) or an MFA challenge { mfaRequired, mfaToken }.
const response = await api.post<AdminLoginResponse>('/auth/admin/login', {
username: credentials.email,
password: credentials.password,
recaptchaToken: credentials.recaptchaToken
@@ -24,6 +25,14 @@ export const authService = {
return response.data;
},
// Second step of the two-step admin login. `code` accepts a 6-digit TOTP
// or a recovery code (e.g. "awzq-jca3-va"). On success the session cookie
// is set server-side and the user object is returned.
async adminLoginMfa(payload: { mfaToken: string; code: string }): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/auth/admin/login/mfa', payload);
return response.data;
},
async adminLogout() {
try {
await api.post('/auth/logout');
+50
View File
@@ -0,0 +1,50 @@
import { api } from '../config/api';
// Per-user admin TOTP MFA (issue #738). All endpoints operate on the
// currently authenticated admin's own account.
export interface MfaStatus {
enabled: boolean;
enrolledAt: string | null;
recoveryCodesRemaining: number;
}
export interface MfaSetupResponse {
secret: string;
otpauthUri: string;
qr: string; // PNG data URL
issuer: string;
account: string;
}
export interface MfaRecoveryCodesResponse {
message: string;
recoveryCodes: string[];
}
export const mfaService = {
async getStatus(): Promise<MfaStatus> {
const response = await api.get<MfaStatus>('/admin/auth/mfa/status');
return response.data;
},
async setup(): Promise<MfaSetupResponse> {
const response = await api.post<MfaSetupResponse>('/admin/auth/mfa/setup');
return response.data;
},
async enable(code: string): Promise<MfaRecoveryCodesResponse> {
const response = await api.post<MfaRecoveryCodesResponse>('/admin/auth/mfa/enable', { code });
return response.data;
},
async disable(code: string): Promise<{ message: string }> {
const response = await api.post<{ message: string }>('/admin/auth/mfa/disable', { code });
return response.data;
},
async regenerateRecoveryCodes(code: string): Promise<MfaRecoveryCodesResponse> {
const response = await api.post<MfaRecoveryCodesResponse>('/admin/auth/mfa/recovery-codes', { code });
return response.data;
},
};