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
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:
1 parent
1406650b7c
commit
ab97638361
25 files changed
+621
-56
No files matched your search
Generated
+2
-2
@@ -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,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>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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 }) });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user