From 1f0cdb68f50091cbf4bd6595a1e2a7d3f32f1841 Mon Sep 17 00:00:00 2001 From: chris Date: Tue, 6 Oct 2026 01:05:30 -0400 Subject: [PATCH] Password reset by email "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 Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8 --- apps/api/drizzle/0011_password_resets.sql | 10 + apps/api/drizzle/meta/0011_snapshot.json | 1325 +++++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/package.json | 2 + apps/api/src/db/schema.ts | 15 + apps/api/src/env.ts | 7 + apps/api/src/lib/mail.ts | 44 + apps/api/src/routes/auth.ts | 66 +- apps/api/test/helpers.ts | 2 +- apps/api/test/password-reset.test.ts | 84 ++ apps/api/vitest.config.ts | 2 + apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/forgot-password.tsx | 62 + apps/mobile/src/components/AuthForm.tsx | 5 + apps/web/src/app/forgot-password/page.tsx | 59 + apps/web/src/app/globals.css | 1 + apps/web/src/app/reset-password/page.tsx | 81 ++ apps/web/src/components/AuthForm.tsx | 5 + package-lock.json | 21 + packages/shared/src/client.ts | 5 +- packages/shared/src/schemas.ts | 12 +- 21 files changed, 1811 insertions(+), 5 deletions(-) create mode 100644 apps/api/drizzle/0011_password_resets.sql create mode 100644 apps/api/drizzle/meta/0011_snapshot.json create mode 100644 apps/api/src/lib/mail.ts create mode 100644 apps/api/test/password-reset.test.ts create mode 100644 apps/mobile/src/app/forgot-password.tsx create mode 100644 apps/web/src/app/forgot-password/page.tsx create mode 100644 apps/web/src/app/reset-password/page.tsx diff --git a/apps/api/drizzle/0011_password_resets.sql b/apps/api/drizzle/0011_password_resets.sql new file mode 100644 index 0000000..cde1f9d --- /dev/null +++ b/apps/api/drizzle/0011_password_resets.sql @@ -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"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0011_snapshot.json b/apps/api/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..4eeb4cf --- /dev/null +++ b/apps/api/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1325 @@ +{ + "id": "8d51c3c4-bcdc-418d-8dff-14f6053ee595", + "prevId": "89f8acc3-35bc-4587-8fb0-4e98abbb730b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.avatars": { + "name": "avatars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "avatars_user_id_users_id_fk": { + "name": "avatars_user_id_users_id_fk", + "tableFrom": "avatars", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_id": { + "name": "blocked_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocks_blocked_idx": { + "name": "blocks_blocked_idx", + "columns": [ + { + "expression": "blocked_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocks_blocker_id_users_id_fk": { + "name": "blocks_blocker_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocks_blocked_id_users_id_fk": { + "name": "blocks_blocked_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocked_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocks_blocker_id_blocked_id_pk": { + "name": "blocks_blocker_id_blocked_id_pk", + "columns": [ + "blocker_id", + "blocked_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connections_user_provider_idx": { + "name": "connections_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connections_user_id_users_id_fk": { + "name": "connections_user_id_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "connections_webhook_secret_unique": { + "name": "connections_webhook_secret_unique", + "nullsNotDistinct": false, + "columns": [ + "webhook_secret" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_idx": { + "name": "devices_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_token_hash_unique": { + "name": "devices_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.featured_songs": { + "name": "featured_songs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "varchar(280)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "featured_songs_user_id_users_id_fk": { + "name": "featured_songs_user_id_users_id_fk", + "tableFrom": "featured_songs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "featured_songs_track_id_tracks_id_fk": { + "name": "featured_songs_track_id_tracks_id_fk", + "tableFrom": "featured_songs", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "followee_id": { + "name": "followee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "follows_followee_idx": { + "name": "follows_followee_idx", + "columns": [ + { + "expression": "followee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_follower_id_users_id_fk": { + "name": "follows_follower_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_followee_id_users_id_fk": { + "name": "follows_followee_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "followee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_follower_id_followee_id_pk": { + "name": "follows_follower_id_followee_id_pk", + "columns": [ + "follower_id", + "followee_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listens": { + "name": "listens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listens_user_started_idx": { + "name": "listens_user_started_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listens_user_id_users_id_fk": { + "name": "listens_user_id_users_id_fk", + "tableFrom": "listens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listens_track_id_tracks_id_fk": { + "name": "listens_track_id_tracks_id_fk", + "tableFrom": "listens", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.now_playing": { + "name": "now_playing", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_playing": { + "name": "is_playing", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "now_playing_track_idx": { + "name": "now_playing_track_idx", + "columns": [ + { + "expression": "track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "now_playing_user_id_users_id_fk": { + "name": "now_playing_user_id_users_id_fk", + "tableFrom": "now_playing", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "now_playing_track_id_tracks_id_fk": { + "name": "now_playing_track_id_tracks_id_fk", + "tableFrom": "now_playing", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_resets": { + "name": "password_resets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_resets_user_idx": { + "name": "password_resets_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_resets_user_id_users_id_fk": { + "name": "password_resets_user_id_users_id_fk", + "tableFrom": "password_resets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reported_id": { + "name": "reported_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_reporter_id_users_id_fk": { + "name": "reports_reporter_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reports_reported_id_users_id_fk": { + "name": "reports_reported_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reported_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.track_links": { + "name": "track_links", + "schema": "", + "columns": { + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "track_links_external_idx": { + "name": "track_links_external_idx", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "track_links_track_id_tracks_id_fk": { + "name": "track_links_track_id_tracks_id_fk", + "tableFrom": "track_links", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "track_links_track_id_service_pk": { + "name": "track_links_track_id_service_pk", + "columns": [ + "track_id", + "service" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracks": { + "name": "tracks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "isrc": { + "name": "isrc", + "type": "varchar(12)", + "primaryKey": false, + "notNull": false + }, + "match_key": { + "name": "match_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist": { + "name": "artist", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "album": { + "name": "album", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "artwork_url": { + "name": "artwork_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "links_resolved_at": { + "name": "links_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tracks_isrc_idx": { + "name": "tracks_isrc_idx", + "columns": [ + { + "expression": "isrc", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_match_key_idx": { + "name": "tracks_match_key_idx", + "columns": [ + { + "expression": "match_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "varchar(280)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "preferred_service": { + "name": "preferred_service", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sharing_paused_until": { + "name": "sharing_paused_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "plan_expires_at": { + "name": "plan_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index a9ca649..0276791 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1791257078479, "tag": "0010_avatars", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1791262411052, + "tag": "0011_password_resets", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index de5f76e..e6eb503 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -31,6 +31,7 @@ "drizzle-orm": "^0.45.3", "fastify": "^5.12.5", "ioredis": "^6.0.0", + "nodemailer": "^10.0.15", "postgres": "^3.4.9", "sharp": "^0.35.5", "undici": "^6.29.0", @@ -38,6 +39,7 @@ }, "devDependencies": { "@types/node": "^26.6.4", + "@types/nodemailer": "^8.0.2", "@types/ws": "^8.18.2", "concurrently": "^10.0.5", "drizzle-kit": "^0.31.11", diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index ada9922..6936256 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -238,6 +238,21 @@ export const featuredSongs = pgTable('featured_songs', { .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' }); /** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */ diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index ce0f413..fdc59ba 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -37,6 +37,12 @@ const schema = z.object({ * dev or self-hosting: in a hosted deployment it lets users make the server * 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 '), ALLOW_PRIVATE_SERVER_URLS: z .string() .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 appleMusicConfigured = Boolean(env.APPLE_TEAM_ID && env.APPLE_MUSICKIT_KEY_ID && env.APPLE_MUSICKIT_PRIVATE_KEY); +export const mailConfigured = Boolean(env.SMTP_HOST); diff --git a/apps/api/src/lib/mail.ts b/apps/api/src/lib/mail.ts new file mode 100644 index 0000000..ff0381e --- /dev/null +++ b/apps/api/src/lib/mail.ts @@ -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 { + 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 = ` +
+
musicshare
+${lines.map((l) => `

${escape(l)}

`).join('\n')} +

${escape(button.label)}

+

${escape(footer)}

+
`; + return { text, html }; +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 1e4917d..4c58092 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,13 +1,17 @@ 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 { loginSchema, registerSchema } from '@musicshare/shared'; +import { forgotPasswordSchema, loginSchema, registerSchema, resetPasswordSchema } from '@musicshare/shared'; import { db, schema } from '../db'; +import { env } from '../env'; import { SESSION_COOKIE, createSession } from '../lib/auth'; +import { randomToken, sha256 } from '../lib/crypto'; import { HttpError } from '../lib/http'; +import { sendMail, simpleEmail } from '../lib/mail'; import { toMeDTO } from './me'; const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }; +const RESET_MINUTES = 60; export async function authRoutes(app: FastifyInstance) { app.post('/auth/register', authRateLimit, async (req, reply) => { @@ -47,6 +51,64 @@ export async function authRoutes(app: FastifyInstance) { 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) => { if (req.sessionId) await db.delete(schema.sessions).where(eq(schema.sessions.id, req.sessionId)); reply.clearCookie(SESSION_COOKIE, { path: '/' }); diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts index a998e4d..16e9ee4 100644 --- a/apps/api/test/helpers.ts +++ b/apps/api/test/helpers.ts @@ -17,7 +17,7 @@ function assertTestTargets() { export async function resetState() { 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(); } diff --git a/apps/api/test/password-reset.test.ts b/apps/api/test/password-reset.test.ts new file mode 100644 index 0000000..9e43c0e --- /dev/null +++ b/apps/api/test/password-reset.test.ts @@ -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/); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 6634064..3f9298c 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -22,6 +22,8 @@ const testEnv = { SPOTIFY_CLIENT_ID: 'test-spotify-id', SPOTIFY_CLIENT_SECRET: 'test-spotify-secret', ODESLI_API_KEY: '', + // Never send real email from tests (apps/api/.env may hold real SMTP settings). + SMTP_HOST: '', APPLE_TEAM_ID: 'TEAMID1234', APPLE_MUSICKIT_KEY_ID: 'KEYID12345', APPLE_MUSICKIT_PRIVATE_KEY: appleKey.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString().replace(/\n/g, '\\n'), diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 8fc2575..b453518 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -97,6 +97,7 @@ function AppNavigator() { + diff --git a/apps/mobile/src/app/forgot-password.tsx b/apps/mobile/src/app/forgot-password.tsx new file mode 100644 index 0000000..60253ad --- /dev/null +++ b/apps/mobile/src/app/forgot-password.tsx @@ -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(null); + const [error, setError] = useState(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 ( + + Check your email + + If {sentTo} has a musicshare account, we've sent it a link to choose a new password. It expires in an hour. + + Open the link, set your new password, then log in here with it. Nothing there? Check your spam folder. + +

+ Remembered it? Log in +

+ + ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 96a7477..852c9eb 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -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); } diff --git a/apps/web/src/app/reset-password/page.tsx b/apps/web/src/app/reset-password/page.tsx new file mode 100644 index 0000000..bc520a2 --- /dev/null +++ b/apps/web/src/app/reset-password/page.tsx @@ -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 ( + + + + ); +} + +function ResetPassword() { + const token = useSearchParams().get('token'); + const router = useRouter(); + const { mutate } = useMe(); + const [show, setShow] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!token) { + return ( +
+

Link incomplete

+

This reset link is missing part of its address. Open it straight from the email, or ask for a new one.

+ + Send a new link + +
+ ); + } + + const onSubmit = async (e: React.FormEvent) => { + 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 ( +
+

Choose a new password

+ + + {error && ( +

+ {error} {error.includes('expired') && Send a new link} +

+ )} + +

This signs you out on your other devices.

+
+ ); +} diff --git a/apps/web/src/components/AuthForm.tsx b/apps/web/src/components/AuthForm.tsx index bb16da3..f451599 100644 --- a/apps/web/src/components/AuthForm.tsx +++ b/apps/web/src/components/AuthForm.tsx @@ -53,6 +53,11 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) { + {mode === 'login' && ( +

+ Forgot your password? +

+ )}

{mode === 'login' ? ( <> diff --git a/package-lock.json b/package-lock.json index 5ec1514..d8a9913 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "drizzle-orm": "^0.45.3", "fastify": "^5.12.5", "ioredis": "^6.0.0", + "nodemailer": "^10.0.15", "postgres": "^3.4.9", "sharp": "^0.35.5", "undici": "^6.29.0", @@ -40,6 +41,7 @@ }, "devDependencies": { "@types/node": "^26.6.4", + "@types/nodemailer": "^8.0.2", "@types/ws": "^8.18.2", "concurrently": "^10.0.5", "drizzle-kit": "^0.31.11", @@ -7494,6 +7496,16 @@ "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": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -15362,6 +15374,15 @@ "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": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts index ebc08bb..91af83c 100644 --- a/packages/shared/src/client.ts +++ b/packages/shared/src/client.ts @@ -7,7 +7,7 @@ import type { UserSummaryDTO, } from './dto'; 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 { constructor( @@ -66,6 +66,9 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt register: (input: RegisterInput) => request('/auth/register', { body: input }), login: (input: LoginInput) => request('/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('/auth/reset-password', { body: input }), logout: () => request('/auth/logout', { method: 'POST', body: {} }), me: () => request('/me'), diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index bf8b29c..98118f7 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -10,10 +10,19 @@ export const usernameSchema = z // Trim before validating: iOS keyboards often append a space after autocompleting an 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({ username: usernameSchema, 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({ @@ -40,6 +49,7 @@ export const connectUsernameSchema = z.object({ export type RegisterInput = z.infer; export type LoginInput = z.infer; +export type ResetPasswordInput = z.infer; export type UpdateMeInput = z.infer; export type SetFeaturedInput = z.infer;