feat(auth): reset a forgotten password by mail

"Passwort vergessen?" appears on the login screen when SMTP and BASE_URL
are configured. The request always gets the same answer and sends the mail
in the background (at most every 2 minutes per account). The link holds a
random token valid once for one hour; only its SHA-256 is stored. Setting
the new password invalidates all open links and ends every session. The
app removes the token from the address bar and offers a way back for
expired links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 08:06:56 +01:00
1 parent 677fe0fc0a
commit 5e20710aa7
16 files changed
+489 -8

No files matched your search

+20 -1
View File
@@ -120,6 +120,17 @@ export const App: React.FC = () => {
instanceName: 'MindSift',
});
const [user, setUser] = useState<User | null>(null);
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
const [resetToken, setResetToken] = useState<string | null>(() => {
try {
return new URLSearchParams(window.location.search).get('resetToken');
} catch {
return null;
}
});
useEffect(() => {
if (resetToken) window.history.replaceState(null, '', window.location.pathname);
}, [resetToken]);
const [customThemes, setCustomThemes] = useState<UserTheme[]>([]);
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
const [stats, setStats] = useState<DashboardStats>({
@@ -666,11 +677,19 @@ export const App: React.FC = () => {
}
// Show Setup Wizard or Login Screen if needsSetup or not logged in
if (authStatus.needsSetup || !user) {
if (authStatus.needsSetup || !user || resetToken) {
return (
<SetupAndAuthView
authStatus={authStatus}
onAuthenticated={handleAuthenticated}
resetToken={resetToken}
onResetDone={() => {
// The new password ended every session, this one included
setResetToken(null);
clearToken();
setUser(null);
}}
onResetCancel={() => setResetToken(null)}
/>
);
}
+171 -4
View File
@@ -19,11 +19,17 @@ import { LegalLinks } from '../../components/LegalLinks';
interface SetupAndAuthViewProps {
authStatus: AuthStatus;
onAuthenticated: (user: User) => void;
resetToken?: string | null;
onResetDone?: () => void;
onResetCancel?: () => void;
}
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
authStatus,
onAuthenticated,
resetToken,
onResetDone,
onResetCancel,
}) => {
// --- Setup Wizard State ---
const [setupName, setSetupName] = useState('');
@@ -36,7 +42,9 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const [createSampleData, setCreateSampleData] = useState(false);
// --- Login / Invite / Register State ---
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register'>('login');
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>(
resetToken ? 'reset' : 'login'
);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
@@ -44,6 +52,48 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await api.requestPasswordReset(email.trim());
setNotice('Falls ein Konto mit dieser E-Mail-Adresse existiert, haben wir dir einen Link zum Zurücksetzen geschickt. Er gilt eine Stunde.');
setAuthTab('login');
} catch (err: any) {
setError(err.message || 'Anfrage fehlgeschlagen');
} finally {
setSubmitting(false);
}
};
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (newPassword !== newPasswordConfirm) {
setError('Die eingegebenen Passwörter stimmen nicht überein.');
triggerHaptic(25);
return;
}
setSubmitting(true);
try {
await api.resetPassword(resetToken || '', newPassword);
setNewPassword('');
setNewPasswordConfirm('');
setPassword('');
setNotice('Dein Passwort wurde geändert. Melde dich jetzt mit dem neuen Passwort an.');
setAuthTab('login');
onResetDone?.();
} catch (err: any) {
setError(err.message || 'Passwort konnte nicht geändert werden');
} finally {
setSubmitting(false);
}
};
const handleCompleteSetup = async (e: React.FormEvent) => {
e.preventDefault();
@@ -355,13 +405,14 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
<Lock className="w-4 h-4 text-faint" />
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border bg-canvas text-xs">
{/* Navigation Tabs (not while choosing a new password) */}
<div className={`${authTab === 'reset' || authTab === 'forgot' ? 'hidden' : 'flex'} border-b border-border bg-canvas text-xs`}>
<button
type="button"
onClick={() => {
setAuthTab('login');
setError(null);
setNotice(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
authTab === 'login'
@@ -416,6 +467,107 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
<span>{error}</span>
</div>
)}
{notice && !error && (
<div role="status" className="mb-4 p-2.5 rounded-card border border-status-ok/40 bg-status-ok/10 text-main flex items-start gap-2">
<Check className="w-4 h-4 text-status-ok flex-shrink-0 mt-px" />
<span>{notice}</span>
</div>
)}
{authTab === 'forgot' && (
<form onSubmit={handleForgotPassword} className="space-y-3.5">
<div>
<h2 className="text-sm font-bold text-main">Passwort vergessen?</h2>
<p className="text-muted mt-1">
Gib die E-Mail-Adresse deines Kontos ein. Wir schicken dir einen Link, mit dem du ein neues Passwort festlegst.
</p>
</div>
<div>
<label className="block text-muted mb-1">E-Mail-Adresse</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de"
required
autoFocus
autoComplete="email"
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
/>
</div>
<div className="flex flex-col-reverse sm:flex-row gap-2 pt-1">
<button
type="button"
onClick={() => {
setAuthTab('login');
setError(null);
}}
className="btn-action h-11 sm:h-9 px-4 text-xs"
>
Zurück zur Anmeldung
</button>
<button
type="submit"
disabled={submitting}
className="flex-1 btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
>
{submitting ? 'Sende…' : 'Link anfordern'}
</button>
</div>
</form>
)}
{authTab === 'reset' && (
<form onSubmit={handleResetPassword} className="space-y-3.5">
<div>
<h2 className="text-sm font-bold text-main">Neues Passwort festlegen</h2>
<p className="text-muted mt-1">Danach bist du überall abgemeldet und meldest dich mit dem neuen Passwort an.</p>
</div>
<div>
<label className="block text-muted mb-1">Neues Passwort</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={4}
required
autoFocus
autoComplete="new-password"
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
/>
</div>
<div>
<label className="block text-muted mb-1">Passwort wiederholen</label>
<input
type="password"
value={newPasswordConfirm}
onChange={(e) => setNewPasswordConfirm(e.target.value)}
minLength={4}
required
autoComplete="new-password"
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
>
{submitting ? 'Speichere…' : 'Passwort speichern'}
</button>
<button
type="button"
onClick={() => {
setError(null);
setAuthTab(authStatus.passwordReset ? 'forgot' : 'login');
onResetCancel?.();
}}
className="w-full btn-action h-11 sm:h-9 text-xs"
>
{authStatus.passwordReset ? 'Neuen Link anfordern' : 'Zur Anmeldung'}
</button>
</form>
)}
{authTab === 'login' && (
<form onSubmit={handleLogin} className="space-y-3.5">
@@ -464,7 +616,22 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
</div>
<div>
<label className="block text-muted mb-1">Passwort</label>
<div className="flex items-baseline justify-between mb-1">
<label className="block text-muted">Passwort</label>
{authStatus.passwordReset && (
<button
type="button"
onClick={() => {
setAuthTab('forgot');
setError(null);
setNotice(null);
}}
className="text-accent hover:underline text-[11px] py-1"
>
Passwort vergessen?
</button>
)}
</div>
<input
type="password"
value={password}
+3
View File
@@ -632,6 +632,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<li>
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern &amp; aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
</li>
<li>
<strong className="text-main">Passwort vergessen:</strong> Hat die Administration den Mailversand eingerichtet, steht im Anmeldefenster „Passwort vergessen?“. Du bekommst einen Link, der eine Stunde gilt; mit dem neuen Passwort bist du auf allen Geräten abgemeldet.
</li>
</ul>
</div>
),
+9
View File
@@ -188,6 +188,15 @@ export const api = {
return res;
},
// Public: always the same answer, whether the address exists or not
async requestPasswordReset(email: string): Promise<void> {
await request('/api/v1/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) });
},
async resetPassword(token: string, password: string): Promise<void> {
await request('/api/v1/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) });
},
async login(loginOrEmail: string, password: string): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
method: 'POST',
+2
View File
@@ -8,6 +8,8 @@ export interface AuthStatus {
instanceName: string;
demoMode?: boolean;
sharingMode?: SharingMode;
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
passwordReset?: boolean;
// Impressum / Datenschutz of this instance (LEGAL_* environment variables)
legal?: {
imprintUrl?: string;