diff --git a/apps/api/drizzle/0012_email_verification.sql b/apps/api/drizzle/0012_email_verification.sql new file mode 100644 index 0000000..8a321d1 --- /dev/null +++ b/apps/api/drizzle/0012_email_verification.sql @@ -0,0 +1,12 @@ +CREATE TABLE "email_verifications" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" uuid NOT NULL, + "email" varchar(320) NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "email_verified_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "pending_email" varchar(320);--> statement-breakpoint +ALTER TABLE "email_verifications" ADD CONSTRAINT "email_verifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "email_verifications_user_idx" ON "email_verifications" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0012_snapshot.json b/apps/api/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..8b5c992 --- /dev/null +++ b/apps/api/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1411 @@ +{ + "id": "1aa02418-10af-488a-91bf-e3a79f263e9c", + "prevId": "8d51c3c4-bcdc-418d-8dff-14f6053ee595", + "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.email_verifications": { + "name": "email_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "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": { + "email_verifications_user_idx": { + "name": "email_verifications_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_verifications_user_id_users_id_fk": { + "name": "email_verifications_user_id_users_id_fk", + "tableFrom": "email_verifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pending_email": { + "name": "pending_email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false + }, + "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 0276791..94c40b1 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1791262411052, "tag": "0011_password_resets", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1791305642128, + "tag": "0012_email_verification", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6314f18..5d33f17 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -11,6 +11,7 @@ import { loadUser } from './lib/auth'; import { HttpError } from './lib/http'; import { redis } from './lib/redis'; import { adminRoutes } from './routes/admin'; +import { accountRoutes } from './routes/account'; import { authRoutes } from './routes/auth'; import { avatarRoutes } from './routes/avatars'; import { connectionRoutes } from './routes/connections'; @@ -66,6 +67,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?: }); await app.register(authRoutes); + await app.register(accountRoutes); await app.register(meRoutes); await app.register(avatarRoutes); await app.register(userRoutes); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 6936256..48e1669 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -22,6 +22,10 @@ export const users = pgTable('users', { username: varchar('username', { length: 32 }).notNull().unique(), email: varchar('email', { length: 320 }).notNull().unique(), passwordHash: text('password_hash').notNull(), + /** Set once they click the link we email them; null until then. */ + emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }), + /** A new address waiting for its confirmation link; `email` changes only once it's clicked. */ + pendingEmail: varchar('pending_email', { length: 320 }), displayName: varchar('display_name', { length: 64 }), bio: varchar('bio', { length: 280 }), /** Path of the uploaded picture (`/avatars/?v=`), resolved against API_PUBLIC_URL when sent. */ @@ -238,6 +242,21 @@ export const featuredSongs = pgTable('featured_songs', { .default(sql`now()`), }); +/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */ +export const emailVerifications = pgTable( + 'email_verifications', + { + id: text('id').primaryKey(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + email: varchar('email', { length: 320 }).notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: createdAt(), + }, + (t) => [index('email_verifications_user_idx').on(t.userId)], +); + /** One-time password reset links. Only a hash of the token is stored. */ export const passwordResets = pgTable( 'password_resets', diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts new file mode 100644 index 0000000..a21e4d2 --- /dev/null +++ b/apps/api/src/routes/account.ts @@ -0,0 +1,78 @@ +import { hash, verify } from '@node-rs/argon2'; +import { and, eq, gt, ne } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { changeEmailSchema, changePasswordSchema, verifyEmailSchema } from '@musicshare/shared'; +import { db, schema } from '../db'; +import { requireUser } from '../lib/auth'; +import { sha256 } from '../lib/crypto'; +import { HttpError } from '../lib/http'; +import { notifyEmailChange, sendVerificationEmail } from '../services/emailVerification'; +import { toMeDTO } from './me'; + +const sensitive = { config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } }; + +export async function accountRoutes(app: FastifyInstance) { + /** The link in the confirmation email. Works signed in or not (it's often opened on another device). */ + app.post('/auth/verify-email', sensitive, async (req) => { + const { token } = verifyEmailSchema.parse(req.body); + return db.transaction(async (tx) => { + const [row] = await tx + .delete(schema.emailVerifications) + .where(and(eq(schema.emailVerifications.id, sha256(token)), gt(schema.emailVerifications.expiresAt, new Date()))) + .returning(); + if (!row) throw new HttpError(400, 'This confirmation link has expired or was already used.'); + const [taken] = await tx + .select({ id: schema.users.id }) + .from(schema.users) + .where(and(eq(schema.users.email, row.email), ne(schema.users.id, row.userId))); + if (taken) throw new HttpError(409, 'That email now belongs to another account.'); + await tx + .update(schema.users) + .set({ email: row.email, emailVerifiedAt: new Date(), pendingEmail: null }) + .where(eq(schema.users.id, row.userId)); + return { ok: true as const, email: row.email }; + }); + }); + + app.post('/me/verify-email/resend', { config: { rateLimit: { max: 3, timeWindow: '1 hour' } } }, async (req) => { + const user = requireUser(req); + const email = user.pendingEmail ?? (user.emailVerifiedAt ? null : user.email); + if (!email) throw new HttpError(400, 'Your email is already confirmed.'); + await sendVerificationEmail(user, email, req.log); + return { ok: true as const }; + }); + + app.post('/me/password', sensitive, async (req) => { + const user = requireUser(req); + const input = changePasswordSchema.parse(req.body); + if (!(await verify(user.passwordHash, input.currentPassword))) throw new HttpError(403, 'Your current password is wrong.'); + await db.update(schema.users).set({ passwordHash: await hash(input.newPassword) }).where(eq(schema.users.id, user.id)); + // Keep this session; sign out everywhere else. + await db + .delete(schema.sessions) + .where(and(eq(schema.sessions.userId, user.id), ne(schema.sessions.id, req.sessionId!))); + return { ok: true as const }; + }); + + app.post('/me/email', sensitive, async (req) => { + const user = requireUser(req); + const input = changeEmailSchema.parse(req.body); + if (!(await verify(user.passwordHash, input.password))) throw new HttpError(403, 'Your password is wrong.'); + if (input.email === user.email) throw new HttpError(400, "That's already your email."); + const [taken] = await db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.email, input.email)); + if (taken) throw new HttpError(409, 'That email is already registered.'); + const [updated] = await db.update(schema.users).set({ pendingEmail: input.email }).where(eq(schema.users.id, user.id)).returning(); + await sendVerificationEmail(updated!, input.email, req.log); + if (user.emailVerifiedAt) notifyEmailChange(user, input.email, req.log); + return toMeDTO(updated!); + }); + + app.delete('/me/email/pending', async (req) => { + const user = requireUser(req); + await db.delete(schema.emailVerifications).where(eq(schema.emailVerifications.userId, user.id)); + const [updated] = await db.update(schema.users).set({ pendingEmail: null }).where(eq(schema.users.id, user.id)).returning(); + // Unconfirmed sign-ups keep a way to confirm their current address. + if (!updated!.emailVerifiedAt) await sendVerificationEmail(updated!, updated!.email, req.log); + return toMeDTO(updated!); + }); +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 4c58092..a30b4ef 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -8,6 +8,7 @@ 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 { sendVerificationEmail } from '../services/emailVerification'; import { toMeDTO } from './me'; const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }; @@ -36,6 +37,7 @@ export async function authRoutes(app: FastifyInstance) { .returning(); return created; }); + await sendVerificationEmail(user!, user!.email, req.log); const token = await createSession(user!.id, reply); return { user: toMeDTO(user!), token }; }); diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index b09aa5d..a5ec2c7 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -20,6 +20,8 @@ export const toMeDTO = (u: User): MeDTO => ({ displayName: u.displayName, avatarUrl: publicAvatarUrl(u.avatarUrl), email: u.email, + emailVerified: Boolean(u.emailVerifiedAt), + pendingEmail: u.pendingEmail, bio: u.bio, preferredService: u.preferredService, role: u.role, diff --git a/apps/api/src/services/emailVerification.ts b/apps/api/src/services/emailVerification.ts new file mode 100644 index 0000000..52dccda --- /dev/null +++ b/apps/api/src/services/emailVerification.ts @@ -0,0 +1,52 @@ +import { eq } from 'drizzle-orm'; +import type { FastifyBaseLogger } from 'fastify'; +import { db, schema } from '../db'; +import type { User } from '../db/schema'; +import { env } from '../env'; +import { randomToken, sha256 } from '../lib/crypto'; +import { sendMail, simpleEmail } from '../lib/mail'; + +const VERIFY_HOURS = 24; + +/** + * Emails a confirmation link for `email` (their current address after sign-up, + * or a new one they're switching to). Replaces any earlier link. + */ +export async function sendVerificationEmail(user: User, email: string, log: FastifyBaseLogger) { + await db.delete(schema.emailVerifications).where(eq(schema.emailVerifications.userId, user.id)); + const token = randomToken(); + await db.insert(schema.emailVerifications).values({ + id: sha256(token), + userId: user.id, + email, + expiresAt: new Date(Date.now() + VERIFY_HOURS * 3600_000), + }); + const changing = email !== user.email; + const url = `${env.WEB_URL.replace(/\/$/, '')}/verify-email?token=${encodeURIComponent(token)}`; + const mail = simpleEmail({ + lines: changing + ? [`Hi ${user.displayName ?? user.username},`, `Confirm ${email} as the new email for your musicshare account.`] + : [`Welcome to musicshare, ${user.displayName ?? user.username}!`, 'Confirm your email so you can reset your password if you ever forget it.'], + button: { label: changing ? 'Confirm new email' : 'Confirm my email', url }, + footer: `This link expires in ${VERIFY_HOURS} hours. If you didn't ask for this, you can ignore it.`, + }); + sendMail({ to: email, subject: changing ? 'Confirm your new musicshare email' : 'Confirm your musicshare email', ...mail }).catch( + (err) => log.error({ err }, 'verification email failed'), + ); +} + +/** Tells the old address an email change was requested, so a hijacker can't do it quietly. */ +export function notifyEmailChange(user: User, newEmail: string, log: FastifyBaseLogger) { + const mail = simpleEmail({ + lines: [ + `Hi ${user.displayName ?? user.username},`, + `Someone asked to change your musicshare email to ${newEmail}. It only changes once that address is confirmed.`, + "If this wasn't you, reset your password now.", + ], + button: { label: 'Reset my password', url: `${env.WEB_URL.replace(/\/$/, '')}/forgot-password` }, + footer: 'You can ignore this email if you made the change.', + }); + sendMail({ to: user.email, subject: 'Your musicshare email is being changed', ...mail }).catch((err) => + log.error({ err }, 'email change notice failed'), + ); +} diff --git a/apps/api/test/account.test.ts b/apps/api/test/account.test.ts new file mode 100644 index 0000000..9297309 --- /dev/null +++ b/apps/api/test/account.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { outbox } from '../src/lib/mail'; +import { bearer, createUser, useTestApp } from './helpers'; + +const app = useTestApp(); +beforeEach(() => { + outbox.length = 0; +}); + +async function mailTo(to: string, subject: RegExp) { + const find = () => outbox.filter((m) => m.to === to && subject.test(m.subject)).at(-1); + for (let i = 0; i < 50 && !find(); i++) await new Promise((r) => setTimeout(r, 10)); + return find(); +} +const tokenFrom = (text: string) => new URL(text.match(/http\S+verify-email\S+/)![0]).searchParams.get('token')!; +const verify = (token: string) => app().inject({ method: 'POST', url: '/auth/verify-email', payload: { token } }); +const me = async (token: string) => (await app().inject({ url: '/me', headers: bearer(token) })).json(); + +describe('email verification', () => { + it('sends a confirmation link at sign-up that confirms the address', async () => { + const { token, user } = await createUser(app(), 'alice'); + expect(user.emailVerified).toBe(false); + const mail = await mailTo('alice@example.com', /Confirm your musicshare email/); + expect(mail!.text).toContain('http://web.test/verify-email?token='); + + const res = await verify(tokenFrom(mail!.text)); + expect(res.json()).toEqual({ ok: true, email: 'alice@example.com' }); + expect((await me(token)).emailVerified).toBe(true); + expect((await verify(tokenFrom(mail!.text))).statusCode).toBe(400); // one use + }); + + it('resends, and refuses once confirmed', async () => { + const { token } = await createUser(app(), 'alice'); + const resend = () => app().inject({ method: 'POST', url: '/me/verify-email/resend', headers: bearer(token), payload: {} }); + expect((await resend()).statusCode).toBe(200); + await verify(tokenFrom((await mailTo('alice@example.com', /Confirm/))!.text)); + expect((await resend()).statusCode).toBe(400); + }); +}); + +describe('changing email', () => { + it('only switches once the new address is confirmed, and warns the old one', async () => { + const { token } = await createUser(app(), 'alice'); + await verify(tokenFrom((await mailTo('alice@example.com', /Confirm/))!.text)); + + const res = await app().inject({ + method: 'POST', + url: '/me/email', + headers: bearer(token), + payload: { email: 'New@Example.com', password: 'password123' }, + }); + expect(res.json()).toMatchObject({ email: 'alice@example.com', pendingEmail: 'new@example.com' }); + expect(await mailTo('alice@example.com', /being changed/)).toBeTruthy(); + + await verify(tokenFrom((await mailTo('new@example.com', /Confirm your new/))!.text)); + expect(await me(token)).toMatchObject({ email: 'new@example.com', emailVerified: true, pendingEmail: null }); + const login = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'new@example.com', password: 'password123' } }); + expect(login.statusCode).toBe(200); + }); + + it('needs the password, and a free address', async () => { + const { token } = await createUser(app(), 'alice'); + await createUser(app(), 'bob'); + const change = (email: string, password: string) => + app().inject({ method: 'POST', url: '/me/email', headers: bearer(token), payload: { email, password } }); + expect((await change('x@example.com', 'wrong-password')).statusCode).toBe(403); + expect((await change('bob@example.com', 'password123')).statusCode).toBe(409); + }); + + it('can be cancelled', async () => { + const { token } = await createUser(app(), 'alice'); + await app().inject({ method: 'POST', url: '/me/email', headers: bearer(token), payload: { email: 'x@example.com', password: 'password123' } }); + const link = tokenFrom((await mailTo('x@example.com', /Confirm/))!.text); + const res = await app().inject({ method: 'DELETE', url: '/me/email/pending', headers: bearer(token) }); + expect(res.json().pendingEmail).toBeNull(); + expect((await verify(link)).statusCode).toBe(400); + }); +}); + +describe('changing password', () => { + it('needs the current password and signs out other sessions only', async () => { + const { token } = await createUser(app(), 'alice'); + const other = ( + await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'password123' } }) + ).json().token; + const change = (currentPassword: string) => + app().inject({ method: 'POST', url: '/me/password', headers: bearer(token), payload: { currentPassword, newPassword: 'better-pass-9' } }); + + expect((await change('nope')).statusCode).toBe(403); + expect((await change('password123')).statusCode).toBe(200); + expect((await app().inject({ url: '/me', headers: bearer(token) })).statusCode).toBe(200); + expect((await app().inject({ url: '/me', headers: bearer(other) })).statusCode).toBe(401); + const login = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'better-pass-9' } }); + expect(login.statusCode).toBe(200); + }); +}); diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts index 16e9ee4..802b350 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, password_resets restart identity cascade`; + await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars, password_resets, email_verifications restart identity cascade`; await redis.flushdb(); } diff --git a/apps/api/test/password-reset.test.ts b/apps/api/test/password-reset.test.ts index 9e43c0e..d290b62 100644 --- a/apps/api/test/password-reset.test.ts +++ b/apps/api/test/password-reset.test.ts @@ -7,10 +7,11 @@ beforeEach(() => { outbox.length = 0; }); -/** The email is sent in the background; give it a moment. */ +/** The reset email is sent in the background; give it a moment. (Sign-up sends its own confirmation email.) */ async function nextMail() { - for (let i = 0; i < 50 && outbox.length === 0; i++) await new Promise((r) => setTimeout(r, 10)); - return outbox.at(-1); + const reset = () => outbox.filter((m) => m.subject.startsWith('Reset')).at(-1); + for (let i = 0; i < 50 && !reset(); i++) await new Promise((r) => setTimeout(r, 10)); + return reset(); } const tokenFrom = (text: string) => new URL(text.match(/http\S+/)![0]).searchParams.get('token')!; @@ -50,7 +51,7 @@ describe('password reset', () => { const res = await requestReset('nobody@example.com'); expect(res.json()).toEqual({ ok: true }); await new Promise((r) => setTimeout(r, 100)); - expect(outbox).toHaveLength(0); + expect(outbox.filter((m) => m.subject.startsWith('Reset'))).toHaveLength(0); }); it('links work once, and a new request replaces the old link', async () => { diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index b854a77..fac54da 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -418,7 +418,22 @@ function JellyfinConnection({ } function Account() { - const { me, signOut } = useSession(); + const { me, setMe, signOut } = useSession(); + const resend = async () => { + try { + await api.resendVerification(); + Alert.alert('Sent', `We sent a new link to ${me?.email}.`); + } catch (err) { + showError(err); + } + }; + const cancelChange = async () => { + try { + setMe(await api.cancelEmailChange()); + } catch (err) { + showError(err); + } + }; const confirmDelete = () => Alert.alert('Delete your account?', 'This permanently removes your profile, follows and connected services.', [ { text: 'Cancel', style: 'cancel' }, @@ -439,8 +454,22 @@ function Account() { Account - Signed in as @{me?.username} · {me?.plan === 'free' ? 'Free plan' : 'Plus'} + Signed in as @{me?.username} + + {me?.email} · {me?.emailVerified ? 'confirmed' : 'not confirmed'} + + {me?.pendingEmail ? ( + + Changing to {me.pendingEmail}: open the link we sent there. + + ) : null} + + {me && !me.emailVerified && !me.pendingEmail && +

+ )} + {!me.emailVerified && !me.pendingEmail && ( + + )} + + + {editing === 'email' ? ( +
{ + e.preventDefault(); + const f = new FormData(e.currentTarget); + const email = field(f, 'email'); + run(() => client.changeEmail({ email, password: field(f, 'password') }), `Check ${email} for a confirmation link.`); + }} + > + + + + setEditing(null)} /> + + ) : editing === 'password' ? ( +
{ + e.preventDefault(); + const f = new FormData(e.currentTarget); + run( + () => client.changePassword({ currentPassword: field(f, 'current'), newPassword: field(f, 'new') }), + 'Password changed. Your other devices were signed out.', + ); + }} + > + + + + setEditing(null)} /> + + ) : ( +
+ + +
+ )} + {message &&

{message}

} + {error &&

{error}

} + + ); +} + +function ShowPassword({ show, setShow }: { show: boolean; setShow: (v: boolean) => void }) { + return ( + + ); +} + +function FormButtons({ label, onCancel }: { label: string; onCancel: () => void }) { + return ( +
+ + +
+ ); +} + function BlockedAccounts() { const { data: blocked = [], mutate } = useSWR('/me/blocks', fetcher); return ( diff --git a/apps/web/src/app/verify-email/page.tsx b/apps/web/src/app/verify-email/page.tsx new file mode 100644 index 0000000..304b01e --- /dev/null +++ b/apps/web/src/app/verify-email/page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { Suspense, useEffect, useRef, useState } from 'react'; +import { client, useMe } from '@/lib/api'; + +export default function VerifyEmailPage() { + return ( + + + + ); +} + +function VerifyEmail() { + const token = useSearchParams().get('token'); + const { me, mutate } = useMe(); + const [state, setState] = useState<{ ok: true; email: string } | { ok: false; error: string } | null>(null); + const started = useRef(false); + + useEffect(() => { + if (!token || started.current) return; // links work once; don't send twice in dev's double effects + started.current = true; + client + .verifyEmail(token) + .then((res) => { + setState(res); + void mutate(); + }) + .catch((err: Error) => setState({ ok: false, error: err.message })); + }, [token, mutate]); + + if (!token) return ; + if (!state) return ; + if (!state.ok) { + return ( + + {me &&

You can send a new link from the banner at the top, or from Settings.

} +
+ ); + } + return ( + + + {me ? 'Go to your feed' : 'Log in'} + + + ); +} + +function Result({ title, body, children }: { title: string; body: string; children?: React.ReactNode }) { + return ( +
+

{title}

+

{body}

+ {children} +
+ ); +} diff --git a/apps/web/src/components/AuthForm.tsx b/apps/web/src/components/AuthForm.tsx index f451599..866975e 100644 --- a/apps/web/src/components/AuthForm.tsx +++ b/apps/web/src/components/AuthForm.tsx @@ -10,6 +10,7 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) { const { mutate } = useMe(); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [showPassword, setShowPassword] = useState(false); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -43,12 +44,15 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) { Password + {error &&

{error}

} } + ); } diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts index 6d910b4..414fb50 100644 --- a/packages/shared/src/client.ts +++ b/packages/shared/src/client.ts @@ -10,7 +10,16 @@ import type { UserSummaryDTO, } from './dto'; import type { DeviceDTO } from './devices'; -import type { LoginInput, RegisterInput, ReportReason, ResetPasswordInput, SetFeaturedInput, UpdateMeInput } from './schemas'; +import type { + ChangeEmailInput, + ChangePasswordInput, + LoginInput, + RegisterInput, + ReportReason, + ResetPasswordInput, + SetFeaturedInput, + UpdateMeInput, +} from './schemas'; export class ApiError extends Error { constructor( @@ -72,6 +81,13 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt /** 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 }), + verifyEmail: (token: string) => request<{ ok: true; email: string }>('/auth/verify-email', { body: { token } }), + resendVerification: () => request<{ ok: true }>('/me/verify-email/resend', { body: {} }), + /** Signs out your other devices. */ + changePassword: (input: ChangePasswordInput) => request<{ ok: true }>('/me/password', { body: input }), + /** Sends a confirmation link to the new address; the email changes once it's clicked. */ + changeEmail: (input: ChangeEmailInput) => request('/me/email', { body: input }), + cancelEmailChange: () => request('/me/email/pending', { method: 'DELETE' }), logout: () => request('/auth/logout', { method: 'POST', body: {} }), me: () => request('/me'), diff --git a/packages/shared/src/dto.ts b/packages/shared/src/dto.ts index ac95a31..c99fcd8 100644 --- a/packages/shared/src/dto.ts +++ b/packages/shared/src/dto.ts @@ -21,6 +21,9 @@ export interface UserSummaryDTO { export interface MeDTO extends UserSummaryDTO { email: string; + emailVerified: boolean; + /** A new address waiting to be confirmed (the link was sent there). */ + pendingEmail: string | null; bio: string | null; preferredService: StreamingService | null; role: 'user' | 'admin'; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 9570619..8b02bdd 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -20,6 +20,18 @@ export const registerSchema = z.object({ export const forgotPasswordSchema = z.object({ email: emailSchema }); +export const verifyEmailSchema = z.object({ token: z.string().min(20).max(200) }); + +export const changePasswordSchema = z.object({ + currentPassword: z.string().min(1).max(200), + newPassword: newPasswordSchema, +}); + +export const changeEmailSchema = z.object({ + email: emailSchema, + password: z.string().min(1).max(200), +}); + export const resetPasswordSchema = z.object({ token: z.string().min(20).max(200), password: newPasswordSchema, @@ -50,6 +62,8 @@ export const connectUsernameSchema = z.object({ export type RegisterInput = z.infer; export type LoginInput = z.infer; export type ResetPasswordInput = z.infer; +export type ChangePasswordInput = z.infer; +export type ChangeEmailInput = z.infer; export type UpdateMeInput = z.infer; export type SetFeaturedInput = z.infer;