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;