Password reset by email
CI / check (push) Canceled after 0s

"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:
2026-10-06 01:05:30 -04:00
co-authored by Claude Opus 5.5
parent 2b02aa8e89
commit 1f0cdb68f5
21 changed files with 1811 additions and 5 deletions
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE "password_resets" (
"id" text PRIMARY KEY NOT NULL,
"user_id" uuid NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "password_resets" ADD CONSTRAINT "password_resets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "password_resets_user_idx" ON "password_resets" USING btree ("user_id");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -78,6 +78,13 @@
"when": 1791257078479, "when": 1791257078479,
"tag": "0010_avatars", "tag": "0010_avatars",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1791262411052,
"tag": "0011_password_resets",
"breakpoints": true
} }
] ]
} }
+2
View File
@@ -31,6 +31,7 @@
"drizzle-orm": "^0.45.3", "drizzle-orm": "^0.45.3",
"fastify": "^5.12.5", "fastify": "^5.12.5",
"ioredis": "^6.0.0", "ioredis": "^6.0.0",
"nodemailer": "^10.0.15",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"sharp": "^0.35.5", "sharp": "^0.35.5",
"undici": "^6.29.0", "undici": "^6.29.0",
@@ -38,6 +39,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.6.4", "@types/node": "^26.6.4",
"@types/nodemailer": "^8.0.2",
"@types/ws": "^8.18.2", "@types/ws": "^8.18.2",
"concurrently": "^10.0.5", "concurrently": "^10.0.5",
"drizzle-kit": "^0.31.11", "drizzle-kit": "^0.31.11",
+15
View File
@@ -238,6 +238,21 @@ export const featuredSongs = pgTable('featured_songs', {
.default(sql`now()`), .default(sql`now()`),
}); });
/** One-time password reset links. Only a hash of the token is stored. */
export const passwordResets = pgTable(
'password_resets',
{
id: text('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [index('password_resets_user_idx').on(t.userId)],
);
const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' }); const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' });
/** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */ /** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */
+7
View File
@@ -37,6 +37,12 @@ const schema = z.object({
* dev or self-hosting: in a hosted deployment it lets users make the server * dev or self-hosting: in a hosted deployment it lets users make the server
* request internal addresses (SSRF). * request internal addresses (SSRF).
*/ */
/** Outgoing email (password resets). Without SMTP_HOST, emails are only logged. */
SMTP_HOST: optional,
SMTP_PORT: z.coerce.number().default(465),
SMTP_USER: optional,
SMTP_PASSWORD: optional,
MAIL_FROM: z.string().default('musicshare <app@musicshare.binarygnome.com>'),
ALLOW_PRIVATE_SERVER_URLS: z ALLOW_PRIVATE_SERVER_URLS: z
.string() .string()
.default('false') .default('false')
@@ -47,3 +53,4 @@ export const env = schema.parse(process.env);
export const spotifyConfigured = Boolean(env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET); export const spotifyConfigured = Boolean(env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET);
export const appleMusicConfigured = Boolean(env.APPLE_TEAM_ID && env.APPLE_MUSICKIT_KEY_ID && env.APPLE_MUSICKIT_PRIVATE_KEY); export const appleMusicConfigured = Boolean(env.APPLE_TEAM_ID && env.APPLE_MUSICKIT_KEY_ID && env.APPLE_MUSICKIT_PRIVATE_KEY);
export const mailConfigured = Boolean(env.SMTP_HOST);
+44
View File
@@ -0,0 +1,44 @@
import nodemailer, { type Transporter } from 'nodemailer';
import { env, mailConfigured } from '../env';
export interface Mail {
to: string;
subject: string;
text: string;
html: string;
}
/** Messages "sent" while SMTP isn't configured (tests, local dev). */
export const outbox: Mail[] = [];
let transport: Transporter | null = null;
export async function sendMail(mail: Mail): Promise<void> {
if (!mailConfigured) {
outbox.push(mail);
if (env.NODE_ENV !== 'test') console.info(`[mail] SMTP not configured; would send to ${mail.to}: ${mail.subject}\n${mail.text}`);
return;
}
transport ??= nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465, // 465 is TLS from the start; 587 upgrades with STARTTLS
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } : undefined,
});
await transport.sendMail({ from: env.MAIL_FROM, ...mail });
}
const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
/** A plain, readable email: a few lines of text and one button. */
export function simpleEmail({ lines, button, footer }: { lines: string[]; button: { label: string; url: string }; footer: string }) {
const text = [...lines, '', `${button.label}: ${button.url}`, '', footer].join('\n');
const html = `<!doctype html><html><body style="margin:0;background:#0b0820;font-family:-apple-system,Segoe UI,Roboto,sans-serif;color:#ecebff">
<div style="max-width:480px;margin:0 auto;padding:32px 24px">
<div style="font-size:22px;font-weight:700;margin-bottom:20px">musicshare</div>
${lines.map((l) => `<p style="font-size:15px;line-height:1.5;margin:0 0 12px">${escape(l)}</p>`).join('\n')}
<p style="margin:24px 0"><a href="${escape(button.url)}" style="display:inline-block;background:linear-gradient(90deg,#7b2ff7,#2f8cff);background-color:#5b4cf0;color:#fff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:999px">${escape(button.label)}</a></p>
<p style="font-size:13px;line-height:1.5;color:#9a97c0;margin:0">${escape(footer)}</p>
</div></body></html>`;
return { text, html };
}
+64 -2
View File
@@ -1,13 +1,17 @@
import { hash, verify } from '@node-rs/argon2'; import { hash, verify } from '@node-rs/argon2';
import { count, eq, or, sql } from 'drizzle-orm'; import { and, count, eq, gt, isNull, or, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { loginSchema, registerSchema } from '@musicshare/shared'; import { forgotPasswordSchema, loginSchema, registerSchema, resetPasswordSchema } from '@musicshare/shared';
import { db, schema } from '../db'; import { db, schema } from '../db';
import { env } from '../env';
import { SESSION_COOKIE, createSession } from '../lib/auth'; import { SESSION_COOKIE, createSession } from '../lib/auth';
import { randomToken, sha256 } from '../lib/crypto';
import { HttpError } from '../lib/http'; import { HttpError } from '../lib/http';
import { sendMail, simpleEmail } from '../lib/mail';
import { toMeDTO } from './me'; import { toMeDTO } from './me';
const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }; const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } };
const RESET_MINUTES = 60;
export async function authRoutes(app: FastifyInstance) { export async function authRoutes(app: FastifyInstance) {
app.post('/auth/register', authRateLimit, async (req, reply) => { app.post('/auth/register', authRateLimit, async (req, reply) => {
@@ -47,6 +51,64 @@ export async function authRoutes(app: FastifyInstance) {
return { user: toMeDTO(user), token }; return { user: toMeDTO(user), token };
}); });
/**
* Emails a one-time reset link. Responds the same whether or not the email has
* an account, so it can't be used to find out who's signed up.
*/
app.post('/auth/forgot-password', { config: { rateLimit: { max: 5, timeWindow: '1 hour' } } }, async (req) => {
const { email } = forgotPasswordSchema.parse(req.body);
const [user] = await db.select().from(schema.users).where(eq(schema.users.email, email));
if (!user || user.suspendedAt) return { ok: true };
// One live link at a time: asking again replaces the old one.
await db.delete(schema.passwordResets).where(eq(schema.passwordResets.userId, user.id));
const token = randomToken();
await db.insert(schema.passwordResets).values({
id: sha256(token),
userId: user.id,
expiresAt: new Date(Date.now() + RESET_MINUTES * 60_000),
});
const url = `${env.WEB_URL.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const mail = simpleEmail({
lines: [`Hi ${user.displayName ?? user.username},`, 'Someone (hopefully you) asked to reset your musicshare password.'],
button: { label: 'Choose a new password', url },
footer: `This link works once and expires in ${RESET_MINUTES} minutes. If you didn't ask for it, you can ignore this email; your password won't change.`,
});
// Send in the background: waiting on the mail server would make the response
// slower for real accounts, which would also reveal who has one.
sendMail({ to: user.email, subject: 'Reset your musicshare password', ...mail }).catch((err) =>
req.log.error({ err }, 'password reset email failed'),
);
return { ok: true };
});
app.post('/auth/reset-password', authRateLimit, async (req, reply) => {
const input = resetPasswordSchema.parse(req.body);
const passwordHash = await hash(input.password);
const user = await db.transaction(async (tx) => {
const [reset] = await tx
.update(schema.passwordResets)
.set({ usedAt: new Date() })
.where(
and(
eq(schema.passwordResets.id, sha256(input.token)),
isNull(schema.passwordResets.usedAt),
gt(schema.passwordResets.expiresAt, new Date()),
),
)
.returning();
if (!reset) return null;
const [updated] = await tx.update(schema.users).set({ passwordHash }).where(eq(schema.users.id, reset.userId)).returning();
// Sign out everywhere: whoever knew the old password shouldn't stay signed in.
await tx.delete(schema.sessions).where(eq(schema.sessions.userId, reset.userId));
return updated ?? null;
});
if (!user) throw new HttpError(400, 'This reset link has expired or was already used. Ask for a new one.');
if (user.suspendedAt) throw new HttpError(403, 'This account has been suspended');
const token = await createSession(user.id, reply);
return { user: toMeDTO(user), token };
});
app.post('/auth/logout', async (req, reply) => { app.post('/auth/logout', async (req, reply) => {
if (req.sessionId) await db.delete(schema.sessions).where(eq(schema.sessions.id, req.sessionId)); if (req.sessionId) await db.delete(schema.sessions).where(eq(schema.sessions.id, req.sessionId));
reply.clearCookie(SESSION_COOKIE, { path: '/' }); reply.clearCookie(SESSION_COOKIE, { path: '/' });
+1 -1
View File
@@ -17,7 +17,7 @@ function assertTestTargets() {
export async function resetState() { export async function resetState() {
assertTestTargets(); assertTestTargets();
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars restart identity cascade`; await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars, password_resets restart identity cascade`;
await redis.flushdb(); await redis.flushdb();
} }
+84
View File
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { outbox } from '../src/lib/mail';
import { bearer, createUser, useTestApp } from './helpers';
const app = useTestApp();
beforeEach(() => {
outbox.length = 0;
});
/** The email is sent in the background; give it a moment. */
async function nextMail() {
for (let i = 0; i < 50 && outbox.length === 0; i++) await new Promise((r) => setTimeout(r, 10));
return outbox.at(-1);
}
const tokenFrom = (text: string) => new URL(text.match(/http\S+/)![0]).searchParams.get('token')!;
async function requestReset(email: string) {
return app().inject({ method: 'POST', url: '/auth/forgot-password', payload: { email } });
}
describe('password reset', () => {
it('emails a link that sets a new password, signs out other sessions and signs in', async () => {
const { token: oldSession } = await createUser(app(), 'alice');
expect((await requestReset(' ALICE@example.com ')).json()).toEqual({ ok: true });
const mail = await nextMail();
expect(mail).toMatchObject({ to: 'alice@example.com', subject: 'Reset your musicshare password' });
expect(mail!.text).toContain('http://web.test/reset-password?token=');
expect(mail!.html).toContain('Choose a new password');
const res = await app().inject({
method: 'POST',
url: '/auth/reset-password',
payload: { token: tokenFrom(mail!.text), password: 'brand-new-pass' },
});
expect(res.statusCode).toBe(200);
expect(res.json().user.username).toBe('alice');
expect((await app().inject({ url: '/me', headers: bearer(oldSession) })).statusCode).toBe(401);
const login = await app().inject({
method: 'POST',
url: '/auth/login',
payload: { email: 'alice@example.com', password: 'brand-new-pass' },
});
expect(login.statusCode).toBe(200);
});
it("doesn't reveal whether an email has an account", async () => {
const res = await requestReset('nobody@example.com');
expect(res.json()).toEqual({ ok: true });
await new Promise((r) => setTimeout(r, 100));
expect(outbox).toHaveLength(0);
});
it('links work once, and a new request replaces the old link', async () => {
await createUser(app(), 'alice');
await requestReset('alice@example.com');
const first = tokenFrom((await nextMail())!.text);
outbox.length = 0;
await requestReset('alice@example.com');
const second = tokenFrom((await nextMail())!.text);
const reset = (token: string) =>
app().inject({ method: 'POST', url: '/auth/reset-password', payload: { token, password: 'another-pass-1' } });
expect((await reset(first)).statusCode).toBe(400);
expect((await reset(second)).statusCode).toBe(200);
const again = await reset(second);
expect(again.statusCode).toBe(400);
expect(again.json().error).toMatch(/expired or was already used/);
});
it('rejects short passwords', async () => {
await createUser(app(), 'alice');
await requestReset('alice@example.com');
const res = await app().inject({
method: 'POST',
url: '/auth/reset-password',
payload: { token: tokenFrom((await nextMail())!.text), password: 'short' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/at least 8/);
});
});
+2
View File
@@ -22,6 +22,8 @@ const testEnv = {
SPOTIFY_CLIENT_ID: 'test-spotify-id', SPOTIFY_CLIENT_ID: 'test-spotify-id',
SPOTIFY_CLIENT_SECRET: 'test-spotify-secret', SPOTIFY_CLIENT_SECRET: 'test-spotify-secret',
ODESLI_API_KEY: '', ODESLI_API_KEY: '',
// Never send real email from tests (apps/api/.env may hold real SMTP settings).
SMTP_HOST: '',
APPLE_TEAM_ID: 'TEAMID1234', APPLE_TEAM_ID: 'TEAMID1234',
APPLE_MUSICKIT_KEY_ID: 'KEYID12345', APPLE_MUSICKIT_KEY_ID: 'KEYID12345',
APPLE_MUSICKIT_PRIVATE_KEY: appleKey.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString().replace(/\n/g, '\\n'), APPLE_MUSICKIT_PRIVATE_KEY: appleKey.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString().replace(/\n/g, '\\n'),
+1
View File
@@ -97,6 +97,7 @@ function AppNavigator() {
<Stack.Protected guard={!signedIn}> <Stack.Protected guard={!signedIn}>
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="register" options={{ title: 'Create account' }} /> <Stack.Screen name="register" options={{ title: 'Create account' }} />
<Stack.Screen name="forgot-password" options={{ title: 'Reset password' }} />
</Stack.Protected> </Stack.Protected>
</Stack> </Stack>
</SWRConfig> </SWRConfig>
+62
View File
@@ -0,0 +1,62 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform } from 'react-native';
import { Screen } from '@/components/Screen';
import { Body, Button, Field, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { colors } from '@/lib/theme';
/** Sends the reset email. The link in it opens the website, where the new password is set. */
export default function ForgotPasswordScreen() {
const [email, setEmail] = useState('');
const [sentTo, setSentTo] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setError(null);
try {
await api.forgotPassword(email.trim());
setSentTo(email.trim());
} catch (err) {
setError((err as Error).message);
}
setBusy(false);
};
if (sentTo) {
return (
<Screen>
<Title>Check your email</Title>
<Body>
If {sentTo} has a musicshare account, we&apos;ve sent it a link to choose a new password. It expires in an hour.
</Body>
<Body muted>Open the link, set your new password, then log in here with it. Nothing there? Check your spam folder.</Body>
<Button variant="primary" title="Back to log in" onPress={() => router.back()} />
</Screen>
);
}
return (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<Screen>
<Body muted>Enter the email you signed up with and we&apos;ll send you a link to choose a new password.</Body>
<Field
label="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
textContentType="emailAddress"
autoFocus
onSubmitEditing={submit}
returnKeyType="send"
/>
{error && <Body style={{ color: colors.danger }}>{error}</Body>}
<Button variant="primary" title="Send reset link" loading={busy} onPress={submit} />
</Screen>
</KeyboardAvoidingView>
);
}
+5
View File
@@ -70,6 +70,11 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
/> />
{error && <Body style={{ color: colors.danger }}>{error}</Body>} {error && <Body style={{ color: colors.danger }}>{error}</Body>}
<Button variant="primary" title={mode === 'login' ? 'Log in' : 'Create account'} loading={busy} onPress={submit} /> <Button variant="primary" title={mode === 'login' ? 'Log in' : 'Create account'} loading={busy} onPress={submit} />
{mode === 'login' && (
<Link href="/forgot-password" style={styles.link}>
Forgot your password?
</Link>
)}
{mode === 'login' ? ( {mode === 'login' ? (
<Link href="/register" style={styles.link}> <Link href="/register" style={styles.link}>
New here? Create an account New here? Create an account
+59
View File
@@ -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&apos;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&apos;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>
);
}
+1
View File
@@ -126,6 +126,7 @@ input:focus, textarea:focus, select:focus {
.card-header .pin { flex-shrink: 0; } .card-header .pin { flex-shrink: 0; }
.form { display: flex; flex-direction: column; gap: 0.8rem; } .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 { 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. */ /* Now-playing cards take their colour from the album art. */
.np-card { border-color: rgba(255, 255, 255, 0.11); } .np-card { border-color: rgba(255, 255, 255, 0.11); }
+81
View File
@@ -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>
);
}
+5
View File
@@ -53,6 +53,11 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
<button className="btn btn-primary" disabled={busy}> <button className="btn btn-primary" disabled={busy}>
{mode === 'login' ? 'Log in' : 'Sign up'} {mode === 'login' ? 'Log in' : 'Sign up'}
</button> </button>
{mode === 'login' && (
<p className="muted">
<Link href="/forgot-password">Forgot your password?</Link>
</p>
)}
<p className="muted"> <p className="muted">
{mode === 'login' ? ( {mode === 'login' ? (
<> <>
+21
View File
@@ -33,6 +33,7 @@
"drizzle-orm": "^0.45.3", "drizzle-orm": "^0.45.3",
"fastify": "^5.12.5", "fastify": "^5.12.5",
"ioredis": "^6.0.0", "ioredis": "^6.0.0",
"nodemailer": "^10.0.15",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"sharp": "^0.35.5", "sharp": "^0.35.5",
"undici": "^6.29.0", "undici": "^6.29.0",
@@ -40,6 +41,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.6.4", "@types/node": "^26.6.4",
"@types/nodemailer": "^8.0.2",
"@types/ws": "^8.18.2", "@types/ws": "^8.18.2",
"concurrently": "^10.0.5", "concurrently": "^10.0.5",
"drizzle-kit": "^0.31.11", "drizzle-kit": "^0.31.11",
@@ -7494,6 +7496,16 @@
"undici-types": "~8.9.0" "undici-types": "~8.9.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz",
"integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -15362,6 +15374,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nodemailer": {
"version": "10.0.15",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.15.tgz",
"integrity": "sha512-EUqp5PhtcsYXs9Fq/lS7s/8zlTrnBqmzZWzFFROrNikMiz+om/YRKMwqN907t+0A1KKyT8jd39CBUbFZmaZmcA==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/npm-package-arg": { "node_modules/npm-package-arg": {
"version": "11.0.3", "version": "11.0.3",
"resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz",
+4 -1
View File
@@ -7,7 +7,7 @@ import type {
UserSummaryDTO, UserSummaryDTO,
} from './dto'; } from './dto';
import type { DeviceDTO } from './devices'; import type { DeviceDTO } from './devices';
import type { LoginInput, RegisterInput, SetFeaturedInput, UpdateMeInput } from './schemas'; import type { LoginInput, RegisterInput, ResetPasswordInput, SetFeaturedInput, UpdateMeInput } from './schemas';
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
@@ -66,6 +66,9 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
register: (input: RegisterInput) => request<AuthResponse>('/auth/register', { body: input }), register: (input: RegisterInput) => request<AuthResponse>('/auth/register', { body: input }),
login: (input: LoginInput) => request<AuthResponse>('/auth/login', { body: input }), login: (input: LoginInput) => request<AuthResponse>('/auth/login', { body: input }),
/** Always succeeds, so it doesn't reveal which emails have accounts. */
forgotPassword: (email: string) => request<{ ok: true }>('/auth/forgot-password', { body: { email } }),
resetPassword: (input: ResetPasswordInput) => request<AuthResponse>('/auth/reset-password', { body: input }),
logout: () => request('/auth/logout', { method: 'POST', body: {} }), logout: () => request('/auth/logout', { method: 'POST', body: {} }),
me: () => request<MeDTO>('/me'), me: () => request<MeDTO>('/me'),
+11 -1
View File
@@ -10,10 +10,19 @@ export const usernameSchema = z
// Trim before validating: iOS keyboards often append a space after autocompleting an email. // Trim before validating: iOS keyboards often append a space after autocompleting an email.
const emailSchema = z.string().trim().toLowerCase().pipe(z.email()); const emailSchema = z.string().trim().toLowerCase().pipe(z.email());
const newPasswordSchema = z.string().min(8, 'Password must be at least 8 characters').max(200);
export const registerSchema = z.object({ export const registerSchema = z.object({
username: usernameSchema, username: usernameSchema,
email: emailSchema, email: emailSchema,
password: z.string().min(8, 'Password must be at least 8 characters').max(200), password: newPasswordSchema,
});
export const forgotPasswordSchema = z.object({ email: emailSchema });
export const resetPasswordSchema = z.object({
token: z.string().min(20).max(200),
password: newPasswordSchema,
}); });
export const loginSchema = z.object({ export const loginSchema = z.object({
@@ -40,6 +49,7 @@ export const connectUsernameSchema = z.object({
export type RegisterInput = z.infer<typeof registerSchema>; export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>; export type LoginInput = z.infer<typeof loginSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export type UpdateMeInput = z.infer<typeof updateMeSchema>; export type UpdateMeInput = z.infer<typeof updateMeSchema>;
export type SetFeaturedInput = z.infer<typeof setFeaturedSchema>; export type SetFeaturedInput = z.infer<typeof setFeaturedSchema>;