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:
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user