feat(auth): confirm the e-mail address of new accounts by link
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m21s

- Setting / REGISTRATION_VERIFY_EMAIL; only with SMTP and BASE_URL; invites skip it
- Hashed one-time tokens (48 h), resend with cooldown, unconfirmed accounts purged
- Approval modes notify admins only after the address is confirmed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-31 14:01:41 +01:00
1 parent 1406650b7c
commit ab97638361
25 files changed
+621 -56

No files matched your search

@@ -52,6 +52,34 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [inviteCode, setInviteCode] = useState(linkInviteCode);
// Link from the confirmation mail (?verifyToken=…): confirm right away and say what happens next
useEffect(() => {
const url = new URL(window.location.href);
const token = url.searchParams.get('verifyToken');
if (!token) return;
url.searchParams.delete('verifyToken');
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
setAuthTab('login');
api
.verifyEmail(token)
.then((res) => setNotice(res.message))
.catch((err) => setError(err.message || 'Der Bestätigungslink ist ungültig oder abgelaufen.'));
}, []);
// Login refused because the address is not confirmed: offer to send the link again
const [resendState, setResendState] = useState<'idle' | 'sending' | 'sent'>('idle');
const resendVerification = async () => {
setResendState('sending');
try {
await api.resendVerification(email.trim());
setResendState('sent');
setError(null);
setNotice('Falls die Adresse noch auf Bestätigung wartet, haben wir dir den Link erneut geschickt.');
} catch (err: any) {
setResendState('idle');
setError(err.message || 'Senden fehlgeschlagen');
}
};
// The code is in the form now; it should not stay in the address bar (history, screenshots)
useEffect(() => {
if (!linkInviteCode) return;
@@ -65,6 +93,8 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
switch (authStatus.registrationMode) {
case 'approval':
return 'Nach der Registrierung gibt ein Administrator dein Konto frei.';
case 'open':
return authStatus.emailVerification ? 'Du bekommst eine E-Mail, um deine Adresse zu bestätigen.' : '';
case 'domain':
return domains ? `Registrierung mit E-Mail-Adressen von ${domains}.` : '';
case 'domain+approval':
@@ -74,6 +104,10 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
}
return '';
})();
const registrationHintFull =
registrationHint && authStatus.emailVerification && authStatus.registrationMode !== 'open'
? `${registrationHint} Vorher bestätigst du deine Adresse per E-Mail.`
: registrationHint;
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -512,7 +546,17 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
{error && (
<div className="mb-4 alert-err flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>{error}</span>
<span className="flex-1">{error}</span>
{/bestätige zuerst deine E-Mail/i.test(error) && email.trim() && (
<button
type="button"
onClick={() => void resendVerification()}
disabled={resendState !== 'idle'}
className="btn-action h-8 px-2.5 text-[11px] shrink-0 disabled:opacity-50"
>
{resendState === 'sending' ? 'Sende…' : 'Link erneut senden'}
</button>
)}
</div>
)}
{notice && !error && (
@@ -718,8 +762,8 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
{(authTab === 'invite' || authTab === 'register') && (
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
{authTab === 'register' && registrationHint && (
<p className="text-[11px] text-muted bg-canvas border border-border rounded-btn px-2.5 py-2">{registrationHint}</p>
{authTab === 'register' && registrationHintFull && (
<p className="text-[11px] text-muted bg-canvas border border-border rounded-btn px-2.5 py-2">{registrationHintFull}</p>
)}
{authTab === 'invite' && (
<div>
@@ -141,6 +141,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
registrationMode: sys.registrationMode || (sys.allowRegistration ? 'open' : 'closed'),
registrationDomains: sys.registrationDomains || '',
registrationConfiguredViaEnv: Boolean(sys.registrationConfiguredViaEnv),
registrationVerifyEmail: Boolean(sys.registrationVerifyEmail),
registrationVerifyConfiguredViaEnv: Boolean(sys.registrationVerifyConfiguredViaEnv),
emailLinksAvailable: Boolean(sys.emailLinksAvailable),
instanceName: sys.instanceName || 'MindSift',
smtpHost: sys.smtpHost || '',
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
@@ -902,7 +905,10 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<div className="flex items-center gap-2.5 min-w-0">
<UserAvatar user={u} size="sm" className="border border-border/80" />
<div className="min-w-0">
<p className="font-bold text-main truncate">{u.displayName}</p>
<p className="font-bold text-main truncate flex items-center gap-2">
{u.displayName}
{u.emailUnverified && <span className="badge-neutral text-[10px] font-normal">E-Mail unbestätigt</span>}
</p>
<p className="text-muted text-[11px] truncate">
{u.email} · registriert am {new Date(u.createdAt).toLocaleDateString('de-DE')}
</p>
@@ -947,8 +953,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<div className="flex items-center gap-2">
<span className="font-bold text-main">{u.displayName}</span>
{!u.isActive && (
<span className="badge-err text-[10px]">
Gesperrt
<span className={`${u.emailUnverified ? 'badge-neutral' : 'badge-err'} text-[10px]`}>
{u.emailUnverified ? 'E-Mail unbestätigt' : 'Gesperrt'}
</span>
)}
</div>
@@ -1355,6 +1361,33 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<code className="font-mono">REGISTRATION_DOMAINS</code>) und hier nicht änderbar.
</p>
)}
{mode !== 'closed' && (
<div className="pt-3 border-t border-border flex items-start justify-between gap-4">
<div className="pr-2">
<p className="font-semibold text-main">E-Mail-Adresse bestätigen lassen</p>
<p className="text-[11px] text-muted mt-0.5">
Neue Konten ohne Einladung bekommen einen Link per E-Mail und sind erst danach aktiv (bei Freigabe
landen sie danach bei dir). Unbestätigte Konten werden nach 48 Stunden gelöscht.
</p>
{!settings.emailLinksAvailable && (
<p className="text-[11px] text-status-warn mt-1">
Wirkt erst mit eingerichtetem Mail-Server und gesetzter <code className="font-mono">BASE_URL</code>.
</p>
)}
{settings.registrationVerifyConfiguredViaEnv && (
<p className="text-[11px] text-status-warn mt-1">
Über <code className="font-mono">REGISTRATION_VERIFY_EMAIL</code> festgelegt.
</p>
)}
</div>
<ToggleSwitch
checked={Boolean(settings.registrationVerifyEmail)}
disabled={Boolean(settings.registrationVerifyConfiguredViaEnv)}
onChange={(registrationVerifyEmail) => handleSaveSystemSettings({ registrationVerifyEmail })}
title="E-Mail-Adresse bei der Registrierung bestätigen lassen"
/>
</div>
)}
</div>
);
})()}