- Sign-up emails a confirmation link (24h); unconfirmed accounts see a banner on the web with "send it again", and status in Settings. - Change email (needs the password): the new address gets a confirmation link and only replaces the old one once clicked; the old address gets a heads-up with a reset-password link. Pending changes can be cancelled. - Change password (needs the current one): signs out other sessions. - Password fields have Show/Hide on web and mobile. 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,12 @@
|
|||||||
|
CREATE TABLE "email_verifications" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"email" varchar(320) NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "email_verified_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "pending_email" varchar(320);--> statement-breakpoint
|
||||||
|
ALTER TABLE "email_verifications" ADD CONSTRAINT "email_verifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "email_verifications_user_idx" ON "email_verifications" USING btree ("user_id");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
|||||||
"when": 1791262411052,
|
"when": 1791262411052,
|
||||||
"tag": "0011_password_resets",
|
"tag": "0011_password_resets",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791305642128,
|
||||||
|
"tag": "0012_email_verification",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import { loadUser } from './lib/auth';
|
|||||||
import { HttpError } from './lib/http';
|
import { HttpError } from './lib/http';
|
||||||
import { redis } from './lib/redis';
|
import { redis } from './lib/redis';
|
||||||
import { adminRoutes } from './routes/admin';
|
import { adminRoutes } from './routes/admin';
|
||||||
|
import { accountRoutes } from './routes/account';
|
||||||
import { authRoutes } from './routes/auth';
|
import { authRoutes } from './routes/auth';
|
||||||
import { avatarRoutes } from './routes/avatars';
|
import { avatarRoutes } from './routes/avatars';
|
||||||
import { connectionRoutes } from './routes/connections';
|
import { connectionRoutes } from './routes/connections';
|
||||||
@@ -66,6 +67,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.register(authRoutes);
|
await app.register(authRoutes);
|
||||||
|
await app.register(accountRoutes);
|
||||||
await app.register(meRoutes);
|
await app.register(meRoutes);
|
||||||
await app.register(avatarRoutes);
|
await app.register(avatarRoutes);
|
||||||
await app.register(userRoutes);
|
await app.register(userRoutes);
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export const users = pgTable('users', {
|
|||||||
username: varchar('username', { length: 32 }).notNull().unique(),
|
username: varchar('username', { length: 32 }).notNull().unique(),
|
||||||
email: varchar('email', { length: 320 }).notNull().unique(),
|
email: varchar('email', { length: 320 }).notNull().unique(),
|
||||||
passwordHash: text('password_hash').notNull(),
|
passwordHash: text('password_hash').notNull(),
|
||||||
|
/** Set once they click the link we email them; null until then. */
|
||||||
|
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
||||||
|
/** A new address waiting for its confirmation link; `email` changes only once it's clicked. */
|
||||||
|
pendingEmail: varchar('pending_email', { length: 320 }),
|
||||||
displayName: varchar('display_name', { length: 64 }),
|
displayName: varchar('display_name', { length: 64 }),
|
||||||
bio: varchar('bio', { length: 280 }),
|
bio: varchar('bio', { length: 280 }),
|
||||||
/** Path of the uploaded picture (`/avatars/<id>?v=<version>`), resolved against API_PUBLIC_URL when sent. */
|
/** Path of the uploaded picture (`/avatars/<id>?v=<version>`), resolved against API_PUBLIC_URL when sent. */
|
||||||
@@ -238,6 +242,21 @@ export const featuredSongs = pgTable('featured_songs', {
|
|||||||
.default(sql`now()`),
|
.default(sql`now()`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
|
||||||
|
export const emailVerifications = pgTable(
|
||||||
|
'email_verifications',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
email: varchar('email', { length: 320 }).notNull(),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('email_verifications_user_idx').on(t.userId)],
|
||||||
|
);
|
||||||
|
|
||||||
/** One-time password reset links. Only a hash of the token is stored. */
|
/** One-time password reset links. Only a hash of the token is stored. */
|
||||||
export const passwordResets = pgTable(
|
export const passwordResets = pgTable(
|
||||||
'password_resets',
|
'password_resets',
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { hash, verify } from '@node-rs/argon2';
|
||||||
|
import { and, eq, gt, ne } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { changeEmailSchema, changePasswordSchema, verifyEmailSchema } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import { requireUser } from '../lib/auth';
|
||||||
|
import { sha256 } from '../lib/crypto';
|
||||||
|
import { HttpError } from '../lib/http';
|
||||||
|
import { notifyEmailChange, sendVerificationEmail } from '../services/emailVerification';
|
||||||
|
import { toMeDTO } from './me';
|
||||||
|
|
||||||
|
const sensitive = { config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } };
|
||||||
|
|
||||||
|
export async function accountRoutes(app: FastifyInstance) {
|
||||||
|
/** The link in the confirmation email. Works signed in or not (it's often opened on another device). */
|
||||||
|
app.post('/auth/verify-email', sensitive, async (req) => {
|
||||||
|
const { token } = verifyEmailSchema.parse(req.body);
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const [row] = await tx
|
||||||
|
.delete(schema.emailVerifications)
|
||||||
|
.where(and(eq(schema.emailVerifications.id, sha256(token)), gt(schema.emailVerifications.expiresAt, new Date())))
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new HttpError(400, 'This confirmation link has expired or was already used.');
|
||||||
|
const [taken] = await tx
|
||||||
|
.select({ id: schema.users.id })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(and(eq(schema.users.email, row.email), ne(schema.users.id, row.userId)));
|
||||||
|
if (taken) throw new HttpError(409, 'That email now belongs to another account.');
|
||||||
|
await tx
|
||||||
|
.update(schema.users)
|
||||||
|
.set({ email: row.email, emailVerifiedAt: new Date(), pendingEmail: null })
|
||||||
|
.where(eq(schema.users.id, row.userId));
|
||||||
|
return { ok: true as const, email: row.email };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/me/verify-email/resend', { config: { rateLimit: { max: 3, timeWindow: '1 hour' } } }, async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
const email = user.pendingEmail ?? (user.emailVerifiedAt ? null : user.email);
|
||||||
|
if (!email) throw new HttpError(400, 'Your email is already confirmed.');
|
||||||
|
await sendVerificationEmail(user, email, req.log);
|
||||||
|
return { ok: true as const };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/me/password', sensitive, async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
const input = changePasswordSchema.parse(req.body);
|
||||||
|
if (!(await verify(user.passwordHash, input.currentPassword))) throw new HttpError(403, 'Your current password is wrong.');
|
||||||
|
await db.update(schema.users).set({ passwordHash: await hash(input.newPassword) }).where(eq(schema.users.id, user.id));
|
||||||
|
// Keep this session; sign out everywhere else.
|
||||||
|
await db
|
||||||
|
.delete(schema.sessions)
|
||||||
|
.where(and(eq(schema.sessions.userId, user.id), ne(schema.sessions.id, req.sessionId!)));
|
||||||
|
return { ok: true as const };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/me/email', sensitive, async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
const input = changeEmailSchema.parse(req.body);
|
||||||
|
if (!(await verify(user.passwordHash, input.password))) throw new HttpError(403, 'Your password is wrong.');
|
||||||
|
if (input.email === user.email) throw new HttpError(400, "That's already your email.");
|
||||||
|
const [taken] = await db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.email, input.email));
|
||||||
|
if (taken) throw new HttpError(409, 'That email is already registered.');
|
||||||
|
const [updated] = await db.update(schema.users).set({ pendingEmail: input.email }).where(eq(schema.users.id, user.id)).returning();
|
||||||
|
await sendVerificationEmail(updated!, input.email, req.log);
|
||||||
|
if (user.emailVerifiedAt) notifyEmailChange(user, input.email, req.log);
|
||||||
|
return toMeDTO(updated!);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/me/email/pending', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
await db.delete(schema.emailVerifications).where(eq(schema.emailVerifications.userId, user.id));
|
||||||
|
const [updated] = await db.update(schema.users).set({ pendingEmail: null }).where(eq(schema.users.id, user.id)).returning();
|
||||||
|
// Unconfirmed sign-ups keep a way to confirm their current address.
|
||||||
|
if (!updated!.emailVerifiedAt) await sendVerificationEmail(updated!, updated!.email, req.log);
|
||||||
|
return toMeDTO(updated!);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { SESSION_COOKIE, createSession } from '../lib/auth';
|
|||||||
import { randomToken, sha256 } from '../lib/crypto';
|
import { randomToken, sha256 } from '../lib/crypto';
|
||||||
import { HttpError } from '../lib/http';
|
import { HttpError } from '../lib/http';
|
||||||
import { sendMail, simpleEmail } from '../lib/mail';
|
import { sendMail, simpleEmail } from '../lib/mail';
|
||||||
|
import { sendVerificationEmail } from '../services/emailVerification';
|
||||||
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' } } };
|
||||||
@@ -36,6 +37,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
.returning();
|
.returning();
|
||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
|
await sendVerificationEmail(user!, user!.email, req.log);
|
||||||
const token = await createSession(user!.id, reply);
|
const token = await createSession(user!.id, reply);
|
||||||
return { user: toMeDTO(user!), token };
|
return { user: toMeDTO(user!), token };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export const toMeDTO = (u: User): MeDTO => ({
|
|||||||
displayName: u.displayName,
|
displayName: u.displayName,
|
||||||
avatarUrl: publicAvatarUrl(u.avatarUrl),
|
avatarUrl: publicAvatarUrl(u.avatarUrl),
|
||||||
email: u.email,
|
email: u.email,
|
||||||
|
emailVerified: Boolean(u.emailVerifiedAt),
|
||||||
|
pendingEmail: u.pendingEmail,
|
||||||
bio: u.bio,
|
bio: u.bio,
|
||||||
preferredService: u.preferredService,
|
preferredService: u.preferredService,
|
||||||
role: u.role,
|
role: u.role,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { User } from '../db/schema';
|
||||||
|
import { env } from '../env';
|
||||||
|
import { randomToken, sha256 } from '../lib/crypto';
|
||||||
|
import { sendMail, simpleEmail } from '../lib/mail';
|
||||||
|
|
||||||
|
const VERIFY_HOURS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emails a confirmation link for `email` (their current address after sign-up,
|
||||||
|
* or a new one they're switching to). Replaces any earlier link.
|
||||||
|
*/
|
||||||
|
export async function sendVerificationEmail(user: User, email: string, log: FastifyBaseLogger) {
|
||||||
|
await db.delete(schema.emailVerifications).where(eq(schema.emailVerifications.userId, user.id));
|
||||||
|
const token = randomToken();
|
||||||
|
await db.insert(schema.emailVerifications).values({
|
||||||
|
id: sha256(token),
|
||||||
|
userId: user.id,
|
||||||
|
email,
|
||||||
|
expiresAt: new Date(Date.now() + VERIFY_HOURS * 3600_000),
|
||||||
|
});
|
||||||
|
const changing = email !== user.email;
|
||||||
|
const url = `${env.WEB_URL.replace(/\/$/, '')}/verify-email?token=${encodeURIComponent(token)}`;
|
||||||
|
const mail = simpleEmail({
|
||||||
|
lines: changing
|
||||||
|
? [`Hi ${user.displayName ?? user.username},`, `Confirm ${email} as the new email for your musicshare account.`]
|
||||||
|
: [`Welcome to musicshare, ${user.displayName ?? user.username}!`, 'Confirm your email so you can reset your password if you ever forget it.'],
|
||||||
|
button: { label: changing ? 'Confirm new email' : 'Confirm my email', url },
|
||||||
|
footer: `This link expires in ${VERIFY_HOURS} hours. If you didn't ask for this, you can ignore it.`,
|
||||||
|
});
|
||||||
|
sendMail({ to: email, subject: changing ? 'Confirm your new musicshare email' : 'Confirm your musicshare email', ...mail }).catch(
|
||||||
|
(err) => log.error({ err }, 'verification email failed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tells the old address an email change was requested, so a hijacker can't do it quietly. */
|
||||||
|
export function notifyEmailChange(user: User, newEmail: string, log: FastifyBaseLogger) {
|
||||||
|
const mail = simpleEmail({
|
||||||
|
lines: [
|
||||||
|
`Hi ${user.displayName ?? user.username},`,
|
||||||
|
`Someone asked to change your musicshare email to ${newEmail}. It only changes once that address is confirmed.`,
|
||||||
|
"If this wasn't you, reset your password now.",
|
||||||
|
],
|
||||||
|
button: { label: 'Reset my password', url: `${env.WEB_URL.replace(/\/$/, '')}/forgot-password` },
|
||||||
|
footer: 'You can ignore this email if you made the change.',
|
||||||
|
});
|
||||||
|
sendMail({ to: user.email, subject: 'Your musicshare email is being changed', ...mail }).catch((err) =>
|
||||||
|
log.error({ err }, 'email change notice failed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mailTo(to: string, subject: RegExp) {
|
||||||
|
const find = () => outbox.filter((m) => m.to === to && subject.test(m.subject)).at(-1);
|
||||||
|
for (let i = 0; i < 50 && !find(); i++) await new Promise((r) => setTimeout(r, 10));
|
||||||
|
return find();
|
||||||
|
}
|
||||||
|
const tokenFrom = (text: string) => new URL(text.match(/http\S+verify-email\S+/)![0]).searchParams.get('token')!;
|
||||||
|
const verify = (token: string) => app().inject({ method: 'POST', url: '/auth/verify-email', payload: { token } });
|
||||||
|
const me = async (token: string) => (await app().inject({ url: '/me', headers: bearer(token) })).json();
|
||||||
|
|
||||||
|
describe('email verification', () => {
|
||||||
|
it('sends a confirmation link at sign-up that confirms the address', async () => {
|
||||||
|
const { token, user } = await createUser(app(), 'alice');
|
||||||
|
expect(user.emailVerified).toBe(false);
|
||||||
|
const mail = await mailTo('alice@example.com', /Confirm your musicshare email/);
|
||||||
|
expect(mail!.text).toContain('http://web.test/verify-email?token=');
|
||||||
|
|
||||||
|
const res = await verify(tokenFrom(mail!.text));
|
||||||
|
expect(res.json()).toEqual({ ok: true, email: 'alice@example.com' });
|
||||||
|
expect((await me(token)).emailVerified).toBe(true);
|
||||||
|
expect((await verify(tokenFrom(mail!.text))).statusCode).toBe(400); // one use
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resends, and refuses once confirmed', async () => {
|
||||||
|
const { token } = await createUser(app(), 'alice');
|
||||||
|
const resend = () => app().inject({ method: 'POST', url: '/me/verify-email/resend', headers: bearer(token), payload: {} });
|
||||||
|
expect((await resend()).statusCode).toBe(200);
|
||||||
|
await verify(tokenFrom((await mailTo('alice@example.com', /Confirm/))!.text));
|
||||||
|
expect((await resend()).statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('changing email', () => {
|
||||||
|
it('only switches once the new address is confirmed, and warns the old one', async () => {
|
||||||
|
const { token } = await createUser(app(), 'alice');
|
||||||
|
await verify(tokenFrom((await mailTo('alice@example.com', /Confirm/))!.text));
|
||||||
|
|
||||||
|
const res = await app().inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/me/email',
|
||||||
|
headers: bearer(token),
|
||||||
|
payload: { email: 'New@Example.com', password: 'password123' },
|
||||||
|
});
|
||||||
|
expect(res.json()).toMatchObject({ email: 'alice@example.com', pendingEmail: 'new@example.com' });
|
||||||
|
expect(await mailTo('alice@example.com', /being changed/)).toBeTruthy();
|
||||||
|
|
||||||
|
await verify(tokenFrom((await mailTo('new@example.com', /Confirm your new/))!.text));
|
||||||
|
expect(await me(token)).toMatchObject({ email: 'new@example.com', emailVerified: true, pendingEmail: null });
|
||||||
|
const login = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'new@example.com', password: 'password123' } });
|
||||||
|
expect(login.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs the password, and a free address', async () => {
|
||||||
|
const { token } = await createUser(app(), 'alice');
|
||||||
|
await createUser(app(), 'bob');
|
||||||
|
const change = (email: string, password: string) =>
|
||||||
|
app().inject({ method: 'POST', url: '/me/email', headers: bearer(token), payload: { email, password } });
|
||||||
|
expect((await change('x@example.com', 'wrong-password')).statusCode).toBe(403);
|
||||||
|
expect((await change('bob@example.com', 'password123')).statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be cancelled', async () => {
|
||||||
|
const { token } = await createUser(app(), 'alice');
|
||||||
|
await app().inject({ method: 'POST', url: '/me/email', headers: bearer(token), payload: { email: 'x@example.com', password: 'password123' } });
|
||||||
|
const link = tokenFrom((await mailTo('x@example.com', /Confirm/))!.text);
|
||||||
|
const res = await app().inject({ method: 'DELETE', url: '/me/email/pending', headers: bearer(token) });
|
||||||
|
expect(res.json().pendingEmail).toBeNull();
|
||||||
|
expect((await verify(link)).statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('changing password', () => {
|
||||||
|
it('needs the current password and signs out other sessions only', async () => {
|
||||||
|
const { token } = await createUser(app(), 'alice');
|
||||||
|
const other = (
|
||||||
|
await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'password123' } })
|
||||||
|
).json().token;
|
||||||
|
const change = (currentPassword: string) =>
|
||||||
|
app().inject({ method: 'POST', url: '/me/password', headers: bearer(token), payload: { currentPassword, newPassword: 'better-pass-9' } });
|
||||||
|
|
||||||
|
expect((await change('nope')).statusCode).toBe(403);
|
||||||
|
expect((await change('password123')).statusCode).toBe(200);
|
||||||
|
expect((await app().inject({ url: '/me', headers: bearer(token) })).statusCode).toBe(200);
|
||||||
|
expect((await app().inject({ url: '/me', headers: bearer(other) })).statusCode).toBe(401);
|
||||||
|
const login = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'better-pass-9' } });
|
||||||
|
expect(login.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, password_resets restart identity cascade`;
|
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars, password_resets, email_verifications restart identity cascade`;
|
||||||
await redis.flushdb();
|
await redis.flushdb();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ beforeEach(() => {
|
|||||||
outbox.length = 0;
|
outbox.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The email is sent in the background; give it a moment. */
|
/** The reset email is sent in the background; give it a moment. (Sign-up sends its own confirmation email.) */
|
||||||
async function nextMail() {
|
async function nextMail() {
|
||||||
for (let i = 0; i < 50 && outbox.length === 0; i++) await new Promise((r) => setTimeout(r, 10));
|
const reset = () => outbox.filter((m) => m.subject.startsWith('Reset')).at(-1);
|
||||||
return outbox.at(-1);
|
for (let i = 0; i < 50 && !reset(); i++) await new Promise((r) => setTimeout(r, 10));
|
||||||
|
return reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenFrom = (text: string) => new URL(text.match(/http\S+/)![0]).searchParams.get('token')!;
|
const tokenFrom = (text: string) => new URL(text.match(/http\S+/)![0]).searchParams.get('token')!;
|
||||||
@@ -50,7 +51,7 @@ describe('password reset', () => {
|
|||||||
const res = await requestReset('nobody@example.com');
|
const res = await requestReset('nobody@example.com');
|
||||||
expect(res.json()).toEqual({ ok: true });
|
expect(res.json()).toEqual({ ok: true });
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
expect(outbox).toHaveLength(0);
|
expect(outbox.filter((m) => m.subject.startsWith('Reset'))).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('links work once, and a new request replaces the old link', async () => {
|
it('links work once, and a new request replaces the old link', async () => {
|
||||||
|
|||||||
@@ -418,7 +418,22 @@ function JellyfinConnection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Account() {
|
function Account() {
|
||||||
const { me, signOut } = useSession();
|
const { me, setMe, signOut } = useSession();
|
||||||
|
const resend = async () => {
|
||||||
|
try {
|
||||||
|
await api.resendVerification();
|
||||||
|
Alert.alert('Sent', `We sent a new link to ${me?.email}.`);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cancelChange = async () => {
|
||||||
|
try {
|
||||||
|
setMe(await api.cancelEmailChange());
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
const confirmDelete = () =>
|
const confirmDelete = () =>
|
||||||
Alert.alert('Delete your account?', 'This permanently removes your profile, follows and connected services.', [
|
Alert.alert('Delete your account?', 'This permanently removes your profile, follows and connected services.', [
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
@@ -439,8 +454,22 @@ function Account() {
|
|||||||
<Card>
|
<Card>
|
||||||
<Heading>Account</Heading>
|
<Heading>Account</Heading>
|
||||||
<Body muted style={styles.small}>
|
<Body muted style={styles.small}>
|
||||||
Signed in as @{me?.username} · {me?.plan === 'free' ? 'Free plan' : 'Plus'}
|
Signed in as @{me?.username}
|
||||||
</Body>
|
</Body>
|
||||||
|
<Body style={styles.small}>
|
||||||
|
{me?.email} · {me?.emailVerified ? 'confirmed' : 'not confirmed'}
|
||||||
|
</Body>
|
||||||
|
{me?.pendingEmail ? (
|
||||||
|
<Body muted style={styles.small}>
|
||||||
|
Changing to {me.pendingEmail}: open the link we sent there.
|
||||||
|
</Body>
|
||||||
|
) : null}
|
||||||
|
<Row>
|
||||||
|
{me && !me.emailVerified && !me.pendingEmail && <Button small variant="primary" title="Resend confirmation" onPress={resend} />}
|
||||||
|
{me?.pendingEmail && <Button small title="Cancel email change" onPress={cancelChange} />}
|
||||||
|
<Button small title="Change email" onPress={() => router.push('/change-email')} />
|
||||||
|
<Button small title="Change password" onPress={() => router.push('/change-password')} />
|
||||||
|
</Row>
|
||||||
<Button small title="Blocked accounts" onPress={() => router.push('/blocked')} style={{ alignSelf: 'flex-start' }} />
|
<Button small title="Blocked accounts" onPress={() => router.push('/blocked')} style={{ alignSelf: 'flex-start' }} />
|
||||||
<Row>
|
<Row>
|
||||||
<Button small title="Log out" onPress={signOut} />
|
<Button small title="Log out" onPress={signOut} />
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ function AppNavigator() {
|
|||||||
<Stack.Screen name="u/[username]/following" options={{ title: 'Following' }} />
|
<Stack.Screen name="u/[username]/following" options={{ title: 'Following' }} />
|
||||||
<Stack.Screen name="report/[username]" options={{ title: 'Report', presentation: 'modal' }} />
|
<Stack.Screen name="report/[username]" options={{ title: 'Report', presentation: 'modal' }} />
|
||||||
<Stack.Screen name="blocked" options={{ title: 'Blocked accounts' }} />
|
<Stack.Screen name="blocked" options={{ title: 'Blocked accounts' }} />
|
||||||
|
<Stack.Screen name="change-password" options={{ title: 'Change password', presentation: 'modal' }} />
|
||||||
|
<Stack.Screen name="change-email" options={{ title: 'Change email', presentation: 'modal' }} />
|
||||||
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Protected guard={!signedIn}>
|
<Stack.Protected guard={!signedIn}>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, KeyboardAvoidingView, Platform } from 'react-native';
|
||||||
|
import { Screen } from '@/components/Screen';
|
||||||
|
import { Body, Button, Field } from '@/components/ui';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export default function ChangeEmailScreen() {
|
||||||
|
const { setMe } = useSession();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setMe(await api.changeEmail({ email: email.trim(), password }));
|
||||||
|
Alert.alert('Check your email', `Open the link we sent to ${email.trim()} to finish the change.`);
|
||||||
|
router.back();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, "Couldn't change your email");
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||||
|
<Screen>
|
||||||
|
<Field
|
||||||
|
label="New email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
keyboardType="email-address"
|
||||||
|
textContentType="emailAddress"
|
||||||
|
/>
|
||||||
|
<Field label="Your password" value={password} onChangeText={setPassword} secureTextEntry autoComplete="current-password" textContentType="password" />
|
||||||
|
<Body muted>Your email changes once you open the confirmation link we send to the new address.</Body>
|
||||||
|
<Button variant="primary" title="Send confirmation link" loading={busy} disabled={!email.trim() || !password} onPress={submit} />
|
||||||
|
</Screen>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, KeyboardAvoidingView, Platform } from 'react-native';
|
||||||
|
import { Screen } from '@/components/Screen';
|
||||||
|
import { Body, Button, Field } from '@/components/ui';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export default function ChangePasswordScreen() {
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [next, setNext] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.changePassword({ currentPassword: current, newPassword: next });
|
||||||
|
Alert.alert('Password changed', 'Your other devices were signed out.');
|
||||||
|
router.back();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, "Couldn't change your password");
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||||
|
<Screen>
|
||||||
|
<Field label="Current password" value={current} onChangeText={setCurrent} secureTextEntry autoComplete="current-password" textContentType="password" />
|
||||||
|
<Field label="New password" value={next} onChangeText={setNext} secureTextEntry autoComplete="new-password" textContentType="newPassword" />
|
||||||
|
<Body muted>At least 8 characters. This signs you out on your other devices.</Body>
|
||||||
|
<Button variant="primary" title="Change password" loading={busy} disabled={!current || next.length < 8} onPress={submit} />
|
||||||
|
</Screen>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -97,22 +97,37 @@ export function Button({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A text field. Password fields (secureTextEntry) get a Show/Hide button. */
|
||||||
export const Field = forwardRef<TextInput, TextInputProps & { label?: string }>(function Field(
|
export const Field = forwardRef<TextInput, TextInputProps & { label?: string }>(function Field(
|
||||||
{ label, style, onFocus, onBlur, ...props },
|
{ label, style, onFocus, onBlur, secureTextEntry, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
return (
|
return (
|
||||||
<View style={{ gap: space.xs }}>
|
<View style={{ gap: space.xs }}>
|
||||||
{label && <Text style={styles.label}>{label}</Text>}
|
{label && <Text style={styles.label}>{label}</Text>}
|
||||||
<TextInput
|
<View>
|
||||||
ref={ref}
|
<TextInput
|
||||||
placeholderTextColor={colors.muted}
|
ref={ref}
|
||||||
style={[styles.input, focused && styles.inputFocused, style]}
|
placeholderTextColor={colors.muted}
|
||||||
onFocus={(e) => (setFocused(true), onFocus?.(e))}
|
style={[styles.input, focused && styles.inputFocused, secureTextEntry && { paddingRight: 64 }, style]}
|
||||||
onBlur={(e) => (setFocused(false), onBlur?.(e))}
|
onFocus={(e) => (setFocused(true), onFocus?.(e))}
|
||||||
{...props}
|
onBlur={(e) => (setFocused(false), onBlur?.(e))}
|
||||||
/>
|
secureTextEntry={secureTextEntry && !revealed}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{secureTextEntry && (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={revealed ? 'Hide password' : 'Show password'}
|
||||||
|
onPress={() => setRevealed((v) => !v)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.reveal}>
|
||||||
|
<Text style={styles.revealText}>{revealed ? 'Hide' : 'Show'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -175,6 +190,8 @@ const styles = StyleSheet.create({
|
|||||||
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||||
buttonTextSmall: { fontSize: 13 },
|
buttonTextSmall: { fontSize: 13 },
|
||||||
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
||||||
|
reveal: { position: 'absolute', right: 0, top: 0, bottom: 0, justifyContent: 'center', paddingHorizontal: space.md },
|
||||||
|
revealText: { color: colors.accent2, fontWeight: '600', fontSize: 14 },
|
||||||
input: {
|
input: {
|
||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
paddingHorizontal: space.md + 2,
|
paddingHorizontal: space.md + 2,
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.report { padding: 0.85rem; border-radius: 0.8rem; background: rgba(255, 255, 255, 0.04); display: flex; flex-direction: column; gap: 0.5rem; }
|
.report { padding: 0.85rem; border-radius: 0.8rem; background: rgba(255, 255, 255, 0.04); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
.report p { margin: 0; }
|
.report p { margin: 0; }
|
||||||
.chip-small.danger { background: rgba(251, 113, 133, 0.18); color: var(--danger); }
|
.chip-small.danger { background: rgba(251, 113, 133, 0.18); color: var(--danger); }
|
||||||
|
.banner { max-width: 680px; margin: 0 auto 0.5rem; padding: 0.6rem 1rem; border-radius: 0.8rem; background: rgba(123, 47, 247, 0.16); border: 1px solid rgba(123, 47, 247, 0.35); font-size: 0.9rem; }
|
||||||
|
.banner .link-button { font-size: inherit; color: var(--text); text-decoration: underline; }
|
||||||
.avatar-picker { display: flex; gap: 1rem; align-items: center; }
|
.avatar-picker { display: flex; gap: 1rem; align-items: center; }
|
||||||
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
|
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
.grow { flex: 1; min-width: 0; }
|
.grow { flex: 1; min-width: 0; }
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ function Settings() {
|
|||||||
<div className="stack">
|
<div className="stack">
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
<ProfileForm me={me} />
|
<ProfileForm me={me} />
|
||||||
|
<AccountSettings me={me} />
|
||||||
<SharingControls />
|
<SharingControls />
|
||||||
<Connections />
|
<Connections />
|
||||||
<FeaturedPicker />
|
<FeaturedPicker />
|
||||||
@@ -148,6 +149,129 @@ function AvatarPicker({ me }: { me: MeDTO }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AccountSettings({ me }: { me: MeDTO }) {
|
||||||
|
const { mutate } = useMe();
|
||||||
|
const [editing, setEditing] = useState<'email' | 'password' | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<unknown>, done: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
await mutate();
|
||||||
|
setEditing(null);
|
||||||
|
setMessage(done);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const field = (f: FormData, k: string) => String(f.get(k) ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card form">
|
||||||
|
<h2>Account</h2>
|
||||||
|
<div>
|
||||||
|
<div className="muted">Email</div>
|
||||||
|
<div>
|
||||||
|
{me.email}{' '}
|
||||||
|
{me.emailVerified ? <span className="chip-small">Confirmed</span> : <span className="chip-small danger">Not confirmed</span>}
|
||||||
|
</div>
|
||||||
|
{me.pendingEmail && (
|
||||||
|
<p className="muted">
|
||||||
|
Changing to <strong>{me.pendingEmail}</strong>: open the link we sent there.{' '}
|
||||||
|
<button type="button" className="link-button" onClick={() => run(() => client.cancelEmailChange(), 'Email change cancelled.')}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!me.emailVerified && !me.pendingEmail && (
|
||||||
|
<button type="button" className="link-button" onClick={() => run(() => client.resendVerification(), `Sent a new link to ${me.email}.`)}>
|
||||||
|
Send the confirmation link again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing === 'email' ? (
|
||||||
|
<form
|
||||||
|
className="form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = new FormData(e.currentTarget);
|
||||||
|
const email = field(f, 'email');
|
||||||
|
run(() => client.changeEmail({ email, password: field(f, 'password') }), `Check ${email} for a confirmation link.`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
New email
|
||||||
|
<input name="email" type="email" required autoComplete="email" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Your password
|
||||||
|
<input name="password" type={show ? 'text' : 'password'} required autoComplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<ShowPassword show={show} setShow={setShow} />
|
||||||
|
<FormButtons label="Send confirmation link" onCancel={() => setEditing(null)} />
|
||||||
|
</form>
|
||||||
|
) : editing === 'password' ? (
|
||||||
|
<form
|
||||||
|
className="form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = new FormData(e.currentTarget);
|
||||||
|
run(
|
||||||
|
() => client.changePassword({ currentPassword: field(f, 'current'), newPassword: field(f, 'new') }),
|
||||||
|
'Password changed. Your other devices were signed out.',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Current password
|
||||||
|
<input name="current" type={show ? 'text' : 'password'} required autoComplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
New password
|
||||||
|
<input name="new" type={show ? 'text' : 'password'} required minLength={8} maxLength={200} autoComplete="new-password" />
|
||||||
|
</label>
|
||||||
|
<ShowPassword show={show} setShow={setShow} />
|
||||||
|
<FormButtons label="Change password" onCancel={() => setEditing(null)} />
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="row">
|
||||||
|
<button type="button" className="btn" onClick={() => (setMessage(null), setEditing('email'))}>
|
||||||
|
Change email
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={() => (setMessage(null), setEditing('password'))}>
|
||||||
|
Change password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message && <p className="muted">{message}</p>}
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShowPassword({ show, setShow }: { show: boolean; setShow: (v: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={show} onChange={(e) => setShow(e.target.checked)} /> Show passwords
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormButtons({ label, onCancel }: { label: string; onCancel: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<button className="btn btn-primary">{label}</button>
|
||||||
|
<button type="button" className="btn" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BlockedAccounts() {
|
function BlockedAccounts() {
|
||||||
const { data: blocked = [], mutate } = useSWR<UserSummaryDTO[]>('/me/blocks', fetcher);
|
const { data: blocked = [], mutate } = useSWR<UserSummaryDTO[]>('/me/blocks', fetcher);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
import { client, useMe } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<VerifyEmail />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerifyEmail() {
|
||||||
|
const token = useSearchParams().get('token');
|
||||||
|
const { me, mutate } = useMe();
|
||||||
|
const [state, setState] = useState<{ ok: true; email: string } | { ok: false; error: string } | null>(null);
|
||||||
|
const started = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || started.current) return; // links work once; don't send twice in dev's double effects
|
||||||
|
started.current = true;
|
||||||
|
client
|
||||||
|
.verifyEmail(token)
|
||||||
|
.then((res) => {
|
||||||
|
setState(res);
|
||||||
|
void mutate();
|
||||||
|
})
|
||||||
|
.catch((err: Error) => setState({ ok: false, error: err.message }));
|
||||||
|
}, [token, mutate]);
|
||||||
|
|
||||||
|
if (!token) return <Result title="Link incomplete" body="Open the link straight from the email." />;
|
||||||
|
if (!state) return <Result title="Confirming…" body="One moment." />;
|
||||||
|
if (!state.ok) {
|
||||||
|
return (
|
||||||
|
<Result title="Couldn't confirm" body={state.error}>
|
||||||
|
{me && <p className="muted">You can send a new link from the banner at the top, or from Settings.</p>}
|
||||||
|
</Result>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Result title="Email confirmed ✓" body={`${state.email} is confirmed for your musicshare account.`}>
|
||||||
|
<Link href={me ? '/' : '/login'} className="btn btn-primary">
|
||||||
|
{me ? 'Go to your feed' : 'Log in'}
|
||||||
|
</Link>
|
||||||
|
</Result>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Result({ title, body, children }: { title: string; body: string; children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="card form narrow">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{body}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
|
|||||||
const { mutate } = useMe();
|
const { mutate } = useMe();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -43,12 +44,15 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
|
|||||||
Password
|
Password
|
||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
required
|
required
|
||||||
minLength={mode === 'register' ? 8 : 1}
|
minLength={mode === 'register' ? 8 : 1}
|
||||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={showPassword} onChange={(e) => setShowPassword(e.target.checked)} /> Show password
|
||||||
|
</label>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<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'}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { api, useMe } from '@/lib/api';
|
import { useState } from 'react';
|
||||||
|
import { api, client, useMe } from '@/lib/api';
|
||||||
import { SupportLink } from './SupportLink';
|
import { SupportLink } from './SupportLink';
|
||||||
|
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
@@ -14,6 +15,7 @@ export function Nav() {
|
|||||||
router.push('/');
|
router.push('/');
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
<Link href="/" className="brand">
|
<Link href="/" className="brand">
|
||||||
<img className="brand-logo" src="/logo.png" alt="" width={30} height={30} />
|
<img className="brand-logo" src="/logo.png" alt="" width={30} height={30} />
|
||||||
@@ -41,5 +43,26 @@ export function Nav() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
{me && !me.emailVerified && <VerifyBanner email={me.pendingEmail ?? me.email} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Until they confirm, a forgotten password can't be reset; nudge them. */
|
||||||
|
function VerifyBanner({ email }: { email: string }) {
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const resend = async () => {
|
||||||
|
try {
|
||||||
|
await client.resendVerification();
|
||||||
|
setSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
alert((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="banner">
|
||||||
|
Confirm your email: we sent a link to <strong>{email}</strong>.{' '}
|
||||||
|
{sent ? <span className="muted">Sent again.</span> : <button className="link-button" onClick={resend}>Send it again</button>}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ import type {
|
|||||||
UserSummaryDTO,
|
UserSummaryDTO,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import type { DeviceDTO } from './devices';
|
import type { DeviceDTO } from './devices';
|
||||||
import type { LoginInput, RegisterInput, ReportReason, ResetPasswordInput, SetFeaturedInput, UpdateMeInput } from './schemas';
|
import type {
|
||||||
|
ChangeEmailInput,
|
||||||
|
ChangePasswordInput,
|
||||||
|
LoginInput,
|
||||||
|
RegisterInput,
|
||||||
|
ReportReason,
|
||||||
|
ResetPasswordInput,
|
||||||
|
SetFeaturedInput,
|
||||||
|
UpdateMeInput,
|
||||||
|
} from './schemas';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -72,6 +81,13 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
|
|||||||
/** Always succeeds, so it doesn't reveal which emails have accounts. */
|
/** Always succeeds, so it doesn't reveal which emails have accounts. */
|
||||||
forgotPassword: (email: string) => request<{ ok: true }>('/auth/forgot-password', { body: { email } }),
|
forgotPassword: (email: string) => request<{ ok: true }>('/auth/forgot-password', { body: { email } }),
|
||||||
resetPassword: (input: ResetPasswordInput) => request<AuthResponse>('/auth/reset-password', { body: input }),
|
resetPassword: (input: ResetPasswordInput) => request<AuthResponse>('/auth/reset-password', { body: input }),
|
||||||
|
verifyEmail: (token: string) => request<{ ok: true; email: string }>('/auth/verify-email', { body: { token } }),
|
||||||
|
resendVerification: () => request<{ ok: true }>('/me/verify-email/resend', { body: {} }),
|
||||||
|
/** Signs out your other devices. */
|
||||||
|
changePassword: (input: ChangePasswordInput) => request<{ ok: true }>('/me/password', { body: input }),
|
||||||
|
/** Sends a confirmation link to the new address; the email changes once it's clicked. */
|
||||||
|
changeEmail: (input: ChangeEmailInput) => request<MeDTO>('/me/email', { body: input }),
|
||||||
|
cancelEmailChange: () => request<MeDTO>('/me/email/pending', { method: 'DELETE' }),
|
||||||
logout: () => request('/auth/logout', { method: 'POST', body: {} }),
|
logout: () => request('/auth/logout', { method: 'POST', body: {} }),
|
||||||
|
|
||||||
me: () => request<MeDTO>('/me'),
|
me: () => request<MeDTO>('/me'),
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export interface UserSummaryDTO {
|
|||||||
|
|
||||||
export interface MeDTO extends UserSummaryDTO {
|
export interface MeDTO extends UserSummaryDTO {
|
||||||
email: string;
|
email: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
/** A new address waiting to be confirmed (the link was sent there). */
|
||||||
|
pendingEmail: string | null;
|
||||||
bio: string | null;
|
bio: string | null;
|
||||||
preferredService: StreamingService | null;
|
preferredService: StreamingService | null;
|
||||||
role: 'user' | 'admin';
|
role: 'user' | 'admin';
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ export const registerSchema = z.object({
|
|||||||
|
|
||||||
export const forgotPasswordSchema = z.object({ email: emailSchema });
|
export const forgotPasswordSchema = z.object({ email: emailSchema });
|
||||||
|
|
||||||
|
export const verifyEmailSchema = z.object({ token: z.string().min(20).max(200) });
|
||||||
|
|
||||||
|
export const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1).max(200),
|
||||||
|
newPassword: newPasswordSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const changeEmailSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: z.string().min(1).max(200),
|
||||||
|
});
|
||||||
|
|
||||||
export const resetPasswordSchema = z.object({
|
export const resetPasswordSchema = z.object({
|
||||||
token: z.string().min(20).max(200),
|
token: z.string().min(20).max(200),
|
||||||
password: newPasswordSchema,
|
password: newPasswordSchema,
|
||||||
@@ -50,6 +62,8 @@ 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 ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||||
|
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
|
||||||
|
export type ChangeEmailInput = z.infer<typeof changeEmailSchema>;
|
||||||
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>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user