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:
@@ -0,0 +1,320 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { ShieldCheck, ShieldOff, Copy, Download, Check, KeyRound, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button, Card, Input, Loading, useConfirm } from '../../../components/common';
|
||||||
|
import { mfaService } from '../../../services/mfa.service';
|
||||||
|
|
||||||
|
// Per-user admin TOTP MFA management (issue #738). Lives on the admin's own
|
||||||
|
// account surface (Settings → General → Admin Account). Self-service: acts on
|
||||||
|
// the currently authenticated admin only.
|
||||||
|
|
||||||
|
interface RecoveryCodesPanelProps {
|
||||||
|
codes: string[];
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecoveryCodesPanel: React.FC<RecoveryCodesPanelProps> = ({ codes, onConfirm }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [acknowledged, setAcknowledged] = useState(false);
|
||||||
|
|
||||||
|
const asText = codes.join('\n');
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(asText);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast.error(t('settings.mfa.copyFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const blob = new Blob([`${asText}\n`], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'picpeak-recovery-codes.txt';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-200">{t('settings.mfa.recoveryCodesWarning')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 p-4 rounded-lg bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 font-mono text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
|
{codes.map((code) => (
|
||||||
|
<span key={code} className="select-all">{code}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="sm" leftIcon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} onClick={handleCopy}>
|
||||||
|
{copied ? t('settings.mfa.copied') : t('settings.mfa.copy')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />} onClick={handleDownload}>
|
||||||
|
{t('settings.mfa.download')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={acknowledged}
|
||||||
|
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||||
|
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('settings.mfa.recoveryCodesAck')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button variant="primary" disabled={!acknowledged} onClick={onConfirm}>
|
||||||
|
{t('settings.mfa.done')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MfaSettingsCard: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const { data: status, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-mfa-status'],
|
||||||
|
queryFn: () => mfaService.getStatus(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enrollment flow state
|
||||||
|
const [setupData, setSetupData] = useState<Awaited<ReturnType<typeof mfaService.setup>> | null>(null);
|
||||||
|
const [enableCode, setEnableCode] = useState('');
|
||||||
|
const [enableError, setEnableError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Recovery codes to display once (after enable or regenerate)
|
||||||
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||||
|
|
||||||
|
// Regenerate flow state
|
||||||
|
const [showRegenerate, setShowRegenerate] = useState(false);
|
||||||
|
const [regenerateCode, setRegenerateCode] = useState('');
|
||||||
|
const [regenerateError, setRegenerateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const invalidateStatus = () => queryClient.invalidateQueries({ queryKey: ['admin-mfa-status'] });
|
||||||
|
|
||||||
|
const errorMessage = (error: any, fallbackKey: string): string =>
|
||||||
|
error?.response?.data?.error || t(fallbackKey);
|
||||||
|
|
||||||
|
const setupMutation = useMutation({
|
||||||
|
mutationFn: () => mfaService.setup(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setSetupData(data);
|
||||||
|
setEnableCode('');
|
||||||
|
setEnableError(null);
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error, 'settings.mfa.setupFailed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const enableMutation = useMutation({
|
||||||
|
mutationFn: (code: string) => mfaService.enable(code),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setRecoveryCodes(data.recoveryCodes);
|
||||||
|
setSetupData(null);
|
||||||
|
setEnableCode('');
|
||||||
|
setEnableError(null);
|
||||||
|
invalidateStatus();
|
||||||
|
},
|
||||||
|
onError: (error) => setEnableError(errorMessage(error, 'settings.mfa.enableFailed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const disableMutation = useMutation({
|
||||||
|
mutationFn: (code: string) => mfaService.disable(code),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('settings.mfa.disabledToast'));
|
||||||
|
invalidateStatus();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error, 'settings.mfa.disableFailed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const regenerateMutation = useMutation({
|
||||||
|
mutationFn: (code: string) => mfaService.regenerateRecoveryCodes(code),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setRecoveryCodes(data.recoveryCodes);
|
||||||
|
setShowRegenerate(false);
|
||||||
|
setRegenerateCode('');
|
||||||
|
setRegenerateError(null);
|
||||||
|
invalidateStatus();
|
||||||
|
},
|
||||||
|
onError: (error) => setRegenerateError(errorMessage(error, 'settings.mfa.regenerateFailed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDisable = async () => {
|
||||||
|
const code = window.prompt(t('settings.mfa.disablePrompt'));
|
||||||
|
if (code === null) return;
|
||||||
|
const trimmed = code.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
toast.error(t('settings.mfa.codeRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t('settings.mfa.disableConfirmTitle'),
|
||||||
|
message: t('settings.mfa.disableConfirmMessage'),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmLabel: t('settings.mfa.disableConfirmButton'),
|
||||||
|
});
|
||||||
|
if (ok) disableMutation.mutate(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding="md">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<ShieldCheck className="w-5 h-5 text-neutral-700 dark:text-neutral-300" />
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('settings.mfa.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">{t('settings.mfa.description')}</p>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-8 flex justify-center">
|
||||||
|
<Loading size="md" />
|
||||||
|
</div>
|
||||||
|
) : recoveryCodes ? (
|
||||||
|
<RecoveryCodesPanel codes={recoveryCodes} onConfirm={() => setRecoveryCodes(null)} />
|
||||||
|
) : status?.enabled ? (
|
||||||
|
/* ---------------- Enrolled ---------------- */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-green-800 dark:text-green-200">{t('settings.mfa.enabledBadge')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t('settings.mfa.recoveryCodesRemaining', { count: status.recoveryCodesRemaining })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showRegenerate ? (
|
||||||
|
<div className="space-y-3 p-4 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">{t('settings.mfa.regenerateHelp')}</p>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={regenerateCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setRegenerateCode(e.target.value);
|
||||||
|
if (regenerateError) setRegenerateError(null);
|
||||||
|
}}
|
||||||
|
placeholder={t('settings.mfa.codePlaceholder')}
|
||||||
|
leftIcon={<KeyRound className="w-5 h-5 text-neutral-400" />}
|
||||||
|
error={regenerateError || undefined}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
isLoading={regenerateMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const trimmed = regenerateCode.trim();
|
||||||
|
if (!trimmed) { setRegenerateError(t('settings.mfa.codeRequired')); return; }
|
||||||
|
regenerateMutation.mutate(trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.mfa.regenerateConfirm')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => { setShowRegenerate(false); setRegenerateCode(''); setRegenerateError(null); }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowRegenerate(true)}>
|
||||||
|
{t('settings.mfa.regenerate')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftIcon={<ShieldOff className="w-4 h-4" />}
|
||||||
|
isLoading={disableMutation.isPending}
|
||||||
|
onClick={handleDisable}
|
||||||
|
>
|
||||||
|
{t('settings.mfa.disable')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : setupData ? (
|
||||||
|
/* ---------------- Setup in progress ---------------- */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">{t('settings.mfa.setupScanInstruction')}</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-start">
|
||||||
|
<img
|
||||||
|
src={setupData.qr}
|
||||||
|
alt={t('settings.mfa.qrAlt')}
|
||||||
|
className="w-44 h-44 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white p-2"
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.mfa.manualEntry')}</p>
|
||||||
|
<code className="block px-3 py-2 rounded bg-neutral-100 dark:bg-neutral-800 text-sm font-mono text-neutral-900 dark:text-neutral-100 break-all select-all">
|
||||||
|
{setupData.secret}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="mfa-enable-code" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('settings.mfa.enterCodeLabel')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="mfa-enable-code"
|
||||||
|
type="text"
|
||||||
|
value={enableCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEnableCode(e.target.value);
|
||||||
|
if (enableError) setEnableError(null);
|
||||||
|
}}
|
||||||
|
placeholder={t('settings.mfa.codePlaceholder')}
|
||||||
|
leftIcon={<KeyRound className="w-5 h-5 text-neutral-400" />}
|
||||||
|
error={enableError || undefined}
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
isLoading={enableMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const trimmed = enableCode.trim();
|
||||||
|
if (!trimmed) { setEnableError(t('settings.mfa.codeRequired')); return; }
|
||||||
|
enableMutation.mutate(trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.mfa.enable')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => { setSetupData(null); setEnableCode(''); setEnableError(null); }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ---------------- Not enrolled ---------------- */
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.mfa.notEnrolled')}</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={<ShieldCheck className="w-5 h-5" />}
|
||||||
|
isLoading={setupMutation.isPending}
|
||||||
|
onClick={() => setupMutation.mutate()}
|
||||||
|
>
|
||||||
|
{t('settings.mfa.setUp')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -35,7 +35,6 @@ export interface GeneralSettings {
|
|||||||
export interface SecuritySettings {
|
export interface SecuritySettings {
|
||||||
password_min_length: number;
|
password_min_length: number;
|
||||||
password_complexity: string;
|
password_complexity: string;
|
||||||
enable_2fa: boolean;
|
|
||||||
session_timeout_minutes: number;
|
session_timeout_minutes: number;
|
||||||
max_login_attempts: number;
|
max_login_attempts: number;
|
||||||
attempt_window_minutes: number;
|
attempt_window_minutes: number;
|
||||||
@@ -134,7 +133,6 @@ export function useSettingsState() {
|
|||||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
|
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
|
||||||
password_min_length: 8,
|
password_min_length: 8,
|
||||||
password_complexity: 'moderate',
|
password_complexity: 'moderate',
|
||||||
enable_2fa: false,
|
|
||||||
session_timeout_minutes: 60,
|
session_timeout_minutes: 60,
|
||||||
max_login_attempts: 5,
|
max_login_attempts: 5,
|
||||||
attempt_window_minutes: 15,
|
attempt_window_minutes: 15,
|
||||||
@@ -231,7 +229,6 @@ export function useSettingsState() {
|
|||||||
setSecuritySettings({
|
setSecuritySettings({
|
||||||
password_min_length: toNumber(settings.security_password_min_length, 8),
|
password_min_length: toNumber(settings.security_password_min_length, 8),
|
||||||
password_complexity: settings.security_password_complexity ?? 'moderate',
|
password_complexity: settings.security_password_complexity ?? 'moderate',
|
||||||
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
|
||||||
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
|
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
|
||||||
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
|
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
|
||||||
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
|
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import type { GeneralSettings } from '../hooks/useSettingsState';
|
import type { GeneralSettings } from '../hooks/useSettingsState';
|
||||||
import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState';
|
import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState';
|
||||||
import { SUPPORTED_LANGUAGES } from "../../../components/common/LanguageSelector.tsx";
|
import { SUPPORTED_LANGUAGES } from "../../../components/common/LanguageSelector.tsx";
|
||||||
|
import { MfaSettingsCard } from '../components/MfaSettingsCard';
|
||||||
|
|
||||||
interface GeneralTabProps {
|
interface GeneralTabProps {
|
||||||
generalSettings: GeneralSettings;
|
generalSettings: GeneralSettings;
|
||||||
@@ -94,6 +95,10 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Per-user two-factor authentication (issue #738) — lives beside the
|
||||||
|
admin's own account details rather than the admin-wide Security tab. */}
|
||||||
|
<MfaSettingsCard />
|
||||||
|
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.general.siteConfiguration')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.general.siteConfiguration')}</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Save, Key, AlertCircle } from 'lucide-react';
|
import { Save, Key, AlertCircle, ShieldCheck } from 'lucide-react';
|
||||||
import { Button, Card, Input } from '../../../components/common';
|
import { Button, Card, Input } from '../../../components/common';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { SecuritySettings } from '../hooks/useSettingsState';
|
import type { SecuritySettings } from '../hooks/useSettingsState';
|
||||||
@@ -124,15 +124,15 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center">
|
<div className="p-4 bg-neutral-50 dark:bg-neutral-800/60 border border-neutral-200 dark:border-neutral-700 rounded-lg">
|
||||||
<input
|
<div className="flex items-start gap-3">
|
||||||
type="checkbox"
|
<ShieldCheck className="w-5 h-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
|
||||||
checked={securitySettings.enable_2fa}
|
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_2fa: e.target.checked }))}
|
<p className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.security.twoFactorTitle')}</p>
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
<p className="mt-1">{t('settings.security.twoFactorNote')}</p>
|
||||||
/>
|
</div>
|
||||||
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.security.enable2FA')}</span>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1422,13 +1422,14 @@
|
|||||||
"attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden",
|
"attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden",
|
||||||
"lockoutDurationMinutes": "Sperrdauer (Minuten)",
|
"lockoutDurationMinutes": "Sperrdauer (Minuten)",
|
||||||
"lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben",
|
"lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben",
|
||||||
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
|
|
||||||
"recaptchaSettings": "reCAPTCHA-Einstellungen",
|
"recaptchaSettings": "reCAPTCHA-Einstellungen",
|
||||||
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
|
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
|
||||||
"siteKey": "Site-Schlüssel",
|
"siteKey": "Site-Schlüssel",
|
||||||
"secretKey": "Geheimer Schlüssel",
|
"secretKey": "Geheimer Schlüssel",
|
||||||
"recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von",
|
"recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von",
|
||||||
"saveSecuritySettings": "Sicherheitseinstellungen speichern"
|
"saveSecuritySettings": "Sicherheitseinstellungen speichern",
|
||||||
|
"twoFactorTitle": "Zwei-Faktor-Authentifizierung",
|
||||||
|
"twoFactorNote": "Die Zwei-Faktor-Authentifizierung wird jetzt pro Admin unter Einstellungen → Allgemein → Admin-Konto verwaltet. Jeder Admin aktiviert sie für seine eigene Anmeldung."
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Veranstaltungserstellung",
|
"title": "Veranstaltungserstellung",
|
||||||
@@ -2040,6 +2041,42 @@
|
|||||||
"testSend": "Test senden",
|
"testSend": "Test senden",
|
||||||
"testSending": "Senden…",
|
"testSending": "Senden…",
|
||||||
"testSentToast": "Testnachricht gesendet (ID: {{id}})."
|
"testSentToast": "Testnachricht gesendet (ID: {{id}})."
|
||||||
|
},
|
||||||
|
"mfa": {
|
||||||
|
"title": "Zwei-Faktor-Authentifizierung",
|
||||||
|
"description": "Sichere deine Admin-Anmeldung mit einem zweiten Schritt über eine Authenticator-App (TOTP).",
|
||||||
|
"notEnrolled": "Die Zwei-Faktor-Authentifizierung ist für dein Konto nicht aktiviert.",
|
||||||
|
"setUp": "Einrichten",
|
||||||
|
"setupScanInstruction": "Scanne diesen QR-Code mit deiner Authenticator-App (z. B. Google Authenticator, 1Password, Authy).",
|
||||||
|
"manualEntry": "Oder gib diesen Schlüssel manuell ein:",
|
||||||
|
"qrAlt": "QR-Code zur Zwei-Faktor-Einrichtung",
|
||||||
|
"enterCodeLabel": "Gib den 6-stelligen Code aus deiner App ein",
|
||||||
|
"codePlaceholder": "123456",
|
||||||
|
"enable": "Aktivieren",
|
||||||
|
"enabledBadge": "Die Zwei-Faktor-Authentifizierung ist aktiviert.",
|
||||||
|
"recoveryCodesRemaining": "Noch {{count}} Wiederherstellungscode übrig.",
|
||||||
|
"recoveryCodesRemaining_other": "Noch {{count}} Wiederherstellungscodes übrig.",
|
||||||
|
"regenerate": "Wiederherstellungscodes neu erzeugen",
|
||||||
|
"regenerateHelp": "Gib einen aktuellen Authentifizierungscode ein, um neue Wiederherstellungscodes zu erzeugen. Deine alten Codes werden ungültig.",
|
||||||
|
"regenerateConfirm": "Neu erzeugen",
|
||||||
|
"disable": "Deaktivieren",
|
||||||
|
"disablePrompt": "Gib einen aktuellen Authentifizierungs- oder Wiederherstellungscode ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren:",
|
||||||
|
"disableConfirmTitle": "Zwei-Faktor-Authentifizierung deaktivieren?",
|
||||||
|
"disableConfirmMessage": "Für dein Konto ist bei der Anmeldung dann kein zweiter Schritt mehr erforderlich. Du kannst sie jederzeit wieder aktivieren.",
|
||||||
|
"disableConfirmButton": "Deaktivieren",
|
||||||
|
"disabledToast": "Zwei-Faktor-Authentifizierung deaktiviert.",
|
||||||
|
"codeRequired": "Ein Code ist erforderlich.",
|
||||||
|
"recoveryCodesWarning": "Speichere diese Wiederherstellungscodes jetzt. Jeder kann einmal verwendet werden, falls du den Zugriff auf deine Authenticator-App verlierst. Sie werden nicht erneut angezeigt.",
|
||||||
|
"recoveryCodesAck": "Ich habe meine Wiederherstellungscodes an einem sicheren Ort gespeichert.",
|
||||||
|
"copy": "Kopieren",
|
||||||
|
"copied": "Kopiert",
|
||||||
|
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen.",
|
||||||
|
"download": ".txt herunterladen",
|
||||||
|
"done": "Fertig",
|
||||||
|
"setupFailed": "Zwei-Faktor-Einrichtung konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||||
|
"enableFailed": "Zwei-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfe den Code und versuche es erneut.",
|
||||||
|
"disableFailed": "Zwei-Faktor-Authentifizierung konnte nicht deaktiviert werden. Prüfe den Code und versuche es erneut.",
|
||||||
|
"regenerateFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden. Prüfe den Code und versuche es erneut."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"branding": {
|
"branding": {
|
||||||
@@ -3585,7 +3622,25 @@
|
|||||||
"generalError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
|
"generalError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
|
||||||
"needHelp": "Hilfe benötigt? Kontakt",
|
"needHelp": "Hilfe benötigt? Kontakt",
|
||||||
"poweredBy": "Bereitgestellt von PicPeak",
|
"poweredBy": "Bereitgestellt von PicPeak",
|
||||||
"devModeHint": "Entwicklungsmodus: E-Mail: [email protected], Passwort: admin123"
|
"devModeHint": "Entwicklungsmodus: E-Mail: [email protected], Passwort: admin123",
|
||||||
|
"mfa": {
|
||||||
|
"title": "Zwei-Faktor-Authentifizierung",
|
||||||
|
"subtitle": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||||
|
"recoverySubtitle": "Gib einen deiner Wiederherstellungscodes ein.",
|
||||||
|
"codeLabel": "Authentifizierungscode",
|
||||||
|
"codePlaceholder": "123456",
|
||||||
|
"recoveryCodeLabel": "Wiederherstellungscode",
|
||||||
|
"recoveryCodePlaceholder": "awzq-jca3-va",
|
||||||
|
"verify": "Bestätigen",
|
||||||
|
"back": "Zurück",
|
||||||
|
"useRecoveryCode": "Stattdessen Wiederherstellungscode verwenden",
|
||||||
|
"useAuthenticator": "Stattdessen Authenticator-App verwenden",
|
||||||
|
"codeRequired": "Gib deinen Authentifizierungscode ein",
|
||||||
|
"invalidCode": "Ungültiger Code. Bitte versuche es erneut.",
|
||||||
|
"sessionExpired": "Deine Bestätigungssitzung ist abgelaufen. Bitte melde dich erneut an.",
|
||||||
|
"locked": "Konto wegen zu vieler Versuche vorübergehend gesperrt. Versuche es später erneut.",
|
||||||
|
"lockedRetry": "Konto vorübergehend gesperrt. Versuche es in {{seconds}} Sekunden erneut."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"cssTemplates": {
|
"cssTemplates": {
|
||||||
"title": "Benutzerdefinierte CSS-Vorlagen",
|
"title": "Benutzerdefinierte CSS-Vorlagen",
|
||||||
|
|||||||
@@ -969,13 +969,14 @@
|
|||||||
"attemptWindowMinutesHelp": "How long to look back when counting failed login attempts",
|
"attemptWindowMinutesHelp": "How long to look back when counting failed login attempts",
|
||||||
"lockoutDurationMinutes": "Lockout Duration (minutes)",
|
"lockoutDurationMinutes": "Lockout Duration (minutes)",
|
||||||
"lockoutDurationMinutesHelp": "How long the gallery or account stays locked after too many failures",
|
"lockoutDurationMinutesHelp": "How long the gallery or account stays locked after too many failures",
|
||||||
"enable2FA": "Enable two-factor authentication for admins",
|
|
||||||
"recaptchaSettings": "reCAPTCHA Settings",
|
"recaptchaSettings": "reCAPTCHA Settings",
|
||||||
"enableRecaptcha": "Enable reCAPTCHA for login forms",
|
"enableRecaptcha": "Enable reCAPTCHA for login forms",
|
||||||
"siteKey": "Site Key",
|
"siteKey": "Site Key",
|
||||||
"secretKey": "Secret Key",
|
"secretKey": "Secret Key",
|
||||||
"recaptchaHelp": "Get your reCAPTCHA keys from",
|
"recaptchaHelp": "Get your reCAPTCHA keys from",
|
||||||
"saveSecuritySettings": "Save Security Settings"
|
"saveSecuritySettings": "Save Security Settings",
|
||||||
|
"twoFactorTitle": "Two-factor authentication",
|
||||||
|
"twoFactorNote": "Two-factor authentication is now managed per admin from Settings → General → Admin Account. Each admin enables it for their own login."
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories",
|
"title": "Categories",
|
||||||
@@ -1587,6 +1588,42 @@
|
|||||||
"testSend": "Send test",
|
"testSend": "Send test",
|
||||||
"testSending": "Sending…",
|
"testSending": "Sending…",
|
||||||
"testSentToast": "Test message sent (id: {{id}})."
|
"testSentToast": "Test message sent (id: {{id}})."
|
||||||
|
},
|
||||||
|
"mfa": {
|
||||||
|
"title": "Two-factor authentication",
|
||||||
|
"description": "Add a second step to your admin sign-in using an authenticator app (TOTP).",
|
||||||
|
"notEnrolled": "Two-factor authentication is not enabled for your account.",
|
||||||
|
"setUp": "Set up",
|
||||||
|
"setupScanInstruction": "Scan this QR code with your authenticator app (e.g. Google Authenticator, 1Password, Authy).",
|
||||||
|
"manualEntry": "Or enter this secret manually:",
|
||||||
|
"qrAlt": "Two-factor setup QR code",
|
||||||
|
"enterCodeLabel": "Enter the 6-digit code from your app",
|
||||||
|
"codePlaceholder": "123456",
|
||||||
|
"enable": "Enable",
|
||||||
|
"enabledBadge": "Two-factor authentication is enabled.",
|
||||||
|
"recoveryCodesRemaining": "{{count}} recovery code remaining.",
|
||||||
|
"recoveryCodesRemaining_other": "{{count}} recovery codes remaining.",
|
||||||
|
"regenerate": "Regenerate recovery codes",
|
||||||
|
"regenerateHelp": "Enter a current authentication code to generate a new set of recovery codes. Your old codes will stop working.",
|
||||||
|
"regenerateConfirm": "Regenerate",
|
||||||
|
"disable": "Disable",
|
||||||
|
"disablePrompt": "Enter a current authentication or recovery code to disable two-factor authentication:",
|
||||||
|
"disableConfirmTitle": "Disable two-factor authentication?",
|
||||||
|
"disableConfirmMessage": "Your account will no longer require a second step at sign-in. You can re-enable it at any time.",
|
||||||
|
"disableConfirmButton": "Disable",
|
||||||
|
"disabledToast": "Two-factor authentication disabled.",
|
||||||
|
"codeRequired": "A code is required.",
|
||||||
|
"recoveryCodesWarning": "Save these recovery codes now. Each can be used once if you lose access to your authenticator app. They will not be shown again.",
|
||||||
|
"recoveryCodesAck": "I have saved my recovery codes in a safe place.",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied",
|
||||||
|
"copyFailed": "Failed to copy to clipboard.",
|
||||||
|
"download": "Download .txt",
|
||||||
|
"done": "Done",
|
||||||
|
"setupFailed": "Could not start two-factor setup. Please try again.",
|
||||||
|
"enableFailed": "Could not enable two-factor authentication. Check the code and try again.",
|
||||||
|
"disableFailed": "Could not disable two-factor authentication. Check the code and try again.",
|
||||||
|
"regenerateFailed": "Could not regenerate recovery codes. Check the code and try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
@@ -3481,7 +3518,25 @@
|
|||||||
"generalError": "An error occurred. Please try again.",
|
"generalError": "An error occurred. Please try again.",
|
||||||
"needHelp": "Need help? Contact",
|
"needHelp": "Need help? Contact",
|
||||||
"poweredBy": "Powered by PicPeak",
|
"poweredBy": "Powered by PicPeak",
|
||||||
"devModeHint": "Development Mode: Use email: [email protected], password: admin123"
|
"devModeHint": "Development Mode: Use email: [email protected], password: admin123",
|
||||||
|
"mfa": {
|
||||||
|
"title": "Two-factor authentication",
|
||||||
|
"subtitle": "Enter the 6-digit code from your authenticator app.",
|
||||||
|
"recoverySubtitle": "Enter one of your recovery codes.",
|
||||||
|
"codeLabel": "Authentication code",
|
||||||
|
"codePlaceholder": "123456",
|
||||||
|
"recoveryCodeLabel": "Recovery code",
|
||||||
|
"recoveryCodePlaceholder": "awzq-jca3-va",
|
||||||
|
"verify": "Verify",
|
||||||
|
"back": "Back",
|
||||||
|
"useRecoveryCode": "Use a recovery code instead",
|
||||||
|
"useAuthenticator": "Use your authenticator app instead",
|
||||||
|
"codeRequired": "Enter your authentication code",
|
||||||
|
"invalidCode": "Invalid code. Please try again.",
|
||||||
|
"sessionExpired": "Your verification session expired. Please sign in again.",
|
||||||
|
"locked": "Account temporarily locked due to too many attempts. Try again later.",
|
||||||
|
"lockedRetry": "Account temporarily locked. Try again in {{seconds}} seconds."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"slideshow": {
|
"slideshow": {
|
||||||
"adminTitle": "Live Slideshow",
|
"adminTitle": "Live Slideshow",
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
import { Lock, Mail, Eye, EyeOff, AlertCircle, ShieldCheck, KeyRound, ArrowLeft } from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button, Input, Card, ReCaptcha } from '../../components/common';
|
import { Button, Input, Card, ReCaptcha } from '../../components/common';
|
||||||
import { useAdminAuth } from '../../contexts';
|
import { useAdminAuth } from '../../contexts';
|
||||||
import { authService } from '../../services/auth.service';
|
import { authService } from '../../services/auth.service';
|
||||||
|
import { isMfaChallenge } from '../../types';
|
||||||
import { setupService } from '../../services/setup.service';
|
import { setupService } from '../../services/setup.service';
|
||||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
||||||
@@ -29,6 +30,14 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
const [loginSuccess, setLoginSuccess] = useState(false);
|
const [loginSuccess, setLoginSuccess] = useState(false);
|
||||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Two-step MFA challenge state (issue #738). When the first step returns
|
||||||
|
// { mfaRequired, mfaToken } we swap the form to a code entry step.
|
||||||
|
const [step, setStep] = useState<'credentials' | 'mfa'>('credentials');
|
||||||
|
const [mfaToken, setMfaToken] = useState<string | null>(null);
|
||||||
|
const [mfaCode, setMfaCode] = useState('');
|
||||||
|
const [useRecoveryCode, setUseRecoveryCode] = useState(false);
|
||||||
|
const [mfaError, setMfaError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: settingsData } = usePublicSettings();
|
const { data: settingsData } = usePublicSettings();
|
||||||
const { isDark } = useAdminDarkMode();
|
const { isDark } = useAdminDarkMode();
|
||||||
|
|
||||||
@@ -103,6 +112,15 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
...formData,
|
...formData,
|
||||||
recaptchaToken
|
recaptchaToken
|
||||||
});
|
});
|
||||||
|
// MFA enabled → move to the second step instead of logging in.
|
||||||
|
if (isMfaChallenge(response)) {
|
||||||
|
setMfaToken(response.mfaToken);
|
||||||
|
setMfaCode('');
|
||||||
|
setUseRecoveryCode(false);
|
||||||
|
setMfaError(null);
|
||||||
|
setStep('mfa');
|
||||||
|
return;
|
||||||
|
}
|
||||||
login(response.token, response.user);
|
login(response.token, response.user);
|
||||||
toast.success(t('adminLogin.loginSuccess'));
|
toast.success(t('adminLogin.loginSuccess'));
|
||||||
setLoginSuccess(true);
|
setLoginSuccess(true);
|
||||||
@@ -142,6 +160,62 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const backToCredentials = () => {
|
||||||
|
setStep('credentials');
|
||||||
|
setMfaToken(null);
|
||||||
|
setMfaCode('');
|
||||||
|
setMfaError(null);
|
||||||
|
setUseRecoveryCode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMfaSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toast.dismiss();
|
||||||
|
|
||||||
|
const code = mfaCode.trim();
|
||||||
|
if (!code) {
|
||||||
|
setMfaError(t('adminLogin.mfa.codeRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mfaToken) {
|
||||||
|
// Token lost somehow — restart the flow.
|
||||||
|
toast.info(t('adminLogin.mfa.sessionExpired'));
|
||||||
|
backToCredentials();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setMfaError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authService.adminLoginMfa({ mfaToken, code });
|
||||||
|
login(response.token, response.user);
|
||||||
|
toast.success(t('adminLogin.loginSuccess'));
|
||||||
|
setLoginSuccess(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
const data = error.response?.data;
|
||||||
|
const code = data?.code;
|
||||||
|
if (error.response?.status === 423) {
|
||||||
|
const retryAfter = data?.retryAfter;
|
||||||
|
toast.error(
|
||||||
|
retryAfter
|
||||||
|
? t('adminLogin.mfa.lockedRetry', { seconds: retryAfter })
|
||||||
|
: t('adminLogin.mfa.locked')
|
||||||
|
);
|
||||||
|
backToCredentials();
|
||||||
|
} else if (code === 'MFA_SESSION_EXPIRED') {
|
||||||
|
toast.info(t('adminLogin.mfa.sessionExpired'));
|
||||||
|
backToCredentials();
|
||||||
|
} else if (code === 'MFA_INVALID') {
|
||||||
|
setMfaError(t('adminLogin.mfa.invalidCode'));
|
||||||
|
} else {
|
||||||
|
setMfaError(data?.error || t('adminLogin.generalError'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
@@ -177,6 +251,7 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Login Form */}
|
{/* Login Form */}
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
|
{step === 'credentials' ? (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Form Error */}
|
{/* Form Error */}
|
||||||
{errors.form && (
|
{errors.form && (
|
||||||
@@ -263,6 +338,81 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
{t('adminLogin.signIn')}
|
{t('adminLogin.signIn')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleMfaSubmit} className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto mb-3 w-12 h-12 rounded-full flex items-center justify-center bg-primary-50 dark:bg-primary-900/30">
|
||||||
|
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--color-primary, #5C8762)' }} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text, #171717)' }}>
|
||||||
|
{t('adminLogin.mfa.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||||
|
{useRecoveryCode ? t('adminLogin.mfa.recoverySubtitle') : t('adminLogin.mfa.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mfaError && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-800">{mfaError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="mfa-code" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{useRecoveryCode ? t('adminLogin.mfa.recoveryCodeLabel') : t('adminLogin.mfa.codeLabel')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="mfa-code"
|
||||||
|
type="text"
|
||||||
|
value={mfaCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMfaCode(e.target.value);
|
||||||
|
if (mfaError) setMfaError(null);
|
||||||
|
}}
|
||||||
|
placeholder={useRecoveryCode ? t('adminLogin.mfa.recoveryCodePlaceholder') : t('adminLogin.mfa.codePlaceholder')}
|
||||||
|
leftIcon={<KeyRound className="w-5 h-5 text-neutral-400" />}
|
||||||
|
inputMode={useRecoveryCode ? 'text' : 'numeric'}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t('adminLogin.mfa.verify')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={backToCredentials}
|
||||||
|
className="inline-flex items-center gap-1 text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
{t('adminLogin.mfa.back')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setUseRecoveryCode((v) => !v);
|
||||||
|
setMfaCode('');
|
||||||
|
setMfaError(null);
|
||||||
|
}}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: 'var(--color-primary, #5C8762)' }}
|
||||||
|
>
|
||||||
|
{useRecoveryCode ? t('adminLogin.mfa.useAuthenticator') : t('adminLogin.mfa.useRecoveryCode')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from '../config/api';
|
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';
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
|
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
|
||||||
@@ -14,9 +14,10 @@ const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthRes
|
|||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
// Admin authentication
|
// Admin authentication
|
||||||
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<LoginResponse> {
|
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<AdminLoginResponse> {
|
||||||
// Backend expects 'username' field, but we accept email
|
// Backend expects 'username' field, but we accept email.
|
||||||
const response = await api.post<LoginResponse>('/auth/admin/login', {
|
// Returns either { user } (session set) or an MFA challenge { mfaRequired, mfaToken }.
|
||||||
|
const response = await api.post<AdminLoginResponse>('/auth/admin/login', {
|
||||||
username: credentials.email,
|
username: credentials.email,
|
||||||
password: credentials.password,
|
password: credentials.password,
|
||||||
recaptchaToken: credentials.recaptchaToken
|
recaptchaToken: credentials.recaptchaToken
|
||||||
@@ -24,6 +25,14 @@ export const authService = {
|
|||||||
return response.data;
|
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() {
|
async adminLogout() {
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/logout');
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -252,6 +252,20 @@ export interface LoginResponse {
|
|||||||
user: AdminUser;
|
user: AdminUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two-step admin login: when MFA is enabled, POST /auth/admin/login returns
|
||||||
|
// this challenge instead of a session (no cookie yet). The mfaToken is a
|
||||||
|
// short-lived (5 min) JWT exchanged at POST /auth/admin/login/mfa.
|
||||||
|
export interface MfaChallengeResponse {
|
||||||
|
mfaRequired: true;
|
||||||
|
mfaToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminLoginResponse = LoginResponse | MfaChallengeResponse;
|
||||||
|
|
||||||
|
export function isMfaChallenge(res: AdminLoginResponse): res is MfaChallengeResponse {
|
||||||
|
return (res as MfaChallengeResponse).mfaRequired === true;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GalleryAuthResponse {
|
export interface GalleryAuthResponse {
|
||||||
token: string;
|
token: string;
|
||||||
event: {
|
event: {
|
||||||
|
|||||||
Reference in New Issue
Block a user