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:
1 parent
677fe0fc0a
commit
5e20710aa7
16 files changed
+489
-8
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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 & 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>
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user