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

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.23.0",
"version": "0.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.23.0",
"version": "0.24.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.23.0",
"version": "0.24.0",
"license": "Unlicense",
"type": "module",
"scripts": {
@@ -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>
);
})()}
+8
View File
@@ -1130,6 +1130,14 @@ export const api = {
});
},
async verifyEmail(token: string): Promise<{ verified: boolean; pending: boolean; message: string }> {
return request('/api/v1/auth/verify-email', { method: 'POST', body: JSON.stringify({ token }) });
},
async resendVerification(email: string): Promise<void> {
await request('/api/v1/auth/resend-verification', { method: 'POST', body: JSON.stringify({ email }) });
},
async adminSendInvite(id: string, email: string): Promise<UserInvite> {
return request<UserInvite>(`/api/v1/admin/invites/${id}/send`, { method: 'POST', body: JSON.stringify({ email }) });
},
+8
View File
@@ -10,6 +10,8 @@ export interface AuthStatus {
// Registering without an invite and the e-mail domains of the domain modes (hint on the form)
registrationMode?: RegistrationMode;
registrationDomains?: string[];
// New accounts confirm their e-mail address by link first
emailVerification?: boolean;
instanceName: string;
demoMode?: boolean;
sharingMode?: SharingMode;
@@ -101,6 +103,8 @@ export interface User {
isActive: boolean;
// Registered and waiting for an admin's approval (admin user list)
pendingApproval?: boolean;
// Registered, e-mail address not confirmed yet (admin user list)
emailUnverified?: boolean;
alarmChannels?: string[];
alarmEmail?: string;
discordWebhookUrl?: string;
@@ -264,6 +268,10 @@ export interface SystemSettings {
registrationDomains?: string;
// REGISTRATION_MODE / REGISTRATION_DOMAINS (or ALLOW_REGISTRATION=true) fix it: shown, not editable
registrationConfiguredViaEnv?: boolean;
registrationVerifyEmail?: boolean;
registrationVerifyConfiguredViaEnv?: boolean;
// Mails with links can go out (mail server and BASE_URL configured)
emailLinksAvailable?: boolean;
// Automatic backups: daily time (HH:MM, server time zone) and days to keep them
backupEnabled?: boolean;
backupTime?: string;