diff --git a/apps/api/drizzle/0029_fluffy_drax.sql b/apps/api/drizzle/0029_fluffy_drax.sql new file mode 100644 index 0000000000..a5dbe7b10a --- /dev/null +++ b/apps/api/drizzle/0029_fluffy_drax.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS "email_verification_codes" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL, + "user_id" uuid NOT NULL, + "email" varchar(255) NOT NULL, + "code" varchar(64) NOT NULL, + "expires_at" timestamp NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "email_verification_codes" ADD CONSTRAINT "email_verification_codes_user_id_userSetting_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."userSetting"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "email_verification_codes_user_id_idx" ON "email_verification_codes" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "email_verification_codes_expires_at_idx" ON "email_verification_codes" USING btree ("expires_at"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0029_snapshot.json b/apps/api/drizzle/meta/0029_snapshot.json new file mode 100644 index 0000000000..902882bba3 --- /dev/null +++ b/apps/api/drizzle/meta/0029_snapshot.json @@ -0,0 +1,1290 @@ +{ + "id": "23f3ad91-3b5d-4930-9a49-628ecd8f138a", + "prevId": "84347453-dc3f-4b93-bc6b-b1b3ff4fc21b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_wallets": { + "name": "user_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deployment_allowance": { + "name": "deployment_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "fee_allowance": { + "name": "fee_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_wallets_user_id_userSetting_id_fk": { + "name": "user_wallets_user_id_userSetting_id_fk", + "tableFrom": "user_wallets", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_wallets_user_id_unique": { + "name": "user_wallets_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "user_wallets_address_unique": { + "name": "user_wallets_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + } + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_validated": { + "name": "is_validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_methods_fingerprint_payment_method_id_unique": { + "name": "payment_methods_fingerprint_payment_method_id_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_default_unique": { + "name": "payment_methods_user_id_is_default_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"payment_methods\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_fingerprint_idx": { + "name": "payment_methods_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_idx": { + "name": "payment_methods_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_validated_idx": { + "name": "payment_methods_user_id_is_validated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_validated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_fingerprint_payment_method_id_idx": { + "name": "payment_methods_user_id_fingerprint_payment_method_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_userSetting_id_fk": { + "name": "payment_methods_user_id_userSetting_id_fk", + "tableFrom": "payment_methods", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.stripe_transactions": { + "name": "stripe_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "stripe_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "stripe_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_refunded": { + "name": "amount_refunded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_coupon_id": { + "name": "stripe_coupon_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_promotion_code_id": { + "name": "stripe_promotion_code_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_brand": { + "name": "card_brand", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_last4": { + "name": "card_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "receipt_url": { + "name": "receipt_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_transactions_user_id_idx": { + "name": "stripe_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_payment_intent_id_idx": { + "name": "stripe_transactions_stripe_payment_intent_id_idx", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_charge_id_idx": { + "name": "stripe_transactions_stripe_charge_id_idx", + "columns": [ + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_coupon_id_idx": { + "name": "stripe_transactions_stripe_coupon_id_idx", + "columns": [ + { + "expression": "stripe_coupon_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_promotion_code_id_idx": { + "name": "stripe_transactions_stripe_promotion_code_id_idx", + "columns": [ + { + "expression": "stripe_promotion_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_status_idx": { + "name": "stripe_transactions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_created_at_idx": { + "name": "stripe_transactions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_user_id_created_at_idx": { + "name": "stripe_transactions_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_transactions_user_id_userSetting_id_fk": { + "name": "stripe_transactions_user_id_userSetting_id_fk", + "tableFrom": "stripe_transactions", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.wallet_settings": { + "name": "wallet_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "auto_reload_enabled": { + "name": "auto_reload_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_settings_user_id_idx": { + "name": "wallet_settings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_settings_wallet_id_user_wallets_id_fk": { + "name": "wallet_settings_wallet_id_user_wallets_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "user_wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wallet_settings_user_id_userSetting_id_fk": { + "name": "wallet_settings_user_id_userSetting_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_settings_wallet_id_unique": { + "name": "wallet_settings_wallet_id_unique", + "nullsNotDistinct": false, + "columns": [ + "wallet_id" + ] + } + } + }, + "public.userSetting": { + "name": "userSetting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribedToNewsletter": { + "name": "subscribedToNewsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "youtubeUsername": { + "name": "youtubeUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "twitterUsername": { + "name": "twitterUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "githubUsername": { + "name": "githubUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_ip": { + "name": "last_ip", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_user_agent": { + "name": "last_user_agent", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_fingerprint": { + "name": "last_fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "userSetting_userId_unique": { + "name": "userSetting_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "userSetting_username_unique": { + "name": "userSetting_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "public.template": { + "name": "template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "copiedFromId": { + "name": "copiedFromId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cpu": { + "name": "cpu", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "storage": { + "name": "storage", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sdl": { + "name": "sdl", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "template_userId_idx": { + "name": "template_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.templateFavorite": { + "name": "templateFavorite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "templateId": { + "name": "templateId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "addedDate": { + "name": "addedDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "templateFavorite_userId_templateId_unique": { + "name": "templateFavorite_userId_templateId_unique", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "templateId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templateFavorite_templateId_template_id_fk": { + "name": "templateFavorite_templateId_template_id_fk", + "tableFrom": "templateFavorite", + "tableTo": "template", + "columnsFrom": [ + "templateId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dseq": { + "name": "dseq", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "id_auto_top_up_enabled_closed_idx": { + "name": "id_auto_top_up_enabled_closed_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "auto_top_up_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "closed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_settings_user_id_userSetting_id_fk": { + "name": "deployment_settings_user_id_userSetting_id_fk", + "tableFrom": "deployment_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dseq_user_id_idx": { + "name": "dseq_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "dseq", + "user_id" + ] + } + } + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_format": { + "name": "key_format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_userSetting_id_fk": { + "name": "api_keys_user_id_userSetting_id_fk", + "tableFrom": "api_keys", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_unique": { + "name": "api_keys_hashed_key_unique", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + } + }, + "public.email_verification_codes": { + "name": "email_verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verification_codes_user_id_idx": { + "name": "email_verification_codes_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_verification_codes_expires_at_idx": { + "name": "email_verification_codes_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_verification_codes_user_id_userSetting_id_fk": { + "name": "email_verification_codes_user_id_userSetting_id_fk", + "tableFrom": "email_verification_codes", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.stripe_transaction_status": { + "name": "stripe_transaction_status", + "schema": "public", + "values": [ + "created", + "pending", + "requires_action", + "succeeded", + "failed", + "refunded", + "canceled" + ] + }, + "public.stripe_transaction_type": { + "name": "stripe_transaction_type", + "schema": "public", + "values": [ + "payment_intent", + "coupon_claim" + ] + } + }, + "schemas": {}, + "_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 6febade28a..d1a454b701 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1771591487804, "tag": "0028_right_red_ghost", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1771979737373, + "tag": "0029_fluffy_drax", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/auth/controllers/auth/auth.controller.spec.ts b/apps/api/src/auth/controllers/auth/auth.controller.spec.ts new file mode 100644 index 0000000000..daf7cb73e9 --- /dev/null +++ b/apps/api/src/auth/controllers/auth/auth.controller.spec.ts @@ -0,0 +1,109 @@ +import { ResponseError } from "auth0"; +import { container as rootContainer } from "tsyringe"; +import { mock } from "vitest-mock-extended"; + +import { AuthService } from "@src/auth/services/auth.service"; +import type { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import { AUTH0_DB_CONNECTION } from "@src/auth/services/auth0/auth0.service"; +import type { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; +import type { UserService } from "@src/user/services/user/user.service"; +import { AuthController } from "./auth.controller"; + +import { UserSeeder } from "@test/seeders/user.seeder"; + +describe(AuthController.name, () => { + describe("signup", () => { + it("creates user via auth0 service", async () => { + const { controller, auth0Service } = setup(); + + auth0Service.createUser.mockResolvedValue(undefined); + + await controller.signup({ email: "user@example.com", password: "StrongPassword123!" }); + + expect(auth0Service.createUser).toHaveBeenCalledWith({ + email: "user@example.com", + password: "StrongPassword123!", + connection: AUTH0_DB_CONNECTION + }); + }); + + it("throws http error when auth0 returns a non-409 error", async () => { + const { controller, auth0Service } = setup(); + + auth0Service.createUser.mockRejectedValue( + new ResponseError(400, JSON.stringify({ message: "PasswordStrengthError: Password is too weak" }), new Headers()) + ); + + await expect(controller.signup({ email: "user@example.com", password: "weak" })).rejects.toThrow("PasswordStrengthError: Password is too weak"); + }); + + it("converts 409 (user exists) to generic 422 error", async () => { + const { controller, auth0Service } = setup(); + + auth0Service.createUser.mockRejectedValue(new ResponseError(409, JSON.stringify({ message: "The user already exists." }), new Headers())); + + await expect(controller.signup({ email: "user@example.com", password: "StrongPassword123!" })).rejects.toThrow( + "Unable to create account. Please try again or use a different email." + ); + }); + + it("re-throws non-ResponseError errors", async () => { + const { controller, auth0Service } = setup(); + + auth0Service.createUser.mockRejectedValue(new Error("Network failure")); + + await expect(controller.signup({ email: "user@example.com", password: "StrongPassword123!" })).rejects.toThrow("Network failure"); + }); + }); + + describe("sendVerificationCode", () => { + it("delegates to emailVerificationCodeService and wraps result in data", async () => { + const user = UserSeeder.create(); + const codeSentAt = new Date().toISOString(); + const { controller, emailVerificationCodeService } = setup({ user }); + + emailVerificationCodeService.sendCode.mockResolvedValue({ codeSentAt }); + + const result = await controller.sendVerificationCode(); + + expect(emailVerificationCodeService.sendCode).toHaveBeenCalledWith(user.id); + expect(result).toEqual({ data: { codeSentAt } }); + }); + }); + + describe("verifyEmailCode", () => { + it("delegates to emailVerificationCodeService with code", async () => { + const user = UserSeeder.create(); + const { controller, emailVerificationCodeService } = setup({ user }); + + emailVerificationCodeService.verifyCode.mockResolvedValue(undefined); + + await controller.verifyEmailCode({ data: { code: "123456" } }); + + expect(emailVerificationCodeService.verifyCode).toHaveBeenCalledWith(user.id, "123456"); + }); + }); + + function setup( + input: { + user?: ReturnType; + } = {} + ) { + const user = input.user ?? UserSeeder.create(); + + rootContainer.register(AuthService, { + useValue: mock({ + isAuthenticated: true, + currentUser: user + }) + }); + + const auth0Service = mock(); + const emailVerificationCodeService = mock(); + const userService = mock(); + + const controller = new AuthController(rootContainer.resolve(AuthService), auth0Service, userService, emailVerificationCodeService); + + return { controller, auth0Service, emailVerificationCodeService, userService }; + } +}); diff --git a/apps/api/src/auth/controllers/auth/auth.controller.ts b/apps/api/src/auth/controllers/auth/auth.controller.ts index 865d49a616..06ed717d62 100644 --- a/apps/api/src/auth/controllers/auth/auth.controller.ts +++ b/apps/api/src/auth/controllers/auth/auth.controller.ts @@ -1,9 +1,14 @@ +import { ResponseError } from "auth0"; +import assert from "http-assert"; import { singleton } from "tsyringe"; import type { SendVerificationEmailRequestInput } from "@src/auth"; import { VerifyEmailRequest } from "@src/auth/http-schemas/verify-email.schema"; +import type { SignupInput } from "@src/auth/routes/signup/signup.router"; +import type { VerifyEmailCodeRequest } from "@src/auth/routes/verify-email-code/verify-email-code.router"; import { AuthService, Protected } from "@src/auth/services/auth.service"; -import { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import { AUTH0_DB_CONNECTION, Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; import { UserService } from "@src/user/services/user/user.service"; @singleton() @@ -11,9 +16,30 @@ export class AuthController { constructor( private readonly authService: AuthService, private readonly auth0: Auth0Service, - private readonly userService: UserService + private readonly userService: UserService, + private readonly emailVerificationCodeService: EmailVerificationCodeService ) {} + async signup(input: SignupInput) { + try { + await this.auth0.createUser({ + email: input.email, + password: input.password, + connection: AUTH0_DB_CONNECTION + }); + } catch (error) { + if (error instanceof ResponseError) { + if (error.statusCode === 409) { + assert(false, 422, "Unable to create account. Please try again or use a different email."); + } + const body = JSON.parse(error.body); + assert(false, error.statusCode, body.message); + } + + throw error; + } + } + @Protected() async sendVerificationEmail({ data: { userId } }: SendVerificationEmailRequestInput) { const { currentUser } = this.authService; @@ -24,6 +50,22 @@ export class AuthController { } } + @Protected() + async sendVerificationCode() { + const { currentUser } = this.authService; + + const result = await this.emailVerificationCodeService.sendCode(currentUser!.id); + + return { data: result }; + } + + @Protected() + async verifyEmailCode({ data: { code } }: VerifyEmailCodeRequest) { + const { currentUser } = this.authService; + + await this.emailVerificationCodeService.verifyCode(currentUser!.id, code); + } + async syncEmailVerified({ data: { email } }: VerifyEmailRequest) { const { emailVerified } = await this.userService.syncEmailVerified({ email }); diff --git a/apps/api/src/auth/model-schemas/email-verification-code/email-verification-code.schema.ts b/apps/api/src/auth/model-schemas/email-verification-code/email-verification-code.schema.ts new file mode 100644 index 0000000000..bfec6d842f --- /dev/null +++ b/apps/api/src/auth/model-schemas/email-verification-code/email-verification-code.schema.ts @@ -0,0 +1,26 @@ +import { sql } from "drizzle-orm"; +import { index, integer, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { Users } from "@src/user/model-schemas"; + +export const EmailVerificationCodes = pgTable( + "email_verification_codes", + { + id: uuid("id") + .primaryKey() + .notNull() + .default(sql`uuid_generate_v4()`), + userId: uuid("user_id") + .references(() => Users.id, { onDelete: "cascade" }) + .notNull(), + email: varchar("email", { length: 255 }).notNull(), + code: varchar("code", { length: 64 }).notNull(), + expiresAt: timestamp("expires_at").notNull(), + attempts: integer("attempts").default(0).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull() + }, + table => ({ + userIdIdx: index("email_verification_codes_user_id_idx").on(table.userId), + expiresAtIdx: index("email_verification_codes_expires_at_idx").on(table.expiresAt) + }) +); diff --git a/apps/api/src/auth/model-schemas/index.ts b/apps/api/src/auth/model-schemas/index.ts index 80523e83ff..83bf964cd3 100644 --- a/apps/api/src/auth/model-schemas/index.ts +++ b/apps/api/src/auth/model-schemas/index.ts @@ -1 +1,2 @@ export * from "./api-key/api-key.schema"; +export * from "./email-verification-code/email-verification-code.schema"; diff --git a/apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts b/apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts new file mode 100644 index 0000000000..471b30262b --- /dev/null +++ b/apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts @@ -0,0 +1,66 @@ +import { and, count, desc, eq, gt, sql } from "drizzle-orm"; +import { singleton } from "tsyringe"; + +import { type ApiPgDatabase, type ApiPgTables, InjectPg, InjectPgTable } from "@src/core/providers"; +import { type AbilityParams, BaseRepository } from "@src/core/repositories/base.repository"; +import { TxService } from "@src/core/services"; + +type Table = ApiPgTables["EmailVerificationCodes"]; +export type EmailVerificationCodeInput = Partial; +export type EmailVerificationCodeDbOutput = Table["$inferSelect"]; + +export type EmailVerificationCodeOutput = Omit & { + expiresAt: string; + createdAt: string; +}; + +@singleton() +export class EmailVerificationCodeRepository extends BaseRepository { + constructor( + @InjectPg() protected readonly pg: ApiPgDatabase, + @InjectPgTable("EmailVerificationCodes") protected readonly table: Table, + protected readonly txManager: TxService + ) { + super(pg, table, txManager, "EmailVerificationCode", "EmailVerificationCodes"); + } + + accessibleBy(...abilityParams: AbilityParams) { + return new EmailVerificationCodeRepository(this.pg, this.table, this.txManager).withAbility(...abilityParams) as this; + } + + async findActiveByUserIdForUpdate(userId: string): Promise { + const [result] = await this.cursor + .select() + .from(this.table) + .where(and(eq(this.table.userId, userId), gt(this.table.expiresAt, sql`now()`))) + .orderBy(desc(this.table.createdAt)) + .limit(1) + .for("update"); + + return result ? this.toOutput(result) : undefined; + } + + async countRecentByUserId(userId: string, since: Date): Promise { + const [result] = await this.cursor + .select({ count: count() }) + .from(this.table) + .where(and(eq(this.table.userId, userId), gt(this.table.createdAt, since))); + + return result?.count ?? 0; + } + + async incrementAttempts(id: string): Promise { + await this.cursor + .update(this.table) + .set({ attempts: sql`${this.table.attempts} + 1` }) + .where(eq(this.table.id, id)); + } + + protected toOutput(payload: EmailVerificationCodeDbOutput): EmailVerificationCodeOutput { + return { + ...payload, + expiresAt: payload.expiresAt.toISOString(), + createdAt: payload.createdAt.toISOString() + }; + } +} diff --git a/apps/api/src/auth/routes/index.ts b/apps/api/src/auth/routes/index.ts index e97060367a..2df734fa11 100644 --- a/apps/api/src/auth/routes/index.ts +++ b/apps/api/src/auth/routes/index.ts @@ -1,2 +1,5 @@ export * from "./send-verification-email/send-verification-email.router"; +export * from "./send-verification-code/send-verification-code.router"; +export * from "./signup/signup.router"; +export * from "./verify-email-code/verify-email-code.router"; export * from "./api-keys/api-keys.router"; diff --git a/apps/api/src/auth/routes/send-verification-code/send-verification-code.router.ts b/apps/api/src/auth/routes/send-verification-code/send-verification-code.router.ts new file mode 100644 index 0000000000..88be666a87 --- /dev/null +++ b/apps/api/src/auth/routes/send-verification-code/send-verification-code.router.ts @@ -0,0 +1,33 @@ +import { SendVerificationCodeResponseSchema } from "@akashnetwork/http-sdk"; +import { container } from "tsyringe"; + +import { AuthController } from "@src/auth/controllers/auth/auth.controller"; +import { createRoute } from "@src/core/lib/create-route/create-route"; +import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; +import { SECURITY_BEARER } from "@src/core/services/openapi-docs/openapi-security"; + +export const sendVerificationCodeRouter = new OpenApiHonoHandler(); + +const route = createRoute({ + method: "post", + path: "/v1/send-verification-code", + summary: "Sends a verification code to the authenticated user's email", + tags: ["Users"], + security: SECURITY_BEARER, + responses: { + 200: { + description: "Returns the timestamp when the code was sent", + content: { + "application/json": { + schema: SendVerificationCodeResponseSchema + } + } + }, + 429: { description: "Too many requests" } + } +}); + +sendVerificationCodeRouter.openapi(route, async function sendVerificationCode(c) { + const result = await container.resolve(AuthController).sendVerificationCode(); + return c.json(result, 200); +}); diff --git a/apps/api/src/auth/routes/signup/signup.router.ts b/apps/api/src/auth/routes/signup/signup.router.ts new file mode 100644 index 0000000000..971258c9a8 --- /dev/null +++ b/apps/api/src/auth/routes/signup/signup.router.ts @@ -0,0 +1,44 @@ +import { container } from "tsyringe"; +import { z } from "zod"; + +import { AuthController } from "@src/auth/controllers/auth/auth.controller"; +import { createRoute } from "@src/core/lib/create-route/create-route"; +import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; +import { SECURITY_NONE } from "@src/core/services/openapi-docs/openapi-security"; + +const signupInputSchema = z.object({ + email: z.string().email(), + password: z.string().min(8) +}); + +export type SignupInput = z.infer; + +const route = createRoute({ + method: "post", + path: "/v1/auth/signup", + summary: "Creates a new user without sending a verification email", + tags: ["Auth"], + security: SECURITY_NONE, + request: { + body: { + required: true, + content: { + "application/json": { + schema: signupInputSchema + } + } + } + }, + responses: { + 204: { + description: "User created successfully" + } + } +}); + +export const signupRouter = new OpenApiHonoHandler(); + +signupRouter.openapi(route, async function signup(c) { + await container.resolve(AuthController).signup(c.req.valid("json")); + return c.body(null, 204); +}); diff --git a/apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts b/apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts new file mode 100644 index 0000000000..72d22fb8dd --- /dev/null +++ b/apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts @@ -0,0 +1,50 @@ +import { container } from "tsyringe"; +import { z } from "zod"; + +import { AuthController } from "@src/auth/controllers/auth/auth.controller"; +import { createRoute } from "@src/core/lib/create-route/create-route"; +import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; +import { SECURITY_BEARER } from "@src/core/services/openapi-docs/openapi-security"; + +export const verifyEmailCodeRouter = new OpenApiHonoHandler(); + +const VerifyEmailCodeRequestSchema = z.object({ + data: z.object({ + code: z + .string() + .length(6) + .regex(/^\d{6}$/, "Code must be exactly 6 digits") + }) +}); + +export type VerifyEmailCodeRequest = z.infer; + +const route = createRoute({ + method: "post", + path: "/v1/verify-email-code", + summary: "Verifies the email using a 6-digit code", + tags: ["Users"], + security: SECURITY_BEARER, + request: { + body: { + required: true, + content: { + "application/json": { + schema: VerifyEmailCodeRequestSchema + } + } + } + }, + responses: { + 204: { + description: "Email verified successfully" + }, + 400: { description: "Invalid or expired code" }, + 429: { description: "Too many attempts" } + } +}); + +verifyEmailCodeRouter.openapi(route, async function verifyEmailCode(c) { + await container.resolve(AuthController).verifyEmailCode(c.req.valid("json")); + return c.body(null, 204); +}); diff --git a/apps/api/src/auth/services/auth0/auth0.service.spec.ts b/apps/api/src/auth/services/auth0/auth0.service.spec.ts index 104d992364..8989beeeed 100644 --- a/apps/api/src/auth/services/auth0/auth0.service.spec.ts +++ b/apps/api/src/auth/services/auth0/auth0.service.spec.ts @@ -1,24 +1,63 @@ import { faker } from "@faker-js/faker"; import type { GetUsers200ResponseOneOfInner, ManagementClient } from "auth0"; -import { mock } from "vitest-mock-extended"; -import type { AuthConfigService } from "@src/auth/services/auth-config/auth-config.service"; import { Auth0Service } from "./auth0.service"; import { createAuth0User } from "@test/seeders"; describe(Auth0Service.name, () => { + describe("createUser", () => { + it("calls managementClient.users.create with verify_email false", async () => { + const create = jest.fn().mockResolvedValue({ data: {} }); + const { auth0Service } = setup({ users: { create } }); + + await auth0Service.createUser({ + email: "user@example.com", + password: "StrongPassword123!", + connection: "Username-Password-Authentication" + }); + + expect(create).toHaveBeenCalledWith({ + email: "user@example.com", + password: "StrongPassword123!", + connection: "Username-Password-Authentication", + verify_email: false + }); + }); + }); + + describe("sendVerificationEmail", () => { + it("calls managementClient.jobs.verifyEmail with user ID", async () => { + const verifyEmail = jest.fn().mockResolvedValue(undefined); + const { auth0Service } = setup({ jobs: { verifyEmail } }); + + await auth0Service.sendVerificationEmail("auth0|abc123"); + + expect(verifyEmail).toHaveBeenCalledWith({ user_id: "auth0|abc123" }); + }); + }); + + describe("markEmailVerified", () => { + it("calls managementClient.users.update with email_verified true", async () => { + const update = jest.fn().mockResolvedValue(undefined); + const { auth0Service } = setup({ users: { update } }); + + await auth0Service.markEmailVerified("auth0|xyz789"); + + expect(update).toHaveBeenCalledWith({ id: "auth0|xyz789" }, { email_verified: true }); + }); + }); + describe("getUserByEmail", () => { - it("should return user when user is found", async () => { + it("returns user when user is found", async () => { const email = faker.internet.email(); const mockUser: Partial = createAuth0User({ email: email, email_verified: true }); - const { auth0Service, mockGetByEmail } = setup({ - mockUsers: [mockUser as GetUsers200ResponseOneOfInner] - }); + const mockGetByEmail = jest.fn().mockResolvedValue({ data: [mockUser] }); + const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); const result = await auth0Service.getUserByEmail(email); @@ -26,12 +65,11 @@ describe(Auth0Service.name, () => { expect(mockGetByEmail).toHaveBeenCalledWith({ email }); }); - it("should return null when no user is found", async () => { + it("returns null when no user is found", async () => { const email = faker.internet.email(); - const { auth0Service, mockGetByEmail } = setup({ - mockUsers: [] - }); + const mockGetByEmail = jest.fn().mockResolvedValue({ data: [] }); + const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); const result = await auth0Service.getUserByEmail(email); @@ -39,7 +77,7 @@ describe(Auth0Service.name, () => { expect(mockGetByEmail).toHaveBeenCalledWith({ email }); }); - it("should return first user when multiple users are found", async () => { + it("returns first user when multiple users are found", async () => { const email = faker.internet.email(); const mockUsers: Partial[] = [ createAuth0User({ @@ -52,9 +90,8 @@ describe(Auth0Service.name, () => { }) ]; - const { auth0Service, mockGetByEmail } = setup({ - mockUsers: mockUsers as GetUsers200ResponseOneOfInner[] - }); + const mockGetByEmail = jest.fn().mockResolvedValue({ data: mockUsers }); + const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); const result = await auth0Service.getUserByEmail(email); @@ -62,52 +99,30 @@ describe(Auth0Service.name, () => { expect(mockGetByEmail).toHaveBeenCalledWith({ email }); }); - it("should handle empty email string", async () => { - const email = ""; + it("returns null for empty email string", async () => { + const mockGetByEmail = jest.fn().mockResolvedValue({ data: [] }); + const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); - const { auth0Service, mockGetByEmail } = setup({ - mockUsers: [] - }); - - const result = await auth0Service.getUserByEmail(email); + const result = await auth0Service.getUserByEmail(""); expect(result).toBeNull(); expect(mockGetByEmail).toHaveBeenCalledWith({ email: "" }); }); - - function setup(input: { mockUsers?: GetUsers200ResponseOneOfInner[]; mockError?: Error }) { - const mockAuthConfig = mock(); - mockAuthConfig.get.mockImplementation((key: string) => { - const config = { - AUTH0_M2M_DOMAIN: "test-domain.auth0.com", - AUTH0_M2M_CLIENT_ID: "test-client-id", - AUTH0_M2M_SECRET: "test-client-secret" - }; - return config[key as keyof typeof config]; - }); - - const mockGetByEmail = jest.fn(); - const mockManagementClient = { - usersByEmail: { - getByEmail: mockGetByEmail - } - } as unknown as ManagementClient; - - if (input.mockError) { - mockGetByEmail.mockRejectedValue(input.mockError); - } else { - mockGetByEmail.mockResolvedValue({ - data: input.mockUsers || [] - }); - } - - const auth0Service = new Auth0Service(mockManagementClient); - - return { - auth0Service, - mockGetByEmail, - mockManagementClient - }; - } }); + + function setup( + input: { + jobs?: { verifyEmail: jest.Mock }; + users?: { update?: jest.Mock; create?: jest.Mock }; + usersByEmail?: { getByEmail: jest.Mock }; + } = {} + ) { + const mockManagementClient = { + ...input + } as unknown as ManagementClient; + + const auth0Service = new Auth0Service(mockManagementClient); + + return { auth0Service, mockManagementClient }; + } }); diff --git a/apps/api/src/auth/services/auth0/auth0.service.ts b/apps/api/src/auth/services/auth0/auth0.service.ts index 80e265c19c..8ad343ca6f 100644 --- a/apps/api/src/auth/services/auth0/auth0.service.ts +++ b/apps/api/src/auth/services/auth0/auth0.service.ts @@ -1,14 +1,29 @@ import { GetUsers200ResponseOneOfInner, ManagementClient } from "auth0"; import { singleton } from "tsyringe"; +export const AUTH0_DB_CONNECTION = "Username-Password-Authentication"; + @singleton() export class Auth0Service { constructor(private readonly managementClient: ManagementClient) {} + async createUser(input: { email: string; password: string; connection: string }): Promise { + await this.managementClient.users.create({ + email: input.email, + password: input.password, + connection: input.connection, + verify_email: false + }); + } + async sendVerificationEmail(userId: string) { await this.managementClient.jobs.verifyEmail({ user_id: userId }); } + async markEmailVerified(userId: string) { + await this.managementClient.users.update({ id: userId }, { email_verified: true }); + } + async getUserByEmail(email: string): Promise { const { data: users } = await this.managementClient.usersByEmail.getByEmail({ email }); if (users.length === 0) { diff --git a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts new file mode 100644 index 0000000000..471624757b --- /dev/null +++ b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts @@ -0,0 +1,192 @@ +import "@test/mocks/logger-service.mock"; + +import { faker } from "@faker-js/faker"; +import { createHash } from "crypto"; +import { mock } from "vitest-mock-extended"; + +import type { + EmailVerificationCodeOutput, + EmailVerificationCodeRepository +} from "@src/auth/repositories/email-verification-code/email-verification-code.repository"; +import type { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import type { LoggerService } from "@src/core/providers/logging.provider"; +import type { NotificationService } from "@src/notifications/services/notification/notification.service"; +import type { UserRepository } from "@src/user/repositories/user/user.repository"; +import { EmailVerificationCodeService } from "./email-verification-code.service"; + +import { UserSeeder } from "@test/seeders/user.seeder"; + +describe(EmailVerificationCodeService.name, () => { + describe("sendCode", () => { + it("creates a new code and sends notification", async () => { + const user = UserSeeder.create({ email: "test@example.com" }); + const createdRecord = createVerificationCodeOutput({ userId: user.id }); + const { service, emailVerificationCodeRepository, userRepository, notificationService } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.countRecentByUserId.mockResolvedValue(0); + emailVerificationCodeRepository.create.mockResolvedValue(createdRecord); + + const result = await service.sendCode(user.id); + + expect(emailVerificationCodeRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: user.id, + email: user.email, + code: expect.stringMatching(/^[a-f0-9]{64}$/) + }) + ); + expect(notificationService.createNotification).toHaveBeenCalled(); + expect(result.codeSentAt).toBe(createdRecord.createdAt); + }); + + it("throws 429 when rate limit exceeded", async () => { + const user = UserSeeder.create({ email: "test@example.com" }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.countRecentByUserId.mockResolvedValue(5); + + await expect(service.sendCode(user.id)).rejects.toThrow(); + }); + + it("throws 404 when user not found", async () => { + const { service, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(undefined); + + await expect(service.sendCode("nonexistent")).rejects.toThrow(); + }); + + it("throws 400 when user has no email", async () => { + const user = UserSeeder.create({ email: null }); + const { service, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + + await expect(service.sendCode(user.id)).rejects.toThrow(); + }); + }); + + describe("verifyCode", () => { + it("verifies valid code and marks email as verified", async () => { + const code = "123456"; + const user = UserSeeder.create({ userId: "auth0|123" }); + const record = createVerificationCodeOutput({ userId: user.id, code: hashCode(code), attempts: 0 }); + const { service, emailVerificationCodeRepository, userRepository, auth0Service } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(record); + + await service.verifyCode(user.id, code); + + expect(auth0Service.markEmailVerified).toHaveBeenCalledWith(user.userId); + expect(userRepository.updateById).toHaveBeenCalledWith(user.id, { emailVerified: true }); + }); + + it("throws and increments attempts for invalid code", async () => { + const user = UserSeeder.create({ userId: "auth0|123" }); + const record = createVerificationCodeOutput({ userId: user.id, code: hashCode("123456"), attempts: 0 }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(record); + + await expect(service.verifyCode(user.id, "999999")).rejects.toThrow("Invalid verification code"); + expect(emailVerificationCodeRepository.incrementAttempts).toHaveBeenCalledWith(record.id); + }); + + it("rejects when max attempts exceeded", async () => { + const user = UserSeeder.create({ userId: "auth0|123" }); + const record = createVerificationCodeOutput({ userId: user.id, code: hashCode("123456"), attempts: 5 }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(record); + + await expect(service.verifyCode(user.id, "123456")).rejects.toThrow(); + expect(emailVerificationCodeRepository.incrementAttempts).not.toHaveBeenCalled(); + }); + + it("throws and increments attempts for mismatched length code", async () => { + const user = UserSeeder.create({ userId: "auth0|123" }); + const record = createVerificationCodeOutput({ userId: user.id, code: hashCode("123456"), attempts: 0 }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(record); + + await expect(service.verifyCode(user.id, "12345")).rejects.toThrow("Invalid verification code"); + expect(emailVerificationCodeRepository.incrementAttempts).toHaveBeenCalledWith(record.id); + }); + + it("updates local DB even if Auth0 call fails", async () => { + const code = "123456"; + const user = UserSeeder.create({ userId: "auth0|123" }); + const record = createVerificationCodeOutput({ userId: user.id, code: hashCode(code), attempts: 0 }); + const { service, emailVerificationCodeRepository, userRepository, auth0Service } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(record); + auth0Service.markEmailVerified.mockRejectedValue(new Error("Auth0 unavailable")); + + await expect(service.verifyCode(user.id, code)).rejects.toThrow("Auth0 unavailable"); + + expect(userRepository.updateById).toHaveBeenCalledWith(user.id, { emailVerified: true }); + }); + + it("rejects when no active code exists", async () => { + const user = UserSeeder.create({ userId: "auth0|123" }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(undefined); + + await expect(service.verifyCode(user.id, "123456")).rejects.toThrow(); + }); + }); + + function hashCode(code: string) { + return createHash("sha256").update(code).digest("hex"); + } + + function createVerificationCodeOutput(overrides: Partial = {}): EmailVerificationCodeOutput { + return { + id: faker.string.uuid(), + userId: faker.string.uuid(), + email: faker.internet.email(), + code: hashCode(faker.string.numeric(6)), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + attempts: 0, + createdAt: new Date().toISOString(), + ...overrides + }; + } + + function setup( + input: { + emailVerificationCodeRepository?: EmailVerificationCodeRepository; + auth0Service?: Auth0Service; + notificationService?: NotificationService; + userRepository?: UserRepository; + logger?: LoggerService; + } = {} + ) { + const emailVerificationCodeRepository = input.emailVerificationCodeRepository ?? mock(); + const auth0Service = input.auth0Service ?? mock(); + const notificationService = input.notificationService ?? mock(); + const userRepository = input.userRepository ?? mock(); + const logger = input.logger ?? mock(); + + const service = new EmailVerificationCodeService(emailVerificationCodeRepository, auth0Service, notificationService, userRepository, logger); + + return { + service, + emailVerificationCodeRepository, + auth0Service, + notificationService, + userRepository, + logger + }; + } +}); diff --git a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts new file mode 100644 index 0000000000..facd0ac477 --- /dev/null +++ b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts @@ -0,0 +1,90 @@ +import { createHash, randomInt, timingSafeEqual } from "crypto"; +import assert from "http-assert"; +import { singleton } from "tsyringe"; + +import { EmailVerificationCodeRepository } from "@src/auth/repositories/email-verification-code/email-verification-code.repository"; +import { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import { WithTransaction } from "@src/core"; +import { LoggerService } from "@src/core/providers/logging.provider"; +import { NotificationService } from "@src/notifications/services/notification/notification.service"; +import { emailVerificationCodeNotification } from "@src/notifications/services/notification-templates/email-verification-code-notification"; +import { UserRepository } from "@src/user/repositories/user/user.repository"; + +const CODE_EXPIRY_MS = 10 * 60 * 1000; +const MAX_ATTEMPTS = 5; +const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; +const MAX_CODES_PER_WINDOW = 5; + +function hashCode(code: string): string { + return createHash("sha256").update(code).digest("hex"); +} + +@singleton() +export class EmailVerificationCodeService { + constructor( + private readonly emailVerificationCodeRepository: EmailVerificationCodeRepository, + private readonly auth0Service: Auth0Service, + private readonly notificationService: NotificationService, + private readonly userRepository: UserRepository, + private readonly logger: LoggerService + ) {} + + async sendCode(userInternalId: string): Promise<{ codeSentAt: string }> { + const user = await this.userRepository.findById(userInternalId); + assert(user, 404, "User not found"); + assert(user.email, 400, "User has no email address"); + + const since = new Date(Date.now() - RATE_LIMIT_WINDOW_MS); + const recentCount = await this.emailVerificationCodeRepository.countRecentByUserId(userInternalId, since); + assert(recentCount < MAX_CODES_PER_WINDOW, 429, "Too many verification code requests. Please try again later."); + + const code = randomInt(100000, 1000000).toString(); + const expiresAt = new Date(Date.now() + CODE_EXPIRY_MS); + + const record = await this.emailVerificationCodeRepository.create({ + userId: userInternalId, + email: user.email, + code: hashCode(code), + expiresAt + }); + + await this.notificationService.createNotification(emailVerificationCodeNotification({ id: userInternalId, email: user.email }, { code })); + + this.logger.info({ event: "VERIFICATION_CODE_SENT", userId: userInternalId }); + + return { codeSentAt: record.createdAt }; + } + + async verifyCode(userInternalId: string, code: string): Promise { + const auth0UserId = await this.verifyCodeInTransaction(userInternalId, code); + + await this.auth0Service.markEmailVerified(auth0UserId); + + this.logger.info({ event: "EMAIL_VERIFIED_VIA_CODE", userId: userInternalId }); + } + + @WithTransaction() + private async verifyCodeInTransaction(userInternalId: string, code: string): Promise { + const [user, record] = await Promise.all([ + this.userRepository.findById(userInternalId), + this.emailVerificationCodeRepository.findActiveByUserIdForUpdate(userInternalId) + ]); + assert(user, 404, "User not found"); + assert(user.userId, 400, "User has no Auth0 ID"); + assert(record, 400, "No active verification code. Please request a new one."); + assert(record.attempts < MAX_ATTEMPTS, 429, "Too many attempts. Please request a new code."); + + const codeBuffer = Buffer.from(hashCode(code)); + const recordBuffer = Buffer.from(record.code); + const isCodeValid = codeBuffer.length === recordBuffer.length && timingSafeEqual(recordBuffer, codeBuffer); + + if (!isCodeValid) { + await this.emailVerificationCodeRepository.incrementAttempts(record.id); + assert(false, 400, "Invalid verification code"); + } + + await this.userRepository.updateById(userInternalId, { emailVerified: true }); + + return user.userId; + } +} diff --git a/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.spec.ts b/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.spec.ts new file mode 100644 index 0000000000..5dcfa31b01 --- /dev/null +++ b/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.spec.ts @@ -0,0 +1,16 @@ +import { emailVerificationCodeNotification } from "./email-verification-code-notification"; + +describe(emailVerificationCodeNotification.name, () => { + it("returns notification with correct structure", () => { + const user = { id: "user-123", email: "test@example.com" }; + const vars = { code: "123456" }; + + const result = emailVerificationCodeNotification(user, vars); + + expect(result.notificationId).toMatch(/^emailVerificationCode\.user-123\.[0-9a-f-]{36}$/); + expect(result.payload.summary).toBe("Your verification code"); + expect(result.payload.description).toContain("123456"); + expect(result.payload.description).toContain("expires in 10 minutes"); + expect(result.user).toEqual({ id: "user-123", email: "test@example.com" }); + }); +}); diff --git a/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts b/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts new file mode 100644 index 0000000000..33fc60ed67 --- /dev/null +++ b/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts @@ -0,0 +1,19 @@ +import { randomUUID } from "crypto"; + +import type { CreateNotificationInput } from "../notification/notification.service"; + +export function emailVerificationCodeNotification(user: { id: string; email: string }, vars: { code: string }): CreateNotificationInput { + return { + notificationId: `emailVerificationCode.${user.id}.${randomUUID()}`, + payload: { + summary: "Your verification code", + description: + `Your email verification code is: ${vars.code}. ` + + `This code expires in 10 minutes. If you did not request this code, please ignore this email.` + }, + user: { + id: user.id, + email: user.email + } + }; +} diff --git a/apps/api/src/rest-app.ts b/apps/api/src/rest-app.ts index 7bf74d66d3..03ff78ace8 100644 --- a/apps/api/src/rest-app.ts +++ b/apps/api/src/rest-app.ts @@ -32,7 +32,7 @@ import { legacyRouter } from "./routers/legacyRouter"; import { web3IndexRouter } from "./routers/web3indexRouter"; import { bytesToHumanReadableSize } from "./utils/files"; import { addressRouter } from "./address"; -import { apiKeysRouter, sendVerificationEmailRouter } from "./auth"; +import { apiKeysRouter, sendVerificationCodeRouter, sendVerificationEmailRouter, signupRouter, verifyEmailCodeRouter } from "./auth"; import { getBalancesRouter, getWalletListRouter, @@ -123,6 +123,9 @@ const openApiHonoHandlers: OpenApiHonoHandler[] = [ userSettingsRouter, userTemplatesRouter, sendVerificationEmailRouter, + sendVerificationCodeRouter, + signupRouter, + verifyEmailCodeRouter, verifyEmailRouter, deploymentSettingRouter, deploymentsRouter, diff --git a/apps/api/src/user/services/user/user.service.integration.ts b/apps/api/src/user/services/user/user.service.integration.ts index 2ad460196c..9f5fde3e65 100644 --- a/apps/api/src/user/services/user/user.service.integration.ts +++ b/apps/api/src/user/services/user/user.service.integration.ts @@ -4,6 +4,7 @@ import { container } from "tsyringe"; import { mock } from "vitest-mock-extended"; import type { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import type { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; import type { LoggerService } from "@src/core/providers/logging.provider"; import type { AnalyticsService } from "@src/core/services/analytics/analytics.service"; import type { NotificationService } from "@src/notifications/services/notification/notification.service"; @@ -343,7 +344,10 @@ describe(UserService.name, () => { mock({ createDefaultChannel: input?.createDefaultNotificationChannel ?? (() => Promise.resolve()) }), - auth0Service + auth0Service, + mock({ + sendCode: jest.fn().mockResolvedValue({ codeSentAt: new Date().toISOString() }) + }) ); return { service, analyticsService, logger, auth0Service, userRepository }; diff --git a/apps/api/src/user/services/user/user.service.spec.ts b/apps/api/src/user/services/user/user.service.spec.ts new file mode 100644 index 0000000000..d6644a8d21 --- /dev/null +++ b/apps/api/src/user/services/user/user.service.spec.ts @@ -0,0 +1,86 @@ +import "@test/mocks/logger-service.mock"; + +import { faker } from "@faker-js/faker"; +import { mock } from "vitest-mock-extended"; + +import type { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import type { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; +import type { LoggerService } from "@src/core/providers/logging.provider"; +import type { AnalyticsService } from "@src/core/services/analytics/analytics.service"; +import type { NotificationService } from "@src/notifications/services/notification/notification.service"; +import type { UserRepository } from "@src/user/repositories/user/user.repository"; +import type { RegisterUserInput } from "./user.service"; +import { UserService } from "./user.service"; + +import { UserSeeder } from "@test/seeders/user.seeder"; + +describe(UserService.name, () => { + describe("registerUser", () => { + it("sends verification code when email is not verified", async () => { + const user = UserSeeder.create({ emailVerified: false, email: "test@example.com" }); + const { service, emailVerificationCodeService, userRepository, notificationService } = setup(); + + userRepository.upsertOnExternalIdConflict.mockResolvedValue(user); + notificationService.createDefaultChannel.mockResolvedValue(undefined); + emailVerificationCodeService.sendCode.mockResolvedValue({ codeSentAt: new Date().toISOString() }); + + await service.registerUser(createRegisterInput({ emailVerified: false })); + + expect(emailVerificationCodeService.sendCode).toHaveBeenCalledWith(user.id); + }); + + it("does not send verification code when email is already verified", async () => { + const user = UserSeeder.create({ emailVerified: true, email: "test@example.com" }); + const { service, emailVerificationCodeService, userRepository, notificationService } = setup(); + + userRepository.upsertOnExternalIdConflict.mockResolvedValue(user); + notificationService.createDefaultChannel.mockResolvedValue(undefined); + + await service.registerUser(createRegisterInput({ emailVerified: true })); + + expect(emailVerificationCodeService.sendCode).not.toHaveBeenCalled(); + }); + + it("logs error but does not throw when verification code send fails", async () => { + const user = UserSeeder.create({ emailVerified: false, email: "test@example.com" }); + const { service, emailVerificationCodeService, userRepository, notificationService, logger } = setup(); + const sendError = new Error("Send failed"); + + userRepository.upsertOnExternalIdConflict.mockResolvedValue(user); + notificationService.createDefaultChannel.mockResolvedValue(undefined); + emailVerificationCodeService.sendCode.mockRejectedValue(sendError); + + const result = await service.registerUser(createRegisterInput({ emailVerified: false })); + + expect(result.id).toBe(user.id); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "FAILED_TO_SEND_INITIAL_VERIFICATION_CODE", id: user.id, error: sendError })); + }); + }); + + function setup() { + const userRepository = mock(); + const analyticsService = mock(); + const logger = mock(); + const notificationService = mock(); + const auth0Service = mock(); + const emailVerificationCodeService = mock(); + + const service = new UserService(userRepository, analyticsService, logger, notificationService, auth0Service, emailVerificationCodeService); + + return { service, userRepository, analyticsService, logger, notificationService, auth0Service, emailVerificationCodeService }; + } + + function createRegisterInput(overrides: Partial = {}): RegisterUserInput { + return { + userId: faker.string.uuid(), + wantedUsername: faker.internet.userName(), + email: faker.internet.email(), + emailVerified: false, + subscribedToNewsletter: false, + ip: faker.internet.ip(), + userAgent: faker.internet.userAgent(), + fingerprint: faker.string.uuid(), + ...overrides + }; + } +}); diff --git a/apps/api/src/user/services/user/user.service.ts b/apps/api/src/user/services/user/user.service.ts index 515b74a932..8204284a2e 100644 --- a/apps/api/src/user/services/user/user.service.ts +++ b/apps/api/src/user/services/user/user.service.ts @@ -3,6 +3,7 @@ import randomInt from "lodash/random"; import { singleton } from "tsyringe"; import { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; import { LoggerService } from "@src/core/providers/logging.provider"; import { isUniqueViolation } from "@src/core/repositories/base.repository"; import { AnalyticsService } from "@src/core/services/analytics/analytics.service"; @@ -16,7 +17,8 @@ export class UserService { private readonly analyticsService: AnalyticsService, private readonly logger: LoggerService, private readonly notificationService: NotificationService, - private readonly auth0: Auth0Service + private readonly auth0: Auth0Service, + private readonly emailVerificationCodeService: EmailVerificationCodeService ) {} async registerUser(data: RegisterUserInput): Promise<{ @@ -59,6 +61,12 @@ export class UserService { this.logger.error({ event: "FAILED_TO_CREATE_DEFAULT_NOTIFICATION_CHANNEL", id: user.id, error: result.error }); } + if (!data.emailVerified && user.email) { + await this.emailVerificationCodeService.sendCode(user.id).catch(error => { + this.logger.error({ event: "FAILED_TO_SEND_INITIAL_VERIFICATION_CODE", id: user.id, error }); + }); + } + const { id, userId, username, email, emailVerified, stripeCustomerId, bio, subscribedToNewsletter, youtubeUsername, twitterUsername, githubUsername } = user; diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index 10b89ea510..1e75f852e3 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -2641,6 +2641,44 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` ], }, }, + "/v1/auth/signup": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "format": "email", + "type": "string", + }, + "password": { + "minLength": 8, + "type": "string", + }, + }, + "required": [ + "email", + "password", + ], + "type": "object", + }, + }, + }, + "required": true, + }, + "responses": { + "204": { + "description": "User created successfully", + }, + }, + "security": [], + "summary": "Creates a new user without sending a verification email", + "tags": [ + "Auth", + ], + }, + }, "/v1/balances": { "get": { "parameters": [ @@ -12504,6 +12542,50 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` ], }, }, + "/v1/send-verification-code": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": { + "codeSentAt": { + "type": "string", + }, + }, + "required": [ + "codeSentAt", + ], + "type": "object", + }, + }, + "required": [ + "data", + ], + "type": "object", + }, + }, + }, + "description": "Returns the timestamp when the code was sent", + }, + "429": { + "description": "Too many requests", + }, + }, + "security": [ + { + "BearerAuth": [], + }, + ], + "summary": "Sends a verification code to the authenticated user's email", + "tags": [ + "Users", + ], + }, + }, "/v1/send-verification-email": { "post": { "requestBody": { @@ -15527,6 +15609,59 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` ], }, }, + "/v1/verify-email-code": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": { + "code": { + "maxLength": 6, + "minLength": 6, + "pattern": "^\\d{6}$", + "type": "string", + }, + }, + "required": [ + "code", + ], + "type": "object", + }, + }, + "required": [ + "data", + ], + "type": "object", + }, + }, + }, + "required": true, + }, + "responses": { + "204": { + "description": "Email verified successfully", + }, + "400": { + "description": "Invalid or expired code", + }, + "429": { + "description": "Too many attempts", + }, + }, + "security": [ + { + "BearerAuth": [], + }, + ], + "summary": "Verifies the email using a 6-digit code", + "tags": [ + "Users", + ], + }, + }, "/v1/wallet-settings": { "delete": { "description": "Deletes wallet settings for a user wallet", diff --git a/apps/deploy-web/src/components/onboarding/OnboardingView/OnboardingView.tsx b/apps/deploy-web/src/components/onboarding/OnboardingView/OnboardingView.tsx index a28d422ec4..b64213cd3c 100644 --- a/apps/deploy-web/src/components/onboarding/OnboardingView/OnboardingView.tsx +++ b/apps/deploy-web/src/components/onboarding/OnboardingView/OnboardingView.tsx @@ -57,7 +57,7 @@ export const OnboardingView: FC = ({ ...steps[OnboardingStepIndex.EMAIL_VERIFICATION], component: ( onStepChange(OnboardingStepIndex.PAYMENT_METHOD)}> - {props => } + {({ sendCode, verifyCode }) => } ) }, diff --git a/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.spec.tsx b/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.spec.tsx index 628f379eee..29ed058a61 100644 --- a/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.spec.tsx +++ b/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.spec.tsx @@ -4,79 +4,72 @@ import { describe, expect, it, vi } from "vitest"; import { VerifyEmailPage } from "./VerifyEmailPage"; -import { act, render, screen } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; describe(VerifyEmailPage.name, () => { - it("calls verifyEmail with the email from search params", () => { - const { mockVerifyEmail } = setup({ email: "test@example.com" }); + it("shows redirect loading text", () => { + const { restore } = setup(); - expect(mockVerifyEmail).toHaveBeenCalledWith("test@example.com"); + expect(screen.queryByText("Redirecting to email verification...")).toBeInTheDocument(); + restore(); }); - it("does not call verifyEmail when email param is missing", () => { - const { mockVerifyEmail } = setup({ email: null }); + it("sets onboarding step to EMAIL_VERIFICATION in localStorage", () => { + const { mockLocalStorage, restore } = setup(); - expect(mockVerifyEmail).not.toHaveBeenCalled(); + expect(mockLocalStorage.setItem).toHaveBeenCalledWith("onboardingStep", "2"); + restore(); }); - it("shows loading text when verification is pending", () => { - setup({ email: "test@example.com", isPending: true }); + it("redirects to onboarding page", () => { + const { getLocationHref, restore } = setup(); - expect(screen.queryByText("Just a moment while we finish verifying your email.")).toBeInTheDocument(); + expect(getLocationHref()).toBe("/signup?return-to=%2F"); + restore(); }); - it("shows success message when email is verified", () => { - const { capturedOnSuccess } = setup({ email: "test@example.com" }); - - act(() => capturedOnSuccess?.(true)); - - expect(screen.queryByTestId("CheckCircleIcon")).toBeInTheDocument(); - }); - - it("shows error message when email verification fails", () => { - const { capturedOnError } = setup({ email: "test@example.com" }); - - act(() => capturedOnError?.()); - - expect(screen.queryByText("Your email was not verified. Please try again.")).toBeInTheDocument(); - }); - - it("shows error message when isVerified is null", () => { - setup({ email: "test@example.com" }); - - expect(screen.queryByText("Your email was not verified. Please try again.")).toBeInTheDocument(); - }); - - function setup(input: { email?: string | null; isPending?: boolean }) { - const mockVerifyEmail = vi.fn(); - let capturedOnSuccess: ((isVerified: boolean) => void) | undefined; - let capturedOnError: (() => void) | undefined; - - const mockUseVerifyEmail = vi.fn().mockImplementation((options: { onSuccess?: (v: boolean) => void; onError?: () => void }) => { - capturedOnSuccess = options.onSuccess; - capturedOnError = options.onError; - return { mutate: mockVerifyEmail, isPending: input.isPending || false }; - }); - - const mockUseWhen = vi.fn().mockImplementation((condition: unknown, run: () => void) => { - if (condition) { - run(); - } + function setup(input: { onboardingUrl?: string } = {}) { + const originalLocalStorage = window.localStorage; + const originalLocation = window.location; + + const mockLocalStorage = { + setItem: vi.fn(), + getItem: vi.fn(), + removeItem: vi.fn() + }; + Object.defineProperty(window, "localStorage", { value: mockLocalStorage, writable: true, configurable: true }); + + let capturedHref = ""; + Object.defineProperty(window, "location", { + value: { + get href() { + return capturedHref; + }, + set href(val: string) { + capturedHref = val; + } + }, + writable: true, + configurable: true }); const dependencies = { - useSearchParams: vi.fn().mockReturnValue(new URLSearchParams(input.email ? `email=${input.email}` : "")), - useVerifyEmail: mockUseVerifyEmail, - useWhen: mockUseWhen, Layout: ({ children }: { children: React.ReactNode }) =>
{children}
, Loading: ({ text }: { text: string }) =>
{text}
, UrlService: { - onboarding: vi.fn(() => "/signup") + onboarding: vi.fn(() => input.onboardingUrl ?? "/signup?return-to=%2F") } } as unknown as ComponentProps["dependencies"]; render(); - return { mockVerifyEmail, capturedOnSuccess, capturedOnError }; + return { + mockLocalStorage, + getLocationHref: () => capturedHref, + restore: () => { + Object.defineProperty(window, "localStorage", { value: originalLocalStorage, writable: true, configurable: true }); + Object.defineProperty(window, "location", { value: originalLocation, writable: true, configurable: true }); + } + }; } }); diff --git a/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.tsx b/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.tsx index a18b236003..b9b1440889 100644 --- a/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.tsx +++ b/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.tsx @@ -1,24 +1,15 @@ -import React, { useCallback, useState } from "react"; -import { AutoButton } from "@akashnetwork/ui/components"; -import CheckCircleIcon from "@mui/icons-material/CheckCircle"; -import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; -import { ArrowRight } from "iconoir-react"; -import { useSearchParams } from "next/navigation"; +import React, { useEffect } from "react"; import { NextSeo } from "next-seo"; import Layout, { Loading } from "@src/components/layout/Layout"; import { OnboardingStepIndex } from "@src/components/onboarding/OnboardingContainer/OnboardingContainer"; -import { useWhen } from "@src/hooks/useWhen"; -import { useVerifyEmail } from "@src/queries/useVerifyEmailQuery"; import { ONBOARDING_STEP_KEY } from "@src/services/storage/keys"; import { UrlService } from "@src/utils/urlUtils"; const DEPENDENCIES = { - useSearchParams, - useVerifyEmail, - useWhen, Layout, Loading, + NextSeo, UrlService }; @@ -26,68 +17,16 @@ type VerifyEmailPageProps = { dependencies?: typeof DEPENDENCIES; }; -type VerificationResultProps = { - isVerified: boolean; - dependencies: Pick; -}; - -function VerificationResult({ isVerified, dependencies: d }: VerificationResultProps) { - const gotoOnboarding = useCallback(() => { - window.localStorage?.setItem(ONBOARDING_STEP_KEY, OnboardingStepIndex.PAYMENT_METHOD.toString()); +export function VerifyEmailPage({ dependencies: d = DEPENDENCIES }: VerifyEmailPageProps) { + useEffect(() => { + window.localStorage.setItem(ONBOARDING_STEP_KEY, OnboardingStepIndex.EMAIL_VERIFICATION.toString()); window.location.href = d.UrlService.onboarding({ returnTo: "/" }); }, [d.UrlService]); - return ( -
- {isVerified ? ( - <> - -
- Your email was verified. -
- You can continue using the application. -
- - Continue - - } - timeout={5000} - /> - - ) : ( - <> - -
Your email was not verified. Please try again.
- - )} -
- ); -} - -export function VerifyEmailPage({ dependencies: d = DEPENDENCIES }: VerifyEmailPageProps) { - const email = d.useSearchParams().get("email"); - const [isVerified, setIsVerified] = useState(null); - const { mutate: verifyEmail, isPending: isVerifying } = d.useVerifyEmail({ onSuccess: setIsVerified, onError: () => setIsVerified(false) }); - - d.useWhen(email, () => { - if (email) { - verifyEmail(email); - } - }); - return ( - - {isVerifying ? ( - - ) : ( - <> - - - )} + + ); } diff --git a/apps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.spec.tsx b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.spec.tsx index a2eaf5df5b..17b2a31016 100644 --- a/apps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.spec.tsx +++ b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.spec.tsx @@ -5,122 +5,75 @@ import { EmailVerificationContainer } from "./EmailVerificationContainer"; import { act, render } from "@testing-library/react"; -describe("EmailVerificationContainer", () => { - it("should render children with initial state", () => { +describe(EmailVerificationContainer.name, () => { + it("renders children with sendCode and verifyCode functions", () => { const { child } = setup(); expect(child).toHaveBeenCalledWith( expect.objectContaining({ - isEmailVerified: false, - isResending: false, - isChecking: false, - onResendEmail: expect.any(Function), - onCheckVerification: expect.any(Function), - onContinue: expect.any(Function) + sendCode: expect.any(Function), + verifyCode: expect.any(Function) }) ); }); - it("should handle resend email success", async () => { - const { child, mockSendVerificationEmail, mockEnqueueSnackbar } = setup(); - mockSendVerificationEmail.mockResolvedValue(undefined); - - const { onResendEmail } = child.mock.calls[0][0]; - await act(async () => { - await onResendEmail(); - }); + it("auto-advances when email is already verified", () => { + const mockOnComplete = vi.fn(); + setup({ user: { id: "test-user", emailVerified: true }, onComplete: mockOnComplete }); - expect(mockSendVerificationEmail).toHaveBeenCalledWith("test-user"); - expect(mockEnqueueSnackbar).toHaveBeenCalledWith( - expect.objectContaining({ - props: expect.objectContaining({ - title: "Verification email sent", - subTitle: "Please check your email and click the verification link", - iconVariant: "success" - }) - }), - { variant: "success" } - ); + expect(mockOnComplete).toHaveBeenCalled(); }); - it("should handle resend email error", async () => { - const { child, mockSendVerificationEmail, mockNotificator } = setup(); - mockSendVerificationEmail.mockRejectedValue(new Error("Failed")); + it("sendCode delegates to auth.sendVerificationCode", async () => { + const { child, mockSendVerificationCode } = setup(); - const { onResendEmail } = child.mock.calls[0][0]; - await act(async () => { - await onResendEmail(); - }); - - expect(mockSendVerificationEmail).toHaveBeenCalledWith("test-user"); - expect(mockNotificator.error).toHaveBeenCalledWith("Failed to send verification email. Please try again later"); - }); + mockSendVerificationCode.mockResolvedValue({}); - it("should handle check verification success", async () => { - const { child, mockCheckSession, mockEnqueueSnackbar } = setup(); - mockCheckSession.mockResolvedValue(undefined); - - const { onCheckVerification } = child.mock.calls[0][0]; + const { sendCode } = child.mock.calls[child.mock.calls.length - 1][0]; await act(async () => { - await onCheckVerification(); + await sendCode(); }); - expect(mockCheckSession).toHaveBeenCalled(); - expect(mockEnqueueSnackbar).toHaveBeenCalledWith( - expect.objectContaining({ - props: expect.objectContaining({ - title: "Verification status updated", - subTitle: "Your email verification status has been refreshed", - iconVariant: "success" - }) - }), - { variant: "success" } - ); + expect(mockSendVerificationCode).toHaveBeenCalled(); }); - it("should handle check verification error", async () => { - const { child, mockCheckSession, mockNotificator } = setup(); - mockCheckSession.mockRejectedValue(new Error("Failed")); + it("verifyCode delegates to auth.verifyEmailCode then calls checkSession", async () => { + const { child, mockVerifyEmailCode, mockCheckSession } = setup(); + mockVerifyEmailCode.mockResolvedValue(undefined); + mockCheckSession.mockResolvedValue(undefined); - const { onCheckVerification } = child.mock.calls[0][0]; + const { verifyCode } = child.mock.calls[0][0]; await act(async () => { - await onCheckVerification(); + await verifyCode("123456"); }); + expect(mockVerifyEmailCode).toHaveBeenCalledWith("123456"); expect(mockCheckSession).toHaveBeenCalled(); - expect(mockNotificator.error).toHaveBeenCalledWith("Failed to check verification. Please try again or refresh the page"); }); - it("should call onComplete when email is verified", () => { - const mockOnComplete = vi.fn(); - const { child } = setup({ - user: { id: "test-user", emailVerified: true }, - onComplete: mockOnComplete - }); + it("verifyCode propagates errors", async () => { + const { child, mockVerifyEmailCode } = setup(); + mockVerifyEmailCode.mockRejectedValue(new Error("Invalid verification code")); - const { onContinue } = child.mock.calls[0][0]; - onContinue(); - - expect(mockOnComplete).toHaveBeenCalled(); + const { verifyCode } = child.mock.calls[0][0]; + await expect( + act(async () => { + await verifyCode("000000"); + }) + ).rejects.toThrow("Invalid verification code"); }); - it("should not call onComplete when email is not verified", () => { + it("tracks analytics on auto-advance", () => { const mockOnComplete = vi.fn(); - const { child } = setup({ - user: { id: "test-user", emailVerified: false }, - onComplete: mockOnComplete - }); - - const { onContinue } = child.mock.calls[0][0]; - onContinue(); + const { mockAnalyticsService } = setup({ user: { id: "test-user", emailVerified: true }, onComplete: mockOnComplete }); - expect(mockOnComplete).not.toHaveBeenCalled(); + expect(mockAnalyticsService.track).toHaveBeenCalledWith("onboarding_email_verified", { category: "onboarding" }); }); - function setup(input: { user?: any; onComplete?: Mock } = {}) { - const mockSendVerificationEmail = vi.fn(); + function setup(input: { user?: { id: string; emailVerified: boolean }; onComplete?: Mock } = {}) { + const mockSendVerificationCode = vi.fn().mockResolvedValue({}); + const mockVerifyEmailCode = vi.fn(); const mockCheckSession = vi.fn(); - const mockEnqueueSnackbar = vi.fn(); const mockAnalyticsService = { track: vi.fn() }; @@ -130,30 +83,17 @@ describe("EmailVerificationContainer", () => { checkSession: mockCheckSession }); - const mockUseSnackbar = vi.fn().mockReturnValue({ - enqueueSnackbar: mockEnqueueSnackbar - }); - const mockUseServices = vi.fn().mockReturnValue({ analyticsService: mockAnalyticsService, auth: { - sendVerificationEmail: mockSendVerificationEmail + sendVerificationCode: mockSendVerificationCode, + verifyEmailCode: mockVerifyEmailCode } }); - const mockSnackbar = ({ title, subTitle, iconVariant }: any) => ( -
- ); - - const mockNotificator = { success: vi.fn(), error: vi.fn() }; - const mockUseNotificator = vi.fn().mockReturnValue(mockNotificator); - const dependencies = { useCustomUser: mockUseCustomUser, - useSnackbar: mockUseSnackbar, - useServices: mockUseServices, - Snackbar: mockSnackbar, - useNotificator: mockUseNotificator + useServices: mockUseServices }; const mockChildren = vi.fn().mockReturnValue(
Test
); @@ -167,10 +107,9 @@ describe("EmailVerificationContainer", () => { return { child: mockChildren, - mockSendVerificationEmail, + mockSendVerificationCode, + mockVerifyEmailCode, mockCheckSession, - mockEnqueueSnackbar, - mockNotificator, mockAnalyticsService }; } diff --git a/apps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.tsx b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.tsx index 4177c8957e..f60984814a 100644 --- a/apps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.tsx +++ b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.tsx @@ -1,93 +1,49 @@ "use client"; import type { FC, ReactNode } from "react"; -import React, { useCallback, useState } from "react"; -import { Snackbar } from "@akashnetwork/ui/components"; -import { useSnackbar } from "notistack"; +import React, { useCallback, useEffect } from "react"; import { useServices } from "@src/context/ServicesProvider"; import { useCustomUser } from "@src/hooks/useCustomUser"; -import { useNotificator } from "@src/hooks/useNotificator"; -const DEPENDENCIES = { +export const DEPENDENCIES = { useCustomUser, - useSnackbar, - useServices, - Snackbar, - useNotificator + useServices }; export type EmailVerificationContainerProps = { - children: (props: { - isEmailVerified: boolean; - isResending: boolean; - isChecking: boolean; - onResendEmail: () => void; - onCheckVerification: () => void; - onContinue: () => void; - }) => ReactNode; + children: (props: { sendCode: () => Promise; verifyCode: (code: string) => Promise }) => ReactNode; onComplete: () => void; dependencies?: typeof DEPENDENCIES; }; export const EmailVerificationContainer: FC = ({ children, onComplete, dependencies: d = DEPENDENCIES }) => { const { user, checkSession } = d.useCustomUser(); - const { enqueueSnackbar } = d.useSnackbar(); - const notificator = d.useNotificator(); - const [isResending, setIsResending] = useState(false); - const [isChecking, setIsChecking] = useState(false); const { analyticsService, auth } = d.useServices(); - const isEmailVerified = !!user?.emailVerified; + const advance = useCallback(() => { + analyticsService.track("onboarding_email_verified", { + category: "onboarding" + }); + onComplete(); + }, [analyticsService, onComplete]); - const handleResendEmail = useCallback(async () => { - if (!user?.id) return; - - setIsResending(true); - try { - await auth.sendVerificationEmail(user.id); - enqueueSnackbar(, { - variant: "success" - }); - } catch (error) { - notificator.error("Failed to send verification email. Please try again later"); - } finally { - setIsResending(false); + useEffect(() => { + if (user?.emailVerified) { + advance(); } - }, [user?.id, auth, enqueueSnackbar, d.Snackbar, notificator]); + }, [user?.emailVerified, advance]); - const handleCheckVerification = useCallback(async () => { - setIsChecking(true); - try { - await checkSession(); - enqueueSnackbar(, { - variant: "success" - }); - } catch (error) { - notificator.error("Failed to check verification. Please try again or refresh the page"); - } finally { - setIsChecking(false); - } - }, [checkSession, enqueueSnackbar, d.Snackbar, notificator]); + const sendCode = useCallback(async () => { + await auth.sendVerificationCode(); + }, [auth]); - const handleContinue = useCallback(() => { - if (isEmailVerified) { - analyticsService.track("onboarding_email_verified", { - category: "onboarding" - }); - onComplete(); - } - }, [isEmailVerified, analyticsService, onComplete]); - - return ( - <> - {children({ - isEmailVerified, - isResending, - isChecking, - onResendEmail: handleResendEmail, - onCheckVerification: handleCheckVerification, - onContinue: handleContinue - })} - + const verifyCode = useCallback( + async (code: string) => { + await auth.verifyEmailCode(code); + await checkSession(); + }, + [auth, checkSession] ); + + return <>{children({ sendCode, verifyCode })}; }; diff --git a/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.spec.tsx b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.spec.tsx new file mode 100644 index 0000000000..b0a4d79cd1 --- /dev/null +++ b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.spec.tsx @@ -0,0 +1,145 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { EmailVerificationStep } from "./EmailVerificationStep"; + +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +describe(EmailVerificationStep.name, () => { + it("renders 6 digit inputs", () => { + setup(); + + const inputs = screen.queryAllByRole("textbox"); + expect(inputs).toHaveLength(6); + }); + + it("renders verification title", () => { + setup(); + + expect(screen.queryByText("Email Verification")).toBeInTheDocument(); + }); + + it("renders code prompt text", () => { + setup(); + + expect(screen.queryByText("We've sent a 6-digit verification code to your email address.")).toBeInTheDocument(); + }); + + it("renders resend button as 'Resend Code' when idle", () => { + setup(); + + expect(screen.queryByText("Resend Code")).toBeInTheDocument(); + }); + + it("calls sendCode when resend button is clicked", async () => { + const { sendCode } = setup(); + + const buttons = screen.queryAllByRole("button"); + const resendButton = buttons.find(b => b.textContent?.includes("Resend Code"))!; + await act(async () => { + fireEvent.click(resendButton); + }); + + expect(sendCode).toHaveBeenCalled(); + }); + + it("shows success notification after resend", async () => { + const { mockNotificator } = setup(); + + const buttons = screen.queryAllByRole("button"); + const resendButton = buttons.find(b => b.textContent?.includes("Resend Code"))!; + await act(async () => { + fireEvent.click(resendButton); + }); + + expect(mockNotificator.success).toHaveBeenCalledWith("Verification code sent. Please check your email for the 6-digit code."); + }); + + it("shows error notification when resend fails", async () => { + const sendCode = vi.fn().mockRejectedValue(new Error("Network error")); + const { mockNotificator } = setup({ sendCode }); + + const buttons = screen.queryAllByRole("button"); + const resendButton = buttons.find(b => b.textContent?.includes("Resend Code"))!; + await act(async () => { + fireEvent.click(resendButton); + }); + + expect(mockNotificator.error).toHaveBeenCalledWith("Failed to send verification code. Please try again later"); + }); + + it("starts cooldown after successful resend", async () => { + setup(); + + const buttons = screen.queryAllByRole("button"); + const resendButton = buttons.find(b => b.textContent?.includes("Resend Code"))!; + await act(async () => { + fireEvent.click(resendButton); + }); + + await waitFor(() => { + expect(screen.queryByText("Resend Code (60s)")).toBeInTheDocument(); + }); + }); + + it("calls verifyCode when all 6 digits are entered", async () => { + const { verifyCode } = setup(); + + const inputs = screen.queryAllByRole("textbox"); + const user = userEvent.setup(); + + for (let i = 0; i < 6; i++) { + await user.click(inputs[i]); + await user.keyboard((i + 1).toString()); + } + + expect(verifyCode).toHaveBeenCalledWith("123456"); + }); + + it("calls verifyCode when OTP autofill injects all 6 digits into first input", async () => { + const { verifyCode } = setup(); + + const inputs = screen.queryAllByRole("textbox"); + await act(async () => { + fireEvent.change(inputs[0], { target: { value: "654321" } }); + }); + + expect(verifyCode).toHaveBeenCalledWith("654321"); + }); + + it("shows error notification when verify fails", async () => { + const verifyCode = vi.fn().mockRejectedValue(new Error("Invalid verification code")); + const { mockNotificator } = setup({ verifyCode }); + + const inputs = screen.queryAllByRole("textbox"); + await act(async () => { + fireEvent.change(inputs[0], { target: { value: "654321" } }); + }); + + await waitFor(() => { + expect(mockNotificator.error).toHaveBeenCalledWith("Invalid verification code"); + }); + }); + + function setup( + input: { + sendCode?: () => Promise; + verifyCode?: (code: string) => Promise; + } = {} + ) { + const sendCode = input.sendCode ?? vi.fn().mockResolvedValue(undefined); + const verifyCode = input.verifyCode ?? vi.fn().mockResolvedValue(undefined); + const mockNotificator = { success: vi.fn(), error: vi.fn() }; + const mockExtractErrorMessage = vi.fn((error: unknown) => (error instanceof Error ? error.message : "An error occurred. Please try again.")); + + const dependencies = { + useNotificator: () => mockNotificator, + extractErrorMessage: mockExtractErrorMessage + }; + + render(); + + return { sendCode, verifyCode, mockNotificator, mockExtractErrorMessage }; + } +}); diff --git a/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.tsx b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.tsx index acde9d669f..1e568f7c69 100644 --- a/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.tsx +++ b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.tsx @@ -1,53 +1,91 @@ "use client"; -import React from "react"; -import { Alert, Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@akashnetwork/ui/components"; -import { Check, Mail, Refresh } from "iconoir-react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Spinner } from "@akashnetwork/ui/components"; +import { Mail, Refresh } from "iconoir-react"; import { Title } from "@src/components/shared/Title"; +import { useNotificator } from "@src/hooks/useNotificator"; +import type { AppError } from "@src/types"; +import { extractErrorMessage } from "@src/utils/errorUtils"; +import type { VerificationCodeInputRef } from "./VerificationCodeInput"; +import { VerificationCodeInput } from "./VerificationCodeInput"; + +const COOLDOWN_DURATION = 60; + +export const DEPENDENCIES = { + useNotificator, + extractErrorMessage +}; interface EmailVerificationStepProps { - isEmailVerified: boolean; - isResending: boolean; - isChecking: boolean; - onResendEmail: () => void; - onCheckVerification: () => void; - onContinue: () => void; + sendCode: () => Promise; + verifyCode: (code: string) => Promise; + dependencies?: typeof DEPENDENCIES; } -export const EmailVerificationStep: React.FunctionComponent = ({ - isEmailVerified, - isResending, - isChecking, - onResendEmail, - onCheckVerification, - onContinue -}) => { +export const EmailVerificationStep: React.FunctionComponent = ({ sendCode, verifyCode, dependencies: d = DEPENDENCIES }) => { + const notificator = d.useNotificator(); + + const [isResending, setIsResending] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [cooldownSeconds, setCooldownSeconds] = useState(0); + const cooldownRef = useRef(0); + const isSendingRef = useRef(false); + const codeInputRef = useRef(null); + + useEffect(() => { + if (cooldownSeconds <= 0) return; + + const timer = setTimeout(() => { + const next = cooldownSeconds - 1; + cooldownRef.current = next; + setCooldownSeconds(next); + }, 1000); + + return () => clearTimeout(timer); + }, [cooldownSeconds]); + + const handleResendCode = useCallback(async () => { + if (isSendingRef.current || cooldownRef.current > 0) return; + + isSendingRef.current = true; + setIsResending(true); + codeInputRef.current?.reset(); + + try { + await sendCode(); + cooldownRef.current = COOLDOWN_DURATION; + setCooldownSeconds(COOLDOWN_DURATION); + notificator.success("Verification code sent. Please check your email for the 6-digit code."); + } catch { + notificator.error("Failed to send verification code. Please try again later"); + } finally { + isSendingRef.current = false; + setIsResending(false); + } + }, [sendCode, notificator]); + + const handleVerifyCode = useCallback( + async (code: string) => { + setIsVerifying(true); + + try { + await verifyCode(code); + notificator.success("Your email has been successfully verified"); + } catch (error) { + notificator.error(d.extractErrorMessage(error as AppError)); + codeInputRef.current?.reset(); + } finally { + setIsVerifying(false); + } + }, + [verifyCode, notificator, d] + ); + return (
Verify Your Email - {isEmailVerified ? ( - -
- -
-
-

Email Verified

-

Your email has been successfully verified.

-
-
- ) : ( - -
- -
-
-

Email Verification Required

-

Please verify your email address to continue.

-
-
- )} -
@@ -56,29 +94,31 @@ export const EmailVerificationStep: React.FunctionComponent
Email Verification - - {isEmailVerified ? "Your email has been verified successfully." : "We've sent a verification link to your email address."} - + We've sent a 6-digit verification code to your email address.
- {!isEmailVerified ? ( - <> -

Didn't receive the email? Check your spam folder or request a new verification email.

-
- - -
- - ) : ( - - )} + + +

Didn't receive the code? Check your spam folder or request a new one.

+ +
diff --git a/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/VerificationCodeInput.spec.tsx b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/VerificationCodeInput.spec.tsx new file mode 100644 index 0000000000..3576dcb18b --- /dev/null +++ b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/VerificationCodeInput.spec.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import type { VerificationCodeInputRef } from "./VerificationCodeInput"; +import { VerificationCodeInput } from "./VerificationCodeInput"; + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +describe(VerificationCodeInput.name, () => { + it("renders 6 digit inputs", () => { + setup(); + + const inputs = screen.queryAllByRole("textbox"); + expect(inputs).toHaveLength(6); + }); + + it("renders inputs with correct aria labels", () => { + setup(); + + for (let i = 1; i <= 6; i++) { + expect(screen.queryByLabelText(`Verification code digit ${i}`)).toBeInTheDocument(); + } + }); + + it("sets autoComplete='one-time-code' on first input only", () => { + setup(); + + const inputs = screen.queryAllByRole("textbox"); + expect(inputs[0]).toHaveAttribute("autoComplete", "one-time-code"); + expect(inputs[1]).toHaveAttribute("autoComplete", "off"); + }); + + it("calls onComplete when all 6 digits are entered", async () => { + const { onComplete } = setup(); + const user = userEvent.setup(); + + const inputs = screen.queryAllByRole("textbox"); + for (let i = 0; i < 6; i++) { + await user.click(inputs[i]); + await user.keyboard((i + 1).toString()); + } + + expect(onComplete).toHaveBeenCalledWith("123456"); + }); + + it("calls onComplete when OTP autofill injects all 6 digits into first input", async () => { + const { onComplete } = setup(); + + const inputs = screen.queryAllByRole("textbox"); + await act(async () => { + fireEvent.change(inputs[0], { target: { value: "654321" } }); + }); + + expect(onComplete).toHaveBeenCalledWith("654321"); + }); + + it("does not call onComplete for non-numeric input", async () => { + const { onComplete } = setup(); + const user = userEvent.setup(); + + const inputs = screen.queryAllByRole("textbox"); + await user.click(inputs[0]); + await user.keyboard("abcdef"); + + expect(onComplete).not.toHaveBeenCalled(); + }); + + it("disables all inputs when disabled prop is true", () => { + setup({ disabled: true }); + + const inputs = screen.queryAllByRole("textbox"); + inputs.forEach(input => { + expect(input).toBeDisabled(); + }); + }); + + it("resets digits when reset is called via ref", async () => { + const ref = React.createRef(); + const { onComplete } = setup({ ref }); + + const inputs = screen.queryAllByRole("textbox"); + await act(async () => { + fireEvent.change(inputs[0], { target: { value: "654321" } }); + }); + + expect(onComplete).toHaveBeenCalledWith("654321"); + onComplete.mockClear(); + + act(() => { + ref.current?.reset(); + }); + + inputs.forEach(input => { + expect(input).toHaveValue(""); + }); + }); + + it("allows re-submitting the same code after reset", async () => { + const ref = React.createRef(); + const { onComplete } = setup({ ref }); + + const inputs = screen.queryAllByRole("textbox"); + await act(async () => { + fireEvent.change(inputs[0], { target: { value: "654321" } }); + }); + + expect(onComplete).toHaveBeenCalledWith("654321"); + onComplete.mockClear(); + + act(() => { + ref.current?.reset(); + }); + + await act(async () => { + fireEvent.change(inputs[0], { target: { value: "654321" } }); + }); + + expect(onComplete).toHaveBeenCalledWith("654321"); + }); + + function setup(input: { onComplete?: (code: string) => void; disabled?: boolean; ref?: React.Ref } = {}) { + const onComplete = (input.onComplete as ReturnType) ?? vi.fn(); + + render(); + + return { onComplete }; + } +}); diff --git a/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/VerificationCodeInput.tsx b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/VerificationCodeInput.tsx new file mode 100644 index 0000000000..f1a7f972ca --- /dev/null +++ b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/VerificationCodeInput.tsx @@ -0,0 +1,132 @@ +"use client"; +import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { Input } from "@akashnetwork/ui/components"; + +const CODE_LENGTH = 6; + +export interface VerificationCodeInputRef { + reset: () => void; + focus: () => void; +} + +interface VerificationCodeInputProps { + onComplete: (code: string) => void; + disabled?: boolean; +} + +export const VerificationCodeInput = React.forwardRef(({ onComplete, disabled = false }, ref) => { + const [digits, setDigits] = useState(Array(CODE_LENGTH).fill("")); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + const submittedCodeRef = useRef(null); + + const reset = useCallback(() => { + setDigits(Array(CODE_LENGTH).fill("")); + submittedCodeRef.current = null; + inputRefs.current[0]?.focus(); + }, []); + + useImperativeHandle( + ref, + () => ({ + reset, + focus: () => inputRefs.current[0]?.focus() + }), + [reset] + ); + + useEffect(() => { + const code = digits.join(""); + if (code.length === CODE_LENGTH) { + if (submittedCodeRef.current !== code) { + submittedCodeRef.current = code; + onComplete(code); + } + } else { + submittedCodeRef.current = null; + } + }, [digits, onComplete]); + + const handleDigitChange = useCallback( + (index: number, value: string) => { + if (!/^\d*$/.test(value) || disabled) return; + + if (value.length > 1) { + const filled = value.slice(0, CODE_LENGTH - index); + setDigits(prev => { + const newDigits = [...prev]; + for (let i = 0; i < filled.length; i++) { + newDigits[index + i] = filled[i]; + } + return newDigits; + }); + const nextIndex = index + filled.length; + if (nextIndex < CODE_LENGTH) { + inputRefs.current[nextIndex]?.focus(); + } + return; + } + + setDigits(prev => { + const newDigits = [...prev]; + newDigits[index] = value; + return newDigits; + }); + if (value && index < CODE_LENGTH - 1) { + inputRefs.current[index + 1]?.focus(); + } + }, + [disabled] + ); + + const handleKeyDown = useCallback((index: number, e: React.KeyboardEvent) => { + const currentDigits = inputRefs.current[index]?.value ?? ""; + if (e.key === "Backspace" && !currentDigits && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }, []); + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + e.preventDefault(); + if (disabled) return; + + const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, CODE_LENGTH); + if (!pasted) return; + + const newDigits = Array(CODE_LENGTH).fill(""); + for (let i = 0; i < CODE_LENGTH; i++) { + newDigits[i] = pasted[i] || ""; + } + setDigits(newDigits); + + if (pasted.length < CODE_LENGTH) { + inputRefs.current[pasted.length]?.focus(); + } + }, + [disabled] + ); + + return ( +
+ {digits.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + aria-label={`Verification code digit ${index + 1}`} + autoComplete={index === 0 ? "one-time-code" : "off"} + inputMode="numeric" + value={digit} + onChange={e => handleDigitChange(index, e.target.value)} + onKeyDown={e => handleKeyDown(index, e)} + className="h-12 w-12" + inputClassName="text-center text-lg font-semibold" + disabled={disabled} + /> + ))} +
+ ); +}); +VerificationCodeInput.displayName = "VerificationCodeInput"; diff --git a/apps/deploy-web/src/hooks/useEmailVerificationRequiredEventHandler.tsx b/apps/deploy-web/src/hooks/useEmailVerificationRequiredEventHandler.tsx index f3cb691910..851f0ee58b 100644 --- a/apps/deploy-web/src/hooks/useEmailVerificationRequiredEventHandler.tsx +++ b/apps/deploy-web/src/hooks/useEmailVerificationRequiredEventHandler.tsx @@ -21,7 +21,7 @@ export const useEmailVerificationRequiredEventHandler = (): ((messageOtherwise: message: messageOtherwise, actions: ({ close }) => [ { - label: "Resend verification email", + label: "Send verification code", side: "left", size: "lg", onClick: () => { @@ -31,17 +31,21 @@ export const useEmailVerificationRequiredEventHandler = (): ((messageOtherwise: } auth - .sendVerificationEmail(user.id) + .sendVerificationCode() .then(() => { enqueueSnackbar( - , + , { variant: "success" } ); }) .catch(() => { - enqueueSnackbar(, { + enqueueSnackbar(, { variant: "error" }); }) @@ -54,6 +58,6 @@ export const useEmailVerificationRequiredEventHandler = (): ((messageOtherwise: return user?.emailVerified ? handler : preventer; }, - [user?.emailVerified, user?.id, requireAction, enqueueSnackbar] + [user?.emailVerified, user?.id, requireAction, enqueueSnackbar, auth, analyticsService] ); }; diff --git a/apps/deploy-web/src/services/session/session.service.spec.ts b/apps/deploy-web/src/services/session/session.service.spec.ts index 14013bdd71..13e2cc29e8 100644 --- a/apps/deploy-web/src/services/session/session.service.spec.ts +++ b/apps/deploy-web/src/services/session/session.service.spec.ts @@ -104,14 +104,12 @@ describe(SessionService.name, () => { }); describe("signUp", () => { - it("returns error when password violates policy", async () => { - const { service, externalHttpClient, consoleApiHttpClient, config } = setup(); + it("returns error when signup fails", async () => { + const { service, consoleApiHttpClient, externalHttpClient } = setup(); - externalHttpClient.post.mockResolvedValueOnce({ + consoleApiHttpClient.post.mockResolvedValueOnce({ status: 400, data: { - code: "invalid_password", - policy: "Password must contain at least 8 characters", message: "Password is too weak" }, headers: {} @@ -124,71 +122,36 @@ describe(SessionService.name, () => { expect(error).toEqual( expect.objectContaining({ message: "Password is too weak", - code: "invalid_password", - policy: "Password must contain at least 8 characters" + code: "signup_failed" }) ); - expect(externalHttpClient.post).toHaveBeenCalledTimes(1); - expect(externalHttpClient.post).toHaveBeenCalledWith( - `${new URL(config.ISSUER_BASE_URL).origin}/dbconnections/signup`, + expect(consoleApiHttpClient.post).toHaveBeenCalledTimes(1); + expect(consoleApiHttpClient.post).toHaveBeenCalledWith( + "/v1/auth/signup", { - client_id: config.CLIENT_ID, email: "user@example.com", - password: "weak", - connection: "Username-Password-Authentication" + password: "weak" }, expect.objectContaining({ validateStatus: expect.any(Function) }) ); - expect(consoleApiHttpClient.post).not.toHaveBeenCalled(); + expect(externalHttpClient.post).not.toHaveBeenCalled(); expect(externalHttpClient.get).not.toHaveBeenCalled(); }); - it("returns friendly_message as error message when present", async () => { - const { service, externalHttpClient, consoleApiHttpClient, config } = setup(); + it("returns user_exists when user already exists and sign-in fails", async () => { + const { service, consoleApiHttpClient, externalHttpClient } = setup(); - externalHttpClient.post.mockResolvedValueOnce({ - status: 400, + consoleApiHttpClient.post.mockResolvedValueOnce({ + status: 409, data: { - friendly_message: "This is a user-friendly error message", - message: "Technical error message", - description: "Error description" + message: "The user already exists." }, headers: {} }); - const result = await service.signUp({ email: "user@example.com", password: "Password123!" }); - - expect(result.ok).toBe(false); - const error = expectErr(result); - expect(error).toEqual( - expect.objectContaining({ - message: "This is a user-friendly error message", - code: "signup_failed" - }) - ); - expect(externalHttpClient.post).toHaveBeenCalledTimes(1); - expect(externalHttpClient.post).toHaveBeenCalledWith( - `${new URL(config.ISSUER_BASE_URL).origin}/dbconnections/signup`, - { - client_id: config.CLIENT_ID, - email: "user@example.com", - password: "Password123!", - connection: "Username-Password-Authentication" - }, - expect.objectContaining({ validateStatus: expect.any(Function) }) - ); - expect(consoleApiHttpClient.post).not.toHaveBeenCalled(); - expect(externalHttpClient.get).not.toHaveBeenCalled(); - }); - - it("returns error when signup fails for other reasons", async () => { - const { service, externalHttpClient, consoleApiHttpClient, config } = setup(); - - externalHttpClient.post.mockResolvedValue({ - status: 409, - data: { - description: "User already exists" - }, + externalHttpClient.post.mockResolvedValueOnce({ + status: 401, + data: { error_description: "Invalid credentials" }, headers: {} }); @@ -202,19 +165,8 @@ describe(SessionService.name, () => { code: "user_exists" }) ); - expect(externalHttpClient.post).toHaveBeenCalledTimes(2); - expect(externalHttpClient.post).toHaveBeenCalledWith( - `${new URL(config.ISSUER_BASE_URL).origin}/dbconnections/signup`, - { - client_id: config.CLIENT_ID, - email: "user@example.com", - password: "Password123!", - connection: "Username-Password-Authentication" - }, - expect.objectContaining({ validateStatus: expect.any(Function) }) - ); - expect(consoleApiHttpClient.post).not.toHaveBeenCalled(); - expect(externalHttpClient.get).not.toHaveBeenCalled(); + expect(consoleApiHttpClient.post).toHaveBeenCalledTimes(1); + expect(externalHttpClient.post).toHaveBeenCalledTimes(1); }); it("creates local user after successful signup", async () => { @@ -257,9 +209,9 @@ describe(SessionService.name, () => { subscribedToNewsletter: true }; - const { service, externalHttpClient, consoleApiHttpClient, config } = setup(); + const { service, externalHttpClient, consoleApiHttpClient } = setup(); - externalHttpClient.post.mockResolvedValueOnce(signupResponse); + consoleApiHttpClient.post.mockResolvedValueOnce(signupResponse); externalHttpClient.post.mockResolvedValueOnce(tokenResponse); externalHttpClient.get.mockResolvedValueOnce(userInfoResponse); consoleApiHttpClient.post.mockResolvedValueOnce({ data: { data: createdUser } }); @@ -269,30 +221,28 @@ describe(SessionService.name, () => { expect(result.ok).toBe(true); const session = expectOk(result); - expect(externalHttpClient.post).toHaveBeenNthCalledWith( + expect(consoleApiHttpClient.post).toHaveBeenNthCalledWith( 1, - `${new URL(config.ISSUER_BASE_URL).origin}/dbconnections/signup`, + "/v1/auth/signup", { - client_id: config.CLIENT_ID, email, - password, - connection: "Username-Password-Authentication" + password }, expect.objectContaining({ validateStatus: expect.any(Function) }) ); expect(externalHttpClient.post).toHaveBeenNthCalledWith( - 2, - `${new URL(config.ISSUER_BASE_URL).origin}/oauth/token`, + 1, + expect.stringContaining("/oauth/token"), expect.objectContaining({ username: email, - password, - audience: config.AUDIENCE + password }), expect.objectContaining({ validateStatus: expect.any(Function) }) ); - expect(consoleApiHttpClient.post).toHaveBeenCalledWith( + expect(consoleApiHttpClient.post).toHaveBeenNthCalledWith( + 2, "/v1/register-user", { wantedUsername: tokenPayload.nickname, diff --git a/apps/deploy-web/src/services/session/session.service.ts b/apps/deploy-web/src/services/session/session.service.ts index 36ad59f229..8edf5002ea 100644 --- a/apps/deploy-web/src/services/session/session.service.ts +++ b/apps/deploy-web/src/services/session/session.service.ts @@ -81,43 +81,22 @@ export class SessionService { async signUp(input: { email: string; password: string; - }): Promise< - Result< - Session, - | { code: "invalid_password"; message: string; policy: string; cause: unknown } - | { code: "user_exists"; message: string; cause: unknown } - | { code: "signup_failed"; message: string; cause: unknown } - > - > { - const oauthIssuerUrl = new URL(this.#config.ISSUER_BASE_URL); - - // https://auth0.com/docs/api/authentication/signup/create-a-new-user - const signupResponse = await this.#externalHttpClient.post( - `${oauthIssuerUrl.origin}/dbconnections/signup`, + }): Promise> { + const signupResponse = await this.#consoleApiHttpClient.post( + "/v1/auth/signup", { - client_id: this.#config.CLIENT_ID, email: input.email, - password: input.password, - connection: "Username-Password-Authentication" + password: input.password }, { validateStatus: notServerError } ); - if (signupResponse.status === 400 && signupResponse.data.code === "invalid_password" && signupResponse.data.policy) { - return Err({ - message: signupResponse.data.message || signupResponse.data.description || "Password violates policy", - code: "invalid_password", - policy: signupResponse.data.policy, - cause: extractResponseDetails(signupResponse) - }); - } - - const isUserExists = signupResponse.status === 409 || (signupResponse.status === 400 && signupResponse.data.code === "invalid_signup"); + const isUserExists = signupResponse.status === 409; if (signupResponse.status >= 400 && !isUserExists) { return Err({ - message: signupResponse.data.friendly_message || signupResponse.data.message || signupResponse.data.description || "Signup failed", + message: signupResponse.data?.message || "Signup failed", code: "signup_failed", cause: extractResponseDetails(signupResponse) }); diff --git a/apps/notifications/src/modules/notifications/services/email-sender/email-sender.service.ts b/apps/notifications/src/modules/notifications/services/email-sender/email-sender.service.ts index 2bbeafa19e..1acd0a0e55 100644 --- a/apps/notifications/src/modules/notifications/services/email-sender/email-sender.service.ts +++ b/apps/notifications/src/modules/notifications/services/email-sender/email-sender.service.ts @@ -36,7 +36,7 @@ export class EmailSenderService { payload: { subject, content: sanitizeHtml(content, { - allowedTags: ["a"], + allowedTags: ["a", "strong"], allowedAttributes: { a: ["href"] } diff --git a/packages/http-sdk/src/auth/auth-http.service.ts b/packages/http-sdk/src/auth/auth-http.service.ts index e3756b6d29..8aca6d7614 100644 --- a/packages/http-sdk/src/auth/auth-http.service.ts +++ b/packages/http-sdk/src/auth/auth-http.service.ts @@ -1,17 +1,26 @@ import type { AxiosRequestConfig } from "axios"; import { HttpService } from "../http/http.service"; -import type { VerifyEmailResponse } from "./auth-http.types"; +import type { SendVerificationCodeResponse, VerifyEmailResponse } from "./auth-http.types"; export class AuthHttpService extends HttpService { constructor(config?: Pick) { super(config); } + /** @deprecated Use {@link sendVerificationCode} instead. This targets the legacy link-based verification endpoint. */ async sendVerificationEmail(userId: string) { return this.post("/v1/send-verification-email", { data: { userId } }); } + async sendVerificationCode() { + return this.extractData(await this.post("/v1/send-verification-code")); + } + + async verifyEmailCode(code: string) { + await this.post("/v1/verify-email-code", { data: { code } }); + } + async verifyEmail(email: string) { return this.extractData(await this.post("/v1/verify-email", { data: { email } }, { withCredentials: true })); } diff --git a/packages/http-sdk/src/auth/auth-http.types.ts b/packages/http-sdk/src/auth/auth-http.types.ts index 69de25797c..28101a2e45 100644 --- a/packages/http-sdk/src/auth/auth-http.types.ts +++ b/packages/http-sdk/src/auth/auth-http.types.ts @@ -7,3 +7,11 @@ export const VerifyEmailResponseSchema = z.object({ }); export type VerifyEmailResponse = z.infer; + +export const SendVerificationCodeResponseSchema = z.object({ + data: z.object({ + codeSentAt: z.string() + }) +}); + +export type SendVerificationCodeResponse = z.infer; diff --git a/packages/ui/components/custom/snackbar.tsx b/packages/ui/components/custom/snackbar.tsx index e5ef4afc74..d52e1bd1dd 100644 --- a/packages/ui/components/custom/snackbar.tsx +++ b/packages/ui/components/custom/snackbar.tsx @@ -11,7 +11,6 @@ type Props = { subTitle?: string | ReactNode; iconVariant?: IconVariant; showLoading?: boolean; - children?: ReactNode; ["data-testid"]?: string; }; @@ -31,7 +30,7 @@ export const Snackbar: React.FunctionComponent = ({ title, subTitle, icon
{title}
- {subTitle &&

{subTitle}

} + {subTitle &&
{subTitle}
} ); };