"Forgot your password?" on web and mobile sends a one-time link (1 hour) to the account's email; the website's reset page sets the new password, signs out every other session and signs in. The request responds the same whether or not the email has an account, and the email goes out in the background so timing doesn't reveal it either. Only a hash of the link token is stored. Sends through SMTP_* settings; without them, emails are logged (and captured in tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const email = String(new FormData(e.currentTarget).get('email') ?? '').trim();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api('/auth/forgot-password', { body: { email } });
|
||||
setSentTo(email);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
if (sentTo) {
|
||||
return (
|
||||
<div className="card form narrow">
|
||||
<h1>Check your email</h1>
|
||||
<p>
|
||||
If <strong>{sentTo}</strong> has a musicshare account, we've sent it a link to choose a new password. It
|
||||
expires in an hour.
|
||||
</p>
|
||||
<p className="muted">Nothing there? Check your spam folder, or try again in a minute.</p>
|
||||
<Link href="/login" className="btn">
|
||||
Back to log in
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card form narrow" onSubmit={onSubmit}>
|
||||
<h1>Reset your password</h1>
|
||||
<p className="muted">Enter the email you signed up with and we'll send you a link to choose a new password.</p>
|
||||
<label>
|
||||
Email
|
||||
<input name="email" type="email" required autoComplete="email" autoFocus />
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button className="btn btn-primary" disabled={busy}>
|
||||
{busy ? 'Sending…' : 'Send reset link'}
|
||||
</button>
|
||||
<p className="muted">
|
||||
Remembered it? <Link href="/login">Log in</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -126,6 +126,7 @@ input:focus, textarea:focus, select:focus {
|
||||
.card-header .pin { flex-shrink: 0; }
|
||||
.form { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
.form label { display: flex; flex-direction: column; gap: 0.35rem; font-weight: 600; font-size: 0.9rem; }
|
||||
.form label.checkbox { flex-direction: row; align-items: center; gap: 0.5rem; font-weight: 500; }
|
||||
|
||||
/* Now-playing cards take their colour from the album art. */
|
||||
.np-card { border-color: rgba(255, 255, 255, 0.11); }
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useState } from 'react';
|
||||
import type { MeDTO } from '@musicshare/shared';
|
||||
import { api, useMe } from '@/lib/api';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPassword />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetPassword() {
|
||||
const token = useSearchParams().get('token');
|
||||
const router = useRouter();
|
||||
const { mutate } = useMe();
|
||||
const [show, setShow] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="card form narrow">
|
||||
<h1>Link incomplete</h1>
|
||||
<p>This reset link is missing part of its address. Open it straight from the email, or ask for a new one.</p>
|
||||
<Link href="/forgot-password" className="btn btn-primary">
|
||||
Send a new link
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const password = String(new FormData(e.currentTarget).get('password') ?? '');
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { user } = await api<{ user: MeDTO }>('/auth/reset-password', { body: { token, password } });
|
||||
await mutate(user, { revalidate: false });
|
||||
router.replace('/');
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="card form narrow" onSubmit={onSubmit}>
|
||||
<h1>Choose a new password</h1>
|
||||
<label>
|
||||
New password
|
||||
<input
|
||||
name="password"
|
||||
type={show ? 'text' : 'password'}
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={200}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={show} onChange={(e) => setShow(e.target.checked)} /> Show password
|
||||
</label>
|
||||
{error && (
|
||||
<p className="error">
|
||||
{error} {error.includes('expired') && <Link href="/forgot-password">Send a new link</Link>}
|
||||
</p>
|
||||
)}
|
||||
<button className="btn btn-primary" disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save and log in'}
|
||||
</button>
|
||||
<p className="muted">This signs you out on your other devices.</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user