Merge remote-tracking branch 'origin/main' into refactor/codebase-cleanup

# Conflicts:
#	backend/src/routes/adminEvents.js
#	backend/src/routes/protectedImages.js
#	frontend/src/pages/admin/EventDetailsPage.tsx
This commit is contained in:
Paul Nothaft
2026-07-03 11:54:01 +02:00
55 changed files with 4019 additions and 154 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.79.1-beta.0",
"version": "3.80.0-beta.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -0,0 +1,219 @@
import React, { useRef, useState } from 'react';
import { Download, Upload, AlertTriangle, ShieldAlert, ExternalLink, CheckCircle2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../common';
import { api } from '../../config/api';
// Portable ".picpeak" roundtrip, split across two Backup Manager tabs:
// - PicpeakExportCard → Dashboard (making a backup)
// - PicpeakRestoreCard → Restore (restoring a backup)
// The manifest is bundled inside the .picpeak, so there is no separate
// "manifest only" download here.
interface RestoreResult {
tables: number;
filesRestored: number;
usesExternalMedia: boolean;
}
// ── Download half (Dashboard) ────────────────────────────────────────────────
export const PicpeakExportCard: React.FC = () => {
const { t } = useTranslation();
const [includePhotos, setIncludePhotos] = useState(false);
const [downloading, setDownloading] = useState(false);
const handleDownload = async () => {
setDownloading(true);
try {
const res = await api.get('/admin/backup/picpeak/export', {
params: { includePhotos },
responseType: 'blob',
});
const cd = (res.headers['content-disposition'] as string) || '';
const match = cd.match(/filename="?([^"]+)"?/);
const filename = (match && match[1]) || 'picpeak-backup.picpeak';
const url = window.URL.createObjectURL(res.data as Blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (_) {
toast.error(t('backup.picpeak.downloadFailed', 'Could not create the backup file.'));
} finally {
setDownloading(false);
}
};
return (
<Card padding="lg">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.title', 'Portable backup (.picpeak)')}
</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')}
</p>
<div className="mt-6">
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-neutral-300"
checked={includePhotos}
onChange={(e) => setIncludePhotos(e.target.checked)}
/>
{t('backup.picpeak.includePhotos', 'Include original gallery photos (larger file)')}
</label>
<div className="mt-3 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-900/20">
<ShieldAlert className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
<p className="text-xs text-amber-800 dark:text-amber-200">
{t('backup.picpeak.secretsWarning', 'This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.')}
</p>
</div>
<Button
variant="outline"
className="mt-3"
isLoading={downloading}
onClick={handleDownload}
leftIcon={<Download className="h-4 w-4" />}
>
{t('backup.picpeak.download', 'Download .picpeak')}
</Button>
</div>
</Card>
);
};
PicpeakExportCard.displayName = 'PicpeakExportCard';
// ── Restore half (Restore tab) ───────────────────────────────────────────────
export const PicpeakRestoreCard: React.FC = () => {
const { t } = useTranslation();
const fileRef = useRef<HTMLInputElement>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [restoring, setRestoring] = useState(false);
const [result, setResult] = useState<RestoreResult | null>(null);
const onFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) setPendingFile(f);
e.target.value = ''; // let the user re-pick the same file after cancelling
};
const confirmRestore = async () => {
if (!pendingFile) return;
setRestoring(true);
try {
const fd = new FormData();
fd.append('backup', pendingFile);
const res = await api.post<RestoreResult>('/admin/backup/picpeak/import', fd);
setResult(res.data);
setPendingFile(null);
toast.success(t('backup.picpeak.restoreDone', 'Backup restored.'));
} catch (e: any) {
const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.');
toast.error(msg);
setPendingFile(null);
} finally {
setRestoring(false);
}
};
return (
<Card padding="lg">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}
</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}
</p>
<input ref={fileRef} type="file" accept=".picpeak,application/zip" className="hidden" onChange={onFilePick} />
<Button
variant="outline"
className="mt-4"
onClick={() => fileRef.current?.click()}
leftIcon={<Upload className="h-4 w-4" />}
>
{t('backup.picpeak.chooseFile', 'Choose .picpeak file…')}
</Button>
{result && (
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900/50 dark:bg-green-900/20">
<div className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-green-600 dark:text-green-400" />
<div className="min-w-0">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
{t('backup.picpeak.restoreDone', 'Backup restored.')}
</p>
<p className="mt-0.5 text-xs text-green-700 dark:text-green-300">
{t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', {
tables: result.tables,
files: result.filesRestored,
})}
</p>
{result.usesExternalMedia && (
<p className="mt-2 flex items-start gap-1 text-xs text-amber-800 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
{t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '}
<a
href="https://github.com/PicPeak/picpeak/blob/main/README.md"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
{t('backup.picpeak.externalMediaLink', 'Setup guide')}
<ExternalLink className="h-3 w-3" />
</a>
</span>
</p>
)}
<Button variant="primary" size="sm" className="mt-3" onClick={() => window.location.reload()}>
{t('backup.picpeak.reload', 'Reload app')}
</Button>
</div>
</div>
</div>
)}
{/* Destructive confirmation */}
{pendingFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-neutral-800">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-600 dark:text-red-400" />
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.confirmTitle', 'Restore will delete all current data')}
</h3>
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-300">
{t('backup.picpeak.confirmBody', 'This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.')}
</p>
<p className="mt-2 truncate text-xs text-neutral-500 dark:text-neutral-400">{pendingFile.name}</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="outline" onClick={() => setPendingFile(null)} disabled={restoring}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
className="!bg-red-600 hover:!bg-red-700"
isLoading={restoring}
onClick={confirmRestore}
>
{t('backup.picpeak.confirmRestore', 'Delete & restore')}
</Button>
</div>
</div>
</div>
)}
</Card>
);
};
PicpeakRestoreCard.displayName = 'PicpeakRestoreCard';
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PicpeakRestoreCard } from './PicpeakBackupCard';
import {
RefreshCw,
AlertTriangle,
@@ -283,12 +284,47 @@ export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
)}
{restoreData.source === 'upload' && (
<Card className="p-4">
<div className="text-center py-8">
<Upload className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.upload.comingSoon')}</p>
<div className="space-y-4">
{/* Two upload kinds: the working portable .picpeak restore, and the
legacy manifest+files upload (still a stub). */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => setRestoreData(prev => ({ ...prev, uploadType: 'picpeak' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.uploadType === 'picpeak'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<FileArchive className={`h-10 w-10 mb-2 mx-auto ${restoreData.uploadType === 'picpeak' ? 'text-primary' : 'text-neutral-400'}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.picpeak.name', '.picpeak backup')}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.picpeak.description', 'Portable full backup — restores everything (full override, keeps your current account).')}</p>
</button>
<button
onClick={() => setRestoreData(prev => ({ ...prev, uploadType: 'manifest' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.uploadType === 'manifest'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<Upload className={`h-10 w-10 mb-2 mx-auto ${restoreData.uploadType === 'manifest' ? 'text-primary' : 'text-neutral-400'}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.manifest.name', 'Manifest + files')}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.manifest.description', 'Upload a manifest and its backup files (legacy format).')}</p>
</button>
</div>
</Card>
{restoreData.uploadType === 'picpeak' && <PicpeakRestoreCard />}
{restoreData.uploadType === 'manifest' && (
<Card className="p-4">
<div className="text-center py-8">
<Upload className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.upload.manifestComingSoon', 'Manifest Upload functionality coming soon')}</p>
</div>
</Card>
)}
</div>
)}
</div>
);
@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { ShieldAlert } from 'lucide-react';
import { Button, Input } from '../common';
import type { FeatureKey } from '../../services/featureFlags.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { emailService, type EmailConfig } from '../../services/email.service';
// Features that need working SMTP to deliver anything.
const EMAIL_FEATURES: FeatureKey[] = ['reminderEmails', 'incomingMail', 'whatsapp', 'bills'];
interface Props {
selectedFeatures: Set<FeatureKey>;
onDone: () => void;
}
// Lean per-feature config, shown after the "How will you use PicPeak?" step.
// Only the sections a selected feature actually needs are rendered; everything
// else keeps its seeded defaults and is tunable later in Settings. Saving is
// best-effort per section — a failure never traps the user on setup.
export const SetupConfigStep: React.FC<Props> = ({ selectedFeatures, onDone }) => {
const { t } = useTranslation();
const showInvoicing = selectedFeatures.has('bills');
const showEmail = EMAIL_FEATURES.some((f) => selectedFeatures.has(f));
const [saving, setSaving] = useState(false);
const [inv, setInv] = useState({
companyName: '', addressLine1: '', postalCode: '', city: '', countryCode: '',
vatId: '', taxId: '', defaultCurrency: 'CHF', iban: '',
});
const [mail, setMail] = useState({
smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '', from_email: '', from_name: '',
});
const invField = (k: keyof typeof inv) => (e: React.ChangeEvent<HTMLInputElement>) =>
setInv((p) => ({ ...p, [k]: e.target.value }));
const mailField = (k: keyof typeof mail) => (e: React.ChangeEvent<HTMLInputElement>) =>
setMail((p) => ({ ...p, [k]: e.target.value }));
const finish = async () => {
setSaving(true);
try {
// Invoicing: only persist if they actually started filling it in.
if (showInvoicing && inv.companyName.trim()) {
await businessProfileService.update({
companyName: inv.companyName.trim(),
addressLine1: inv.addressLine1.trim(),
postalCode: inv.postalCode.trim(),
city: inv.city.trim(),
countryCode: inv.countryCode.trim(),
vatId: inv.vatId.trim(),
taxId: inv.taxId.trim(),
defaultCurrency: inv.defaultCurrency.trim() || 'CHF',
});
if (inv.iban.trim()) {
await businessProfileService.createBankAccount({
iban: inv.iban.replace(/\s+/g, ''),
accountHolder: inv.companyName.trim(),
currency: inv.defaultCurrency.trim() || 'CHF',
isDefault: true,
});
}
}
// Email: only persist if a host was entered.
if (showEmail && mail.smtp_host.trim()) {
const port = parseInt(mail.smtp_port, 10) || 587;
const config: EmailConfig = {
smtp_host: mail.smtp_host.trim(),
smtp_port: port,
smtp_secure: port === 465,
smtp_user: mail.smtp_user.trim(),
smtp_pass: mail.smtp_pass,
from_email: mail.from_email.trim(),
from_name: mail.from_name.trim(),
tls_reject_unauthorized: true,
};
await emailService.updateConfig(config);
}
} catch (_) {
toast.warn(t('setup.config.saveFailed', 'Some settings could not be saved — you can finish them in Settings.'));
} finally {
setSaving(false);
onDone();
}
};
return (
<div className="space-y-8">
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
{t('setup.config.intro', 'A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.')}
</p>
{showInvoicing && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.invoicing', 'Invoicing details')}</h3>
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3">
<ShieldAlert className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600" />
<p className="text-xs text-amber-800">
{t('setup.config.invoicingDisclaimer', 'Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.')}
</p>
</div>
<Input placeholder={t('setup.config.companyName', 'Company / legal name')} value={inv.companyName} onChange={invField('companyName')} />
<Input placeholder={t('setup.config.addressLine1', 'Street and number')} value={inv.addressLine1} onChange={invField('addressLine1')} />
<div className="grid grid-cols-3 gap-3">
<Input placeholder={t('setup.config.postalCode', 'Postal code')} value={inv.postalCode} onChange={invField('postalCode')} />
<div className="col-span-2"><Input placeholder={t('setup.config.city', 'City')} value={inv.city} onChange={invField('city')} /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.countryCode', 'Country code (e.g. CH)')} value={inv.countryCode} onChange={invField('countryCode')} />
<Input placeholder={t('setup.config.currency', 'Currency (e.g. CHF)')} value={inv.defaultCurrency} onChange={invField('defaultCurrency')} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.vatId', 'VAT ID (or leave blank)')} value={inv.vatId} onChange={invField('vatId')} />
<Input placeholder={t('setup.config.taxId', 'Tax number (or VAT ID)')} value={inv.taxId} onChange={invField('taxId')} />
</div>
<Input placeholder={t('setup.config.iban', 'IBAN (for invoice payments)')} value={inv.iban} onChange={invField('iban')} />
</div>
)}
{showEmail && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.email', 'Email delivery (SMTP)')}</h3>
<p className="text-xs text-neutral-500">{t('setup.config.emailHint', 'Required to send reminders, invoices and notifications.')}</p>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2"><Input placeholder={t('setup.config.smtpHost', 'SMTP host')} value={mail.smtp_host} onChange={mailField('smtp_host')} /></div>
<Input placeholder={t('setup.config.smtpPort', 'Port')} value={mail.smtp_port} onChange={mailField('smtp_port')} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.smtpUser', 'Username')} value={mail.smtp_user} onChange={mailField('smtp_user')} autoComplete="off" />
<Input type="password" placeholder={t('setup.config.smtpPass', 'Password')} value={mail.smtp_pass} onChange={mailField('smtp_pass')} autoComplete="new-password" />
</div>
<div className="grid grid-cols-2 gap-3">
<Input type="email" placeholder={t('setup.config.fromEmail', 'From address')} value={mail.from_email} onChange={mailField('from_email')} />
<Input placeholder={t('setup.config.fromName', 'From name')} value={mail.from_name} onChange={mailField('from_name')} />
</div>
</div>
)}
<div className="flex gap-3">
<Button type="button" variant="outline" size="lg" onClick={onDone} disabled={saving}>
{t('setup.config.skip', 'Skip for now')}
</Button>
<Button type="button" variant="primary" size="lg" isLoading={saving} className="flex-1" onClick={finish}>
{t('setup.config.finish', 'Finish setup')}
</Button>
</div>
</div>
);
};
SetupConfigStep.displayName = 'SetupConfigStep';
@@ -137,18 +137,26 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
throw new Error('No URL provided');
}
// Build full URL for the image
// Build full URL for the image. Only relative paths are app-owned;
// an absolute URL is passed through untouched.
const isRelative = rawUrl.startsWith('/');
const fullImageUrl = rawUrl.startsWith('/admin')
? buildResourceUrl(`/api${rawUrl}`)
: rawUrl.startsWith('/')
: isRelative
? buildResourceUrl(rawUrl)
: rawUrl;
const headers: Record<string, string> = {};
const slugForRequest = resolveSlug(rawUrl);
const token = getGalleryToken(slugForRequest);
if (token) {
headers.Authorization = `Bearer ${token}`;
// Attach the gallery bearer token ONLY to relative (same-app) image
// paths. Never send it to an absolute/external URL — that would leak
// gallery credentials cross-origin. AuthenticatedImage does not
// support external URLs by design.
if (isRelative) {
const slugForRequest = resolveSlug(rawUrl);
const token = getGalleryToken(slugForRequest);
if (token) {
headers.Authorization = `Bearer ${token}`;
}
}
const response = await fetch(fullImageUrl, {
+12 -4
View File
@@ -56,11 +56,19 @@ api.interceptors.request.use(
const pathname = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
const isGalleryEndpoint = /^\/gallery\//.test(pathname)
|| /^\/secure-images\//.test(pathname)
|| /^\/auth\/gallery\//.test(pathname);
// Never attach the gallery token to an absolute URL. Requests to the
// app's own API use relative paths (axios prepends baseURL); an
// absolute URL could point at any origin, and extracting its
// `/gallery/...` pathname would otherwise match below and leak the
// bearer token cross-origin.
const isAbsoluteUrl = /^https?:\/\//i.test(config.url || '');
const isGallerySessionCheck = pathname === '/auth/session'
const isGalleryEndpoint = !isAbsoluteUrl && (
/^\/gallery\//.test(pathname)
|| /^\/secure-images\//.test(pathname)
|| /^\/auth\/gallery\//.test(pathname));
const isGallerySessionCheck = !isAbsoluteUrl && pathname === '/auth/session'
&& (!!paramSlug || window.location.pathname.startsWith('/gallery/'));
if (isGalleryEndpoint || isGallerySessionCheck) {
@@ -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 {
password_min_length: number;
password_complexity: string;
enable_2fa: boolean;
session_timeout_minutes: number;
max_login_attempts: number;
attempt_window_minutes: number;
@@ -134,7 +133,6 @@ export function useSettingsState() {
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
password_min_length: 8,
password_complexity: 'moderate',
enable_2fa: false,
session_timeout_minutes: 60,
max_login_attempts: 5,
attempt_window_minutes: 15,
@@ -231,7 +229,6 @@ export function useSettingsState() {
setSecuritySettings({
password_min_length: toNumber(settings.security_password_min_length, 8),
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),
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
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 { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState';
import { SUPPORTED_LANGUAGES } from "../../../components/common/LanguageSelector.tsx";
import { MfaSettingsCard } from '../components/MfaSettingsCard';
interface GeneralTabProps {
generalSettings: GeneralSettings;
@@ -94,6 +95,10 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
)}
</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">
<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 { Save, Key, AlertCircle } from 'lucide-react';
import { Save, Key, AlertCircle, ShieldCheck } from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { SecuritySettings } from '../hooks/useSettingsState';
@@ -124,15 +124,15 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
</div>
</div>
<label className="flex items-center">
<input
type="checkbox"
checked={securitySettings.enable_2fa}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_2fa: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.security.enable2FA')}</span>
</label>
<div className="p-4 bg-neutral-50 dark:bg-neutral-800/60 border border-neutral-200 dark:border-neutral-700 rounded-lg">
<div className="flex items-start gap-3">
<ShieldCheck className="w-5 h-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-neutral-700 dark:text-neutral-300">
<p className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.security.twoFactorTitle')}</p>
<p className="mt-1">{t('settings.security.twoFactorNote')}</p>
</div>
</div>
</div>
</div>
</Card>
+128 -5
View File
@@ -634,7 +634,16 @@
"upload": {
"name": "Backup hochladen",
"description": "Eine Backup-Datei hochladen",
"comingSoon": "Upload-Funktion kommt bald"
"comingSoon": "Upload-Funktion kommt bald",
"manifestComingSoon": "Manifest-Upload-Funktion kommt bald",
"picpeak": {
"name": ".picpeak-Backup",
"description": "Portables Voll-Backup — stellt alles wieder her (vollständige Überschreibung, Ihr aktuelles Konto bleibt erhalten)."
},
"manifest": {
"name": "Manifest + Dateien",
"description": "Ein Manifest und die zugehörigen Backup-Dateien hochladen (Legacy-Format)."
}
},
"configuration": {
"s3": "S3-Konfiguration",
@@ -1413,13 +1422,14 @@
"attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden",
"lockoutDurationMinutes": "Sperrdauer (Minuten)",
"lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben",
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
"recaptchaSettings": "reCAPTCHA-Einstellungen",
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
"siteKey": "Site-Schlüssel",
"secretKey": "Geheimer Schlüssel",
"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": {
"title": "Veranstaltungserstellung",
@@ -1779,7 +1789,27 @@
"title": "E-Mail-Einstellungen"
},
"backup": {
"title": "Backup"
"title": "Backup",
"picpeak": {
"title": "Portables Backup (.picpeak)",
"intro": "Laden Sie eine einzelne, in sich geschlossene Datei herunter und laden Sie sie auf einer anderen Instanz hoch, um diese zu klonen — komplett im Browser.",
"includePhotos": "Original-Galeriefotos einschließen (größere Datei)",
"secretsWarning": "Diese Datei enthält Geheimnisse im Klartext (E-Mail-Passwort, Admin-Zugangsdaten, API-Schlüssel). Bewahren Sie sie sicher auf und übertragen Sie sie nur über vertrauenswürdige Kanäle.",
"download": ".picpeak herunterladen",
"downloadFailed": "Die Backup-Datei konnte nicht erstellt werden.",
"restoreTitle": "Aus einer .picpeak wiederherstellen",
"restoreIntro": "Laden Sie eine .picpeak von dieser oder einer anderen Instanz hoch. Nur dieselbe Datenbank-Engine.",
"chooseFile": ".picpeak-Datei auswählen…",
"restoreDone": "Backup wiederhergestellt.",
"restoreFailed": "Wiederherstellung fehlgeschlagen.",
"restoreSummary": "{{tables}} Tabellen und {{files}} Dateien wiederhergestellt.",
"externalMediaNote": "Dieses Backup verweist auf eine externe Medienbibliothek. Stellen Sie sicher, dass das externe Medien-Routing auf dieser Instanz konfiguriert ist.",
"externalMediaLink": "Einrichtungsanleitung",
"reload": "App neu laden",
"confirmTitle": "Die Wiederherstellung löscht alle aktuellen Daten",
"confirmBody": "Dies ersetzt ALLE Daten auf dieser Instanz dauerhaft durch das hochgeladene Backup, mit Ausnahme Ihres aktuellen Kontos. Dies kann nicht rückgängig gemacht werden.",
"confirmRestore": "Löschen & wiederherstellen"
}
},
"branding": {
"title": "Branding"
@@ -2011,6 +2041,42 @@
"testSend": "Test senden",
"testSending": "Senden…",
"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": {
@@ -3467,6 +3533,45 @@
"subtitle": "Erstellen Sie Ihr Administrator-Konto, um loszulegen",
"tokenStepSubtitle": "Geben Sie zunächst Ihren einmaligen Setup-Token ein",
"accountStepSubtitle": "Erstellen Sie nun Ihr Administrator-Konto",
"usageSubtitle": "Wie möchten Sie PicPeak nutzen?",
"usageAlwaysOn": "Galerien, Analysen und Benutzerverwaltung sind immer enthalten. Wählen Sie unten optionale Funktionen — Sie können dies jederzeit in den Einstellungen ändern.",
"usageGroupCrm": "Kundenverwaltung",
"usageGroupAccounting": "Buchhaltung",
"usageGroupAutomation": "Automatisierung & Versand",
"usageDepsNote": "Rechnungen aktivieren automatisch den Buchhaltungsbereich.",
"usageSkip": "Nur mit Galerien fortfahren",
"finish": "Einrichtung abschließen",
"featuresSaveFailed": "Ihre Funktionsauswahl konnte nicht gespeichert werden — Sie können sie später unter Einstellungen → Funktionen festlegen.",
"restoreStepSubtitle": "Aus einem Backup wiederherstellen",
"restoreEntry": "Wechsel von einer anderen PicPeak-Instanz?",
"restoreEntryHint": "Stellen Sie stattdessen ein .picpeak-Backup wieder her, anstatt neu einzurichten.",
"restoreIntro": "Laden Sie ein .picpeak-Backup hoch, um eine andere Instanz auf diese zu klonen. Dies ersetzt alles außer dem gerade erstellten Konto.",
"config": {
"subtitle": "Richten Sie Ihre Funktionen ein",
"intro": "Einige Angaben zu den gewählten Funktionen. Was Sie überspringen, behält den Standard und kann später in den Einstellungen festgelegt werden.",
"invoicing": "Rechnungsdaten",
"invoicingDisclaimer": "Erscheint auf Ihren Rechnungen. Bank-/IBAN- und Mehrwertsteuerangaben liegen in Ihrer Verantwortung — prüfen Sie sie mit Ihrer Bank und Ihrem Treuhänder/Steuerberater.",
"companyName": "Firma / rechtlicher Name",
"addressLine1": "Strasse und Nummer",
"postalCode": "PLZ",
"city": "Ort",
"countryCode": "Ländercode (z. B. CH)",
"currency": "Währung (z. B. CHF)",
"vatId": "MwSt-Nummer (oder leer lassen)",
"taxId": "Steuernummer (oder MwSt-Nummer)",
"iban": "IBAN (für Rechnungszahlungen)",
"email": "E-Mail-Versand (SMTP)",
"emailHint": "Erforderlich, um Erinnerungen, Rechnungen und Benachrichtigungen zu senden.",
"smtpHost": "SMTP-Host",
"smtpPort": "Port",
"smtpUser": "Benutzername",
"smtpPass": "Passwort",
"fromEmail": "Absenderadresse",
"fromName": "Absendername",
"skip": "Vorerst überspringen",
"finish": "Einrichtung abschließen",
"saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen."
},
"stepOf": "Schritt {{current}} von {{total}}",
"continue": "Weiter",
"back": "Zurück",
@@ -3517,7 +3622,25 @@
"generalError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
"needHelp": "Hilfe benötigt? Kontakt",
"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": {
"title": "Benutzerdefinierte CSS-Vorlagen",
+128 -5
View File
@@ -969,13 +969,14 @@
"attemptWindowMinutesHelp": "How long to look back when counting failed login attempts",
"lockoutDurationMinutes": "Lockout Duration (minutes)",
"lockoutDurationMinutesHelp": "How long the gallery or account stays locked after too many failures",
"enable2FA": "Enable two-factor authentication for admins",
"recaptchaSettings": "reCAPTCHA Settings",
"enableRecaptcha": "Enable reCAPTCHA for login forms",
"siteKey": "Site Key",
"secretKey": "Secret Key",
"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": {
"title": "Categories",
@@ -1335,7 +1336,27 @@
"title": "Email Settings"
},
"backup": {
"title": "Backup"
"title": "Backup",
"picpeak": {
"title": "Portable backup (.picpeak)",
"intro": "Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.",
"includePhotos": "Include original gallery photos (larger file)",
"secretsWarning": "This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.",
"download": "Download .picpeak",
"downloadFailed": "Could not create the backup file.",
"restoreTitle": "Restore from a .picpeak",
"restoreIntro": "Upload a .picpeak taken from this or another instance. Same database engine only.",
"chooseFile": "Choose .picpeak file…",
"restoreDone": "Backup restored.",
"restoreFailed": "Restore failed.",
"restoreSummary": "{{tables}} tables and {{files}} files restored.",
"externalMediaNote": "This backup references an external-media library. Make sure external-media routing is configured on this instance.",
"externalMediaLink": "Setup guide",
"reload": "Reload app",
"confirmTitle": "Restore will delete all current data",
"confirmBody": "This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.",
"confirmRestore": "Delete & restore"
}
},
"branding": {
"title": "Branding"
@@ -1567,6 +1588,42 @@
"testSend": "Send test",
"testSending": "Sending…",
"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": {
@@ -3002,7 +3059,16 @@
"upload": {
"name": "Upload Backup",
"description": "Upload a backup file",
"comingSoon": "Upload functionality coming soon"
"comingSoon": "Upload functionality coming soon",
"manifestComingSoon": "Manifest Upload functionality coming soon",
"picpeak": {
"name": ".picpeak backup",
"description": "Portable full backup — restores everything (full override, keeps your current account)."
},
"manifest": {
"name": "Manifest + files",
"description": "Upload a manifest and its backup files (legacy format)."
}
},
"configuration": {
"s3": "S3 Configuration",
@@ -3363,6 +3429,45 @@
"subtitle": "Create your administrator account to get started",
"tokenStepSubtitle": "First, enter your one-time setup token",
"accountStepSubtitle": "Now create your administrator account",
"usageSubtitle": "How will you use PicPeak?",
"usageAlwaysOn": "Galleries, analytics and user management are always included. Pick any extras below — you can change these anytime in Settings.",
"usageGroupCrm": "Client management",
"usageGroupAccounting": "Accounting",
"usageGroupAutomation": "Automation & delivery",
"usageDepsNote": "Invoices automatically enable the Accounting area.",
"usageSkip": "Continue with galleries only",
"finish": "Finish setup",
"featuresSaveFailed": "Could not save your feature choices — you can set them later in Settings → Features.",
"restoreStepSubtitle": "Restore from a backup",
"restoreEntry": "Migrating from another PicPeak?",
"restoreEntryHint": "Restore a .picpeak backup instead of setting up fresh.",
"restoreIntro": "Upload a .picpeak backup to clone another instance onto this one. This replaces everything except the account you just created.",
"config": {
"subtitle": "Set up your features",
"intro": "A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.",
"invoicing": "Invoicing details",
"invoicingDisclaimer": "Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.",
"companyName": "Company / legal name",
"addressLine1": "Street and number",
"postalCode": "Postal code",
"city": "City",
"countryCode": "Country code (e.g. CH)",
"currency": "Currency (e.g. CHF)",
"vatId": "VAT ID (or leave blank)",
"taxId": "Tax number (or VAT ID)",
"iban": "IBAN (for invoice payments)",
"email": "Email delivery (SMTP)",
"emailHint": "Required to send reminders, invoices and notifications.",
"smtpHost": "SMTP host",
"smtpPort": "Port",
"smtpUser": "Username",
"smtpPass": "Password",
"fromEmail": "From address",
"fromName": "From name",
"skip": "Skip for now",
"finish": "Finish setup",
"saveFailed": "Some settings could not be saved — you can finish them in Settings."
},
"stepOf": "Step {{current}} of {{total}}",
"continue": "Continue",
"back": "Back",
@@ -3413,7 +3518,25 @@
"generalError": "An error occurred. Please try again.",
"needHelp": "Need help? Contact",
"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": {
"adminTitle": "Live Slideshow",
+154 -11
View File
@@ -8,6 +8,9 @@ import { useTranslation } from 'react-i18next';
import { Button, Input, Card, Loading } from '../components/common';
import { useAdminAuth } from '../contexts';
import { setupService } from '../services/setup.service';
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
import type { AdminUser } from '../types';
@@ -16,6 +19,19 @@ import type { AdminUser } from '../types';
const SETUP_DOCS_URL =
'https://github.com/PicPeak/picpeak/blob/main/README.md#first-run--create-your-admin-account';
// "How will you use PicPeak?" — the opt-in feature groups shown after the admin
// account is created. galleries/analytics/userManagement are always on and not
// listed. Labels/descriptions reuse the existing Settings→Features i18n keys
// (`settings.features.<key>.title/description`) so translations stay in sync.
// Server-side applyDependencyRules resolves dependencies (e.g. Invoices pulls in
// Accounting) when we PUT the selection, so we only send the raw ticks.
const USAGE_GROUPS: { id: string; titleKey: string; features: FeatureKey[] }[] = [
{ id: 'crm', titleKey: 'setup.usageGroupCrm', features: ['quotes', 'contracts', 'bills', 'hoursLogging', 'customerPortal', 'calendar'] },
{ id: 'accounting', titleKey: 'setup.usageGroupAccounting', features: ['taxReport', 'incomingInvoices', 'expenses'] },
{ id: 'automation', titleKey: 'setup.usageGroupAutomation', features: ['reminderEmails', 'slideshow', 'workflows', 'whatsapp', 'incomingMail'] },
];
const ALL_USAGE_FEATURES: FeatureKey[] = USAGE_GROUPS.flatMap((g) => g.features);
// First-run screen. Reached on a fresh instance where no admin account exists
// yet — creates the first (super_admin) account from the browser using the
// one-time setup token printed to the server logs. Once an admin exists the
@@ -37,13 +53,15 @@ export const SetupPage: React.FC = () => {
staleTime: Infinity,
});
const [step, setStep] = useState<'token' | 'account'>('token');
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore' | 'config'>('token');
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isVerifyingToken, setIsVerifyingToken] = useState(false);
const [copied, setCopied] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [selectedFeatures, setSelectedFeatures] = useState<Set<FeatureKey>>(new Set());
const [isSavingFeatures, setIsSavingFeatures] = useState(false);
if (statusLoading) {
return <Loading fullScreen />;
@@ -144,7 +162,10 @@ export const SetupPage: React.FC = () => {
};
login('', adminUser);
toast.success(t('setup.success'));
navigate('/admin/dashboard', { replace: true });
// Admin now exists and we're logged in (cookie set) — advance to the
// opt-in "How will you use PicPeak?" step rather than jumping straight to
// the dashboard. Authenticated calls (feature flags) work from here.
setStep('usage');
} catch (error: any) {
const httpStatus = error.response?.status;
const data = error.response?.data;
@@ -184,10 +205,44 @@ export const SetupPage: React.FC = () => {
}
};
const stepNumber = step === 'token' ? 1 : 2;
const toggleFeature = (key: FeatureKey) => {
setSelectedFeatures((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
// Persist the feature selection, then enter the app. Saving is best-effort —
// if it fails the admin can still flip features later in Settings, so we don't
// trap them on the setup screen.
const finishSetup = async () => {
setIsSavingFeatures(true);
try {
const flags: Partial<FeatureFlags> = {};
for (const key of ALL_USAGE_FEATURES) flags[key] = selectedFeatures.has(key);
await featureFlagsService.update(flags);
} catch (_) {
toast.warn(t('setup.featuresSaveFailed'));
} finally {
setIsSavingFeatures(false);
// If the chosen features need config the wizard can collect (invoicing,
// email), go to the config step; otherwise enter the app.
const needsConfig =
selectedFeatures.has('bills') ||
selectedFeatures.has('reminderEmails') ||
selectedFeatures.has('incomingMail') ||
selectedFeatures.has('whatsapp');
if (needsConfig) setStep('config');
else navigate('/admin/dashboard', { replace: true });
}
};
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
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: '#fafafa' }}>
<div className="w-full max-w-md">
<div className="text-center mb-8">
{/* On a fresh instance there are no branding settings yet, so use the
@@ -202,13 +257,23 @@ export const SetupPage: React.FC = () => {
</div>
);
})()}
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>{t('setup.title')}</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
{step === 'token' ? t('setup.tokenStepSubtitle') : t('setup.accountStepSubtitle')}
</p>
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
{t('setup.stepOf', { current: stepNumber, total: 2 })}
<h1 className="text-3xl font-bold" style={{ color: '#171717' }}>{t('setup.title')}</h1>
<p className="mt-2" style={{ color: '#171717', opacity: 0.7 }}>
{step === 'token'
? t('setup.tokenStepSubtitle')
: step === 'account'
? t('setup.accountStepSubtitle')
: step === 'restore'
? t('setup.restoreStepSubtitle')
: step === 'config'
? t('setup.config.subtitle')
: t('setup.usageSubtitle')}
</p>
{(step === 'token' || step === 'account' || step === 'usage') && (
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
{t('setup.stepOf', { current: stepNumber, total: 3 })}
</p>
)}
</div>
<Card padding="lg">
@@ -271,7 +336,7 @@ export const SetupPage: React.FC = () => {
{t('setup.continue')}
</Button>
</form>
) : (
) : step === 'account' ? (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="setup-email" className="block text-sm font-medium text-neutral-700 mb-1">
@@ -348,6 +413,84 @@ export const SetupPage: React.FC = () => {
</Button>
</div>
</form>
) : step === 'usage' ? (
<div className="space-y-6">
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
{t('setup.usageAlwaysOn')}
</p>
<button
type="button"
onClick={() => setStep('restore')}
className="w-full rounded-lg border border-dashed border-neutral-300 p-3 text-left hover:bg-neutral-50 transition-colors"
>
<span className="block text-sm font-medium text-neutral-800">{t('setup.restoreEntry')}</span>
<span className="block text-xs text-neutral-500">{t('setup.restoreEntryHint')}</span>
</button>
{USAGE_GROUPS.map((group) => (
<div key={group.id}>
<h3 className="text-sm font-semibold text-neutral-800 mb-2">{t(group.titleKey)}</h3>
<div className="space-y-2">
{group.features.map((key) => (
<label
key={key}
className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50 transition-colors"
>
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-neutral-300"
checked={selectedFeatures.has(key)}
onChange={() => toggleFeature(key)}
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-neutral-800">
{t(`settings.features.${key}.title`)}
</span>
<span className="block text-xs text-neutral-500">
{t(`settings.features.${key}.description`)}
</span>
</span>
</label>
))}
</div>
</div>
))}
{selectedFeatures.has('bills') && !selectedFeatures.has('taxReport') && (
<p className="text-xs text-neutral-500">{t('setup.usageDepsNote')}</p>
)}
<Button
type="button"
variant="primary"
size="lg"
isLoading={isSavingFeatures}
className="w-full"
onClick={finishSetup}
>
{selectedFeatures.size > 0 ? t('setup.finish') : t('setup.usageSkip')}
</Button>
</div>
) : step === 'restore' ? (
<div className="space-y-6">
<p className="text-sm text-neutral-600">{t('setup.restoreIntro')}</p>
<PicpeakRestoreCard />
<Button
type="button"
variant="outline"
size="lg"
onClick={() => setStep('usage')}
leftIcon={<ArrowLeft className="w-4 h-4" />}
>
{t('setup.back')}
</Button>
</div>
) : (
<SetupConfigStep
selectedFeatures={selectedFeatures}
onDone={() => navigate('/admin/dashboard', { replace: true })}
/>
)}
</Card>
</div>
+151 -1
View File
@@ -1,13 +1,14 @@
import React, { useState, useEffect } from 'react';
import { Navigate, useSearchParams } from 'react-router-dom';
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 { useTranslation } from 'react-i18next';
import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service';
import { isMfaChallenge } from '../../types';
import { setupService } from '../../services/setup.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
@@ -29,6 +30,14 @@ export const AdminLoginPage: React.FC = () => {
const [loginSuccess, setLoginSuccess] = useState(false);
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 { isDark } = useAdminDarkMode();
@@ -103,6 +112,15 @@ export const AdminLoginPage: React.FC = () => {
...formData,
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);
toast.success(t('adminLogin.loginSuccess'));
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 (
<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">
@@ -177,6 +251,7 @@ export const AdminLoginPage: React.FC = () => {
{/* Login Form */}
<Card padding="lg">
{step === 'credentials' ? (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Form Error */}
{errors.form && (
@@ -263,6 +338,81 @@ export const AdminLoginPage: React.FC = () => {
{t('adminLogin.signIn')}
</Button>
</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>
{/* Footer */}
+10 -6
View File
@@ -23,6 +23,7 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard';
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
import { BackupHistory } from '../../components/admin/BackupHistory';
import { RestoreWizard } from '../../components/admin/RestoreWizard';
import { PicpeakExportCard } from '../../components/admin/PicpeakBackupCard';
import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard';
import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard';
import { api } from '../../config/api';
@@ -193,12 +194,15 @@ export const BackupManagement: React.FC = () => {
{/* Tab Content */}
<div className="mt-6">
{activeTab === 'dashboard' && (
<BackupDashboard
status={backupStatus}
config={backupConfig}
onRunBackup={() => manualBackupMutation.mutate()}
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending}
/>
<div className="space-y-6">
<BackupDashboard
status={backupStatus}
config={backupConfig}
onRunBackup={() => manualBackupMutation.mutate()}
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending}
/>
<PicpeakExportCard />
</div>
)}
{activeTab === 'configuration' && (
+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;
},
};
+14
View File
@@ -252,6 +252,20 @@ export interface LoginResponse {
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 {
token: string;
event: {