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>