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:
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user