From f4567382730cfb28a73925b5b7ec57ddde8bf270 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:19:28 -0500 Subject: [PATCH 01/24] feat(auth): add email verification code flow with cooldown enforcement Implements server-side email verification via 6-digit codes with: - Advisory lock to prevent race conditions on concurrent send requests - 60s cooldown enforced both client-side (cooldownRef guard) and server-side - Timing-safe code comparison with max 5 attempts - Bold code rendering in email by allowing in sanitizer --- apps/api/drizzle/0029_fluffy_drax.sql | 18 + apps/api/drizzle/meta/0029_snapshot.json | 1290 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + .../auth/controllers/auth/auth.controller.ts | 27 +- .../email-verification-code.schema.ts | 26 + apps/api/src/auth/model-schemas/index.ts | 1 + .../email-verification-code.repository.ts | 63 + apps/api/src/auth/routes/index.ts | 2 + .../send-verification-code.router.ts | 57 + .../verify-email-code.router.ts | 59 + .../src/auth/services/auth0/auth0.service.ts | 4 + .../email-verification-code.service.spec.ts | 210 +++ .../email-verification-code.service.ts | 90 ++ .../email-verification-code-notification.ts | 17 + apps/api/src/rest-app.ts | 4 +- .../VerifyEmailPage/VerifyEmailPage.spec.tsx | 83 +- .../VerifyEmailPage/VerifyEmailPage.tsx | 74 +- .../EmailVerificationContainer.spec.tsx | 137 +- .../EmailVerificationContainer.tsx | 130 +- .../EmailVerificationStep.tsx | 130 +- ...eEmailVerificationRequiredEventHandler.tsx | 12 +- .../email-sender/email-sender.service.ts | 2 +- .../http-sdk/src/auth/auth-http.service.ts | 10 +- packages/http-sdk/src/auth/auth-http.types.ts | 16 + 24 files changed, 2241 insertions(+), 228 deletions(-) create mode 100644 apps/api/drizzle/0029_fluffy_drax.sql create mode 100644 apps/api/drizzle/meta/0029_snapshot.json create mode 100644 apps/api/src/auth/model-schemas/email-verification-code/email-verification-code.schema.ts create mode 100644 apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts create mode 100644 apps/api/src/auth/routes/send-verification-code/send-verification-code.router.ts create mode 100644 apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts create mode 100644 apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts create mode 100644 apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts create mode 100644 apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts diff --git a/apps/api/drizzle/0029_fluffy_drax.sql b/apps/api/drizzle/0029_fluffy_drax.sql new file mode 100644 index 0000000000..2c126b91e1 --- /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(6) 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..7d66e30509 --- /dev/null +++ b/apps/api/drizzle/meta/0029_snapshot.json @@ -0,0 +1,1290 @@ +{ + "id": "84347453-dc3f-4b93-bc6b-b1b3ff4fc21b", + "prevId": "54b9ce43-1f54-40bf-81e5-1eabf3e71a66", + "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(6)", + "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.ts b/apps/api/src/auth/controllers/auth/auth.controller.ts index 865d49a616..fe4e2cd236 100644 --- a/apps/api/src/auth/controllers/auth/auth.controller.ts +++ b/apps/api/src/auth/controllers/auth/auth.controller.ts @@ -1,9 +1,11 @@ import { singleton } from "tsyringe"; -import type { SendVerificationEmailRequestInput } from "@src/auth"; +import type { SendVerificationCodeRequest, SendVerificationEmailRequestInput } from "@src/auth"; import { VerifyEmailRequest } from "@src/auth/http-schemas/verify-email.schema"; +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 { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; import { UserService } from "@src/user/services/user/user.service"; @singleton() @@ -11,7 +13,8 @@ export class AuthController { constructor( private readonly authService: AuthService, private readonly auth0: Auth0Service, - private readonly userService: UserService + private readonly userService: UserService, + private readonly emailVerificationCodeService: EmailVerificationCodeService ) {} @Protected() @@ -24,6 +27,26 @@ export class AuthController { } } + @Protected() + async sendVerificationCode({ data: { userId } }: SendVerificationCodeRequest) { + const { currentUser } = this.authService; + this.authService.throwUnlessCan("create", "VerificationEmail", { id: userId }); + + const result = await this.emailVerificationCodeService.sendCode(currentUser!.id); + + return { data: result }; + } + + @Protected() + async verifyEmailCode({ data: { userId, code } }: VerifyEmailCodeRequest) { + const { currentUser } = this.authService; + this.authService.throwUnlessCan("create", "VerificationEmail", { id: userId }); + + const result = await this.emailVerificationCodeService.verifyCode(currentUser!.id, code); + + return { data: result }; + } + 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..4f68744bee --- /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: 6 }).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..b3f2f855a3 --- /dev/null +++ b/apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts @@ -0,0 +1,63 @@ +import { and, 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 findActiveByUserId(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()`))) + .limit(1); + + return result ? this.toOutput(result) : undefined; + } + + async acquireUserLock(userId: string): Promise { + await this.cursor.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${userId}))`); + } + + async incrementAttempts(id: string): Promise { + await this.cursor + .update(this.table) + .set({ attempts: sql`${this.table.attempts} + 1` }) + .where(eq(this.table.id, id)); + } + + async deleteByUserId(userId: string): Promise { + await this.cursor.delete(this.table).where(eq(this.table.userId, userId)); + } + + 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..01a556a203 100644 --- a/apps/api/src/auth/routes/index.ts +++ b/apps/api/src/auth/routes/index.ts @@ -1,2 +1,4 @@ export * from "./send-verification-email/send-verification-email.router"; +export * from "./send-verification-code/send-verification-code.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..6668e241a9 --- /dev/null +++ b/apps/api/src/auth/routes/send-verification-code/send-verification-code.router.ts @@ -0,0 +1,57 @@ +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 sendVerificationCodeRouter = new OpenApiHonoHandler(); + +const SendVerificationCodeRequestSchema = z.object({ + data: z.object({ + userId: z.string() + }) +}); + +export type SendVerificationCodeRequest = z.infer; + +const SendVerificationCodeResponseSchema = z.object({ + data: z.object({ + codeSentAt: z.string() + }) +}); + +const route = createRoute({ + method: "post", + path: "/v1/send-verification-code", + summary: "Sends a verification code to the user's email", + tags: ["Users"], + security: SECURITY_BEARER, + request: { + body: { + required: true, + content: { + "application/json": { + schema: SendVerificationCodeRequestSchema + } + } + } + }, + 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(c.req.valid("json")); + return c.json(result, 200); +}); 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..27bae474e5 --- /dev/null +++ b/apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts @@ -0,0 +1,59 @@ +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({ + userId: z.string(), + code: z.string().length(6) + }) +}); + +export type VerifyEmailCodeRequest = z.infer; + +const VerifyEmailCodeResponseSchema = z.object({ + data: z.object({ + emailVerified: z.boolean() + }) +}); + +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: { + 200: { + description: "Returns the email verification status", + content: { + "application/json": { + schema: VerifyEmailCodeResponseSchema + } + } + }, + 400: { description: "Invalid or expired code" }, + 429: { description: "Too many attempts" } + } +}); + +verifyEmailCodeRouter.openapi(route, async function verifyEmailCode(c) { + const result = await container.resolve(AuthController).verifyEmailCode(c.req.valid("json")); + return c.json(result, 200); +}); diff --git a/apps/api/src/auth/services/auth0/auth0.service.ts b/apps/api/src/auth/services/auth0/auth0.service.ts index 80e265c19c..f02784fd11 100644 --- a/apps/api/src/auth/services/auth0/auth0.service.ts +++ b/apps/api/src/auth/services/auth0/auth0.service.ts @@ -9,6 +9,10 @@ export class Auth0Service { 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..1c588b1445 --- /dev/null +++ b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts @@ -0,0 +1,210 @@ +import "@test/mocks/logger-service.mock"; + +import { faker } from "@faker-js/faker"; +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("acquires advisory lock before checking for existing code", async () => { + const user = UserSeeder.create({ email: "test@example.com" }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + const callOrder: string[] = []; + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.acquireUserLock.mockImplementation(async () => { + callOrder.push("acquireUserLock"); + }); + emailVerificationCodeRepository.findActiveByUserId.mockImplementation(async () => { + callOrder.push("findActiveByUserId"); + return undefined; + }); + emailVerificationCodeRepository.create.mockResolvedValue({} as any); + + await service.sendCode(user.id); + + expect(callOrder).toEqual(["acquireUserLock", "findActiveByUserId"]); + }); + + it("returns existing codeSentAt when cooldown is active", async () => { + const user = UserSeeder.create({ email: "test@example.com" }); + const existingCode = createVerificationCodeOutput({ + userId: user.id, + createdAt: new Date(Date.now() - 10_000).toISOString() + }); + const { service, emailVerificationCodeRepository, userRepository, notificationService } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserId.mockResolvedValue(existingCode); + + const result = await service.sendCode(user.id); + + expect(result).toEqual({ codeSentAt: existingCode.createdAt }); + expect(emailVerificationCodeRepository.deleteByUserId).not.toHaveBeenCalled(); + expect(emailVerificationCodeRepository.create).not.toHaveBeenCalled(); + expect(notificationService.createNotification).not.toHaveBeenCalled(); + }); + + it("sends new code when cooldown has expired", async () => { + const user = UserSeeder.create({ email: "test@example.com" }); + const expiredCode = createVerificationCodeOutput({ + userId: user.id, + createdAt: new Date(Date.now() - 61_000).toISOString() + }); + const { service, emailVerificationCodeRepository, userRepository, notificationService } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserId.mockResolvedValue(expiredCode); + emailVerificationCodeRepository.create.mockResolvedValue({} as any); + + const result = await service.sendCode(user.id); + + expect(emailVerificationCodeRepository.deleteByUserId).toHaveBeenCalledWith(user.id); + expect(emailVerificationCodeRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: user.id, + email: user.email, + code: expect.stringMatching(/^\d{6}$/) + }) + ); + expect(notificationService.createNotification).toHaveBeenCalled(); + expect(result.codeSentAt).toBeDefined(); + }); + + it("sends new code when no existing code found", async () => { + const user = UserSeeder.create({ email: "test@example.com" }); + const { service, emailVerificationCodeRepository, userRepository, notificationService } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserId.mockResolvedValue(undefined); + emailVerificationCodeRepository.create.mockResolvedValue({} as any); + + await service.sendCode(user.id); + + expect(emailVerificationCodeRepository.deleteByUserId).toHaveBeenCalledWith(user.id); + expect(emailVerificationCodeRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: user.id, + email: user.email, + code: expect.stringMatching(/^\d{6}$/) + }) + ); + expect(notificationService.createNotification).toHaveBeenCalled(); + }); + + 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, attempts: 0 }); + const { service, emailVerificationCodeRepository, userRepository, auth0Service } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserId.mockResolvedValue(record); + + const result = await service.verifyCode(user.id, code); + + expect(result).toEqual({ emailVerified: true }); + expect(auth0Service.markEmailVerified).toHaveBeenCalledWith(user.userId); + expect(userRepository.updateById).toHaveBeenCalledWith(user.id, { emailVerified: true }); + expect(emailVerificationCodeRepository.deleteByUserId).toHaveBeenCalledWith(user.id); + }); + + it("rejects invalid code and increments attempts", async () => { + const user = UserSeeder.create({ userId: "auth0|123" }); + const record = createVerificationCodeOutput({ userId: user.id, code: "123456", attempts: 0 }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserId.mockResolvedValue(record); + + await expect(service.verifyCode(user.id, "999999")).rejects.toThrow(); + 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: "123456", attempts: 5 }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserId.mockResolvedValue(record); + + await expect(service.verifyCode(user.id, "123456")).rejects.toThrow(); + expect(emailVerificationCodeRepository.incrementAttempts).not.toHaveBeenCalled(); + }); + + 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.findActiveByUserId.mockResolvedValue(undefined); + + await expect(service.verifyCode(user.id, "123456")).rejects.toThrow(); + }); + }); + + function createVerificationCodeOutput(overrides: Partial = {}): EmailVerificationCodeOutput { + return { + id: faker.string.uuid(), + userId: faker.string.uuid(), + email: faker.internet.email(), + code: faker.string.numeric(6), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + attempts: 0, + createdAt: new Date().toISOString(), + ...overrides + }; + } + + function setup() { + const emailVerificationCodeRepository = mock(); + const auth0Service = mock(); + const notificationService = mock(); + const userRepository = mock(); + const logger = mock(); + + emailVerificationCodeRepository.acquireUserLock.mockResolvedValue(undefined); + + 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..852dbed506 --- /dev/null +++ b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts @@ -0,0 +1,90 @@ +import { 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 RESEND_COOLDOWN_MS = 60 * 1000; + +@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 + ) {} + + @WithTransaction() + 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"); + + await this.emailVerificationCodeRepository.acquireUserLock(userInternalId); + + const existing = await this.emailVerificationCodeRepository.findActiveByUserId(userInternalId); + + if (existing) { + const createdAt = new Date(existing.createdAt).getTime(); + const cooldownEnd = createdAt + RESEND_COOLDOWN_MS; + + if (Date.now() < cooldownEnd) { + return { codeSentAt: existing.createdAt }; + } + } + + await this.emailVerificationCodeRepository.deleteByUserId(userInternalId); + + const code = randomInt(100000, 999999).toString(); + const expiresAt = new Date(Date.now() + CODE_EXPIRY_MS); + + await this.emailVerificationCodeRepository.create({ + userId: userInternalId, + email: user.email, + code, + expiresAt + }); + + await this.notificationService.createNotification(emailVerificationCodeNotification({ id: userInternalId, email: user.email }, { code })); + + this.logger.info({ event: "VERIFICATION_CODE_SENT", userId: userInternalId }); + + return { codeSentAt: new Date().toISOString() }; + } + + @WithTransaction() + async verifyCode(userInternalId: string, code: string): Promise<{ emailVerified: boolean }> { + const user = await this.userRepository.findById(userInternalId); + assert(user, 404, "User not found"); + assert(user.userId, 400, "User has no Auth0 ID"); + + const record = await this.emailVerificationCodeRepository.findActiveByUserId(userInternalId); + 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 isCodeValid = timingSafeEqual(Buffer.from(record.code), Buffer.from(code)); + + if (!isCodeValid) { + await this.emailVerificationCodeRepository.incrementAttempts(record.id); + assert(false, 400, "Invalid verification code"); + } + + await this.auth0Service.markEmailVerified(user.userId); + await this.userRepository.updateById(userInternalId, { emailVerified: true }); + await this.emailVerificationCodeRepository.deleteByUserId(userInternalId); + + this.logger.info({ event: "EMAIL_VERIFIED_VIA_CODE", userId: userInternalId }); + + return { emailVerified: true }; + } +} 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..9ce98a92db --- /dev/null +++ b/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts @@ -0,0 +1,17 @@ +import type { CreateNotificationInput } from "../notification/notification.service"; + +export function emailVerificationCodeNotification(user: { id: string; email: string }, vars: { code: string }): CreateNotificationInput { + return { + notificationId: `emailVerificationCode.${user.id}.${Date.now()}`, + 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..cc49450d6b 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, verifyEmailCodeRouter } from "./auth"; import { getBalancesRouter, getWalletListRouter, @@ -123,6 +123,8 @@ const openApiHonoHandlers: OpenApiHonoHandler[] = [ userSettingsRouter, userTemplatesRouter, sendVerificationEmailRouter, + sendVerificationCodeRouter, + verifyEmailCodeRouter, verifyEmailRouter, deploymentSettingRouter, deploymentsRouter, 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..2100742429 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,58 @@ 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", () => { + setup(); - expect(mockVerifyEmail).toHaveBeenCalledWith("test@example.com"); + expect(screen.queryByText("Redirecting to email verification...")).toBeInTheDocument(); }); - 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 } = setup(); - expect(mockVerifyEmail).not.toHaveBeenCalled(); + expect(mockLocalStorage.setItem).toHaveBeenCalledWith("onboardingStep", "2"); }); - it("shows loading text when verification is pending", () => { - setup({ email: "test@example.com", isPending: true }); + it("redirects to onboarding page", () => { + const { mockLocationAssign } = setup(); - expect(screen.queryByText("Just a moment while we finish verifying your email.")).toBeInTheDocument(); + expect(mockLocationAssign).toBe("/signup?return-to=%2F"); }); - 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() { + const mockLocalStorage = { + setItem: vi.fn(), + getItem: vi.fn(), + removeItem: vi.fn() + }; + Object.defineProperty(window, "localStorage", { value: mockLocalStorage, writable: true }); + + let capturedHref = ""; + Object.defineProperty(window, "location", { + value: { + get href() { + return capturedHref; + }, + set href(val: string) { + capturedHref = val; + } + }, + writable: 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(() => "/signup?return-to=%2F") } } as unknown as ComponentProps["dependencies"]; render(); - return { mockVerifyEmail, capturedOnSuccess, capturedOnError }; + return { mockLocalStorage, mockLocationAssign: capturedHref }; } }); diff --git a/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.tsx b/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.tsx index a18b236003..27d677c6f5 100644 --- a/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.tsx +++ b/apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.tsx @@ -1,22 +1,12 @@ -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, UrlService @@ -26,68 +16,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..7c1348d7ee 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 @@ -13,29 +13,47 @@ describe("EmailVerificationContainer", () => { expect.objectContaining({ isEmailVerified: false, isResending: false, - isChecking: false, - onResendEmail: expect.any(Function), - onCheckVerification: expect.any(Function), + isVerifying: false, + cooldownSeconds: expect.any(Number), + verifyError: null, + onResendCode: expect.any(Function), + onVerifyCode: expect.any(Function), onContinue: expect.any(Function) }) ); }); - it("should handle resend email success", async () => { - const { child, mockSendVerificationEmail, mockEnqueueSnackbar } = setup(); - mockSendVerificationEmail.mockResolvedValue(undefined); + it("should auto-send code on mount when email is not verified", () => { + const { mockSendVerificationCode } = setup(); - const { onResendEmail } = child.mock.calls[0][0]; + expect(mockSendVerificationCode).toHaveBeenCalledWith("test-user"); + }); + + it("should not auto-send code when email is already verified", () => { + const { mockSendVerificationCode } = setup({ + user: { id: "test-user", emailVerified: true } + }); + + expect(mockSendVerificationCode).not.toHaveBeenCalled(); + }); + + it("should handle resend code success and show snackbar for freshly sent code", async () => { + const { child, mockSendVerificationCode, mockEnqueueSnackbar } = setup(); + + await act(async () => {}); + + mockSendVerificationCode.mockResolvedValue({ data: { codeSentAt: new Date().toISOString() } }); + + const { onResendCode } = child.mock.calls[child.mock.calls.length - 1][0]; await act(async () => { - await onResendEmail(); + await onResendCode(); }); - 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", + title: "Verification code sent", + subTitle: "Please check your email for the 6-digit code", iconVariant: "success" }) }), @@ -43,34 +61,77 @@ describe("EmailVerificationContainer", () => { ); }); - it("should handle resend email error", async () => { - const { child, mockSendVerificationEmail, mockNotificator } = setup(); - mockSendVerificationEmail.mockRejectedValue(new Error("Failed")); + it("should not show snackbar when code was already sent recently (cooldown return)", async () => { + const { child, mockSendVerificationCode, mockEnqueueSnackbar } = setup(); - const { onResendEmail } = child.mock.calls[0][0]; + await act(async () => {}); + + mockSendVerificationCode.mockResolvedValue({ data: { codeSentAt: new Date(Date.now() - 30_000).toISOString() } }); + + const { onResendCode } = child.mock.calls[child.mock.calls.length - 1][0]; await act(async () => { - await onResendEmail(); + await onResendCode(); }); - expect(mockSendVerificationEmail).toHaveBeenCalledWith("test-user"); - expect(mockNotificator.error).toHaveBeenCalledWith("Failed to send verification email. Please try again later"); + expect(mockEnqueueSnackbar).not.toHaveBeenCalled(); }); - it("should handle check verification success", async () => { - const { child, mockCheckSession, mockEnqueueSnackbar } = setup(); + it("should not resend code while cooldown is active", async () => { + const { child, mockSendVerificationCode } = setup(); + + await act(async () => {}); + + mockSendVerificationCode.mockClear(); + mockSendVerificationCode.mockResolvedValue({ data: { codeSentAt: new Date().toISOString() } }); + + const { onResendCode: firstResend } = child.mock.calls[child.mock.calls.length - 1][0]; + await act(async () => { + await firstResend(); + }); + + expect(mockSendVerificationCode).toHaveBeenCalledTimes(1); + mockSendVerificationCode.mockClear(); + + const { onResendCode: secondResend } = child.mock.calls[child.mock.calls.length - 1][0]; + await act(async () => { + await secondResend(); + }); + + expect(mockSendVerificationCode).not.toHaveBeenCalled(); + }); + + it("should handle resend code error", async () => { + const { child, mockSendVerificationCode, mockNotificator } = setup(); + + await act(async () => {}); + + mockSendVerificationCode.mockRejectedValue(new Error("Failed")); + + const { onResendCode } = child.mock.calls[child.mock.calls.length - 1][0]; + await act(async () => { + await onResendCode(); + }); + + expect(mockNotificator.error).toHaveBeenCalledWith("Failed to send verification code. Please try again later"); + }); + + it("should handle verify code success", async () => { + const { child, mockVerifyEmailCode, mockCheckSession, mockEnqueueSnackbar } = setup(); + mockVerifyEmailCode.mockResolvedValue({ emailVerified: true }); mockCheckSession.mockResolvedValue(undefined); - const { onCheckVerification } = child.mock.calls[0][0]; + const { onVerifyCode } = child.mock.calls[0][0]; await act(async () => { - await onCheckVerification(); + await onVerifyCode("123456"); }); + expect(mockVerifyEmailCode).toHaveBeenCalledWith("test-user", "123456"); expect(mockCheckSession).toHaveBeenCalled(); expect(mockEnqueueSnackbar).toHaveBeenCalledWith( expect.objectContaining({ props: expect.objectContaining({ - title: "Verification status updated", - subTitle: "Your email verification status has been refreshed", + title: "Email verified", + subTitle: "Your email has been successfully verified", iconVariant: "success" }) }), @@ -78,17 +139,17 @@ describe("EmailVerificationContainer", () => { ); }); - it("should handle check verification error", async () => { - const { child, mockCheckSession, mockNotificator } = setup(); - mockCheckSession.mockRejectedValue(new Error("Failed")); + it("should handle verify code error", async () => { + const { child, mockVerifyEmailCode } = setup(); + mockVerifyEmailCode.mockRejectedValue(new Error("Invalid verification code")); - const { onCheckVerification } = child.mock.calls[0][0]; + const { onVerifyCode } = child.mock.calls[0][0]; await act(async () => { - await onCheckVerification(); + await onVerifyCode("000000"); }); - expect(mockCheckSession).toHaveBeenCalled(); - expect(mockNotificator.error).toHaveBeenCalledWith("Failed to check verification. Please try again or refresh the page"); + const lastCall = child.mock.calls[child.mock.calls.length - 1][0]; + expect(lastCall.verifyError).toBe("Invalid verification code"); }); it("should call onComplete when email is verified", () => { @@ -118,7 +179,8 @@ describe("EmailVerificationContainer", () => { }); function setup(input: { user?: any; onComplete?: Mock } = {}) { - const mockSendVerificationEmail = vi.fn(); + const mockSendVerificationCode = vi.fn().mockResolvedValue({ data: { codeSentAt: new Date(Date.now() - 61_000).toISOString() } }); + const mockVerifyEmailCode = vi.fn(); const mockCheckSession = vi.fn(); const mockEnqueueSnackbar = vi.fn(); const mockAnalyticsService = { @@ -137,7 +199,8 @@ describe("EmailVerificationContainer", () => { const mockUseServices = vi.fn().mockReturnValue({ analyticsService: mockAnalyticsService, auth: { - sendVerificationEmail: mockSendVerificationEmail + sendVerificationCode: mockSendVerificationCode, + verifyEmailCode: mockVerifyEmailCode } }); @@ -148,12 +211,15 @@ describe("EmailVerificationContainer", () => { const mockNotificator = { success: vi.fn(), error: vi.fn() }; const mockUseNotificator = vi.fn().mockReturnValue(mockNotificator); + const mockExtractErrorMessage = vi.fn((error: unknown) => (error instanceof Error ? error.message : "An error occurred. Please try again.")); + const dependencies = { useCustomUser: mockUseCustomUser, useSnackbar: mockUseSnackbar, useServices: mockUseServices, Snackbar: mockSnackbar, - useNotificator: mockUseNotificator + useNotificator: mockUseNotificator, + extractErrorMessage: mockExtractErrorMessage }; const mockChildren = vi.fn().mockReturnValue(
Test
); @@ -167,7 +233,8 @@ describe("EmailVerificationContainer", () => { return { child: mockChildren, - mockSendVerificationEmail, + mockSendVerificationCode, + mockVerifyEmailCode, mockCheckSession, mockEnqueueSnackbar, mockNotificator, 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..3f121682a5 100644 --- a/apps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.tsx +++ b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.tsx @@ -1,28 +1,35 @@ "use client"; import type { FC, ReactNode } from "react"; -import React, { useCallback, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Snackbar } from "@akashnetwork/ui/components"; import { useSnackbar } from "notistack"; import { useServices } from "@src/context/ServicesProvider"; import { useCustomUser } from "@src/hooks/useCustomUser"; import { useNotificator } from "@src/hooks/useNotificator"; +import type { AppError } from "@src/types"; +import { extractErrorMessage } from "@src/utils/errorUtils"; + +const COOLDOWN_DURATION = 60; const DEPENDENCIES = { useCustomUser, useSnackbar, useServices, Snackbar, - useNotificator + useNotificator, + extractErrorMessage }; export type EmailVerificationContainerProps = { children: (props: { isEmailVerified: boolean; isResending: boolean; - isChecking: boolean; - onResendEmail: () => void; - onCheckVerification: () => void; + isVerifying: boolean; + cooldownSeconds: number; + verifyError: string | null; + onResendCode: () => void; + onVerifyCode: (code: string) => void; onContinue: () => void; }) => ReactNode; onComplete: () => void; @@ -34,40 +41,91 @@ export const EmailVerificationContainer: FC = ( const { enqueueSnackbar } = d.useSnackbar(); const notificator = d.useNotificator(); const [isResending, setIsResending] = useState(false); - const [isChecking, setIsChecking] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [verifyError, setVerifyError] = useState(null); + const [cooldownSeconds, setCooldownSeconds] = useState(0); const { analyticsService, auth } = d.useServices(); + const hasSentInitialCode = useRef(false); + const isSendingRef = useRef(false); + const cooldownRef = useRef(0); const isEmailVerified = !!user?.emailVerified; - const handleResendEmail = useCallback(async () => { - if (!user?.id) return; + useEffect(() => { + if (cooldownSeconds <= 0) 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); - } - }, [user?.id, auth, enqueueSnackbar, d.Snackbar, notificator]); - - const handleCheckVerification = useCallback(async () => { - setIsChecking(true); - try { - await checkSession(); - enqueueSnackbar(, { - variant: "success" + cooldownRef.current = cooldownSeconds; + const timer = setInterval(() => { + setCooldownSeconds(prev => { + const next = prev <= 1 ? 0 : prev - 1; + cooldownRef.current = next; + return next; }); - } catch (error) { - notificator.error("Failed to check verification. Please try again or refresh the page"); - } finally { - setIsChecking(false); + }, 1000); + + return () => clearInterval(timer); + }, [cooldownSeconds]); + + const sendCode = useCallback( + async ({ silent }: { silent?: boolean } = {}) => { + if (!user?.id || isSendingRef.current || cooldownRef.current > 0) return; + + isSendingRef.current = true; + setIsResending(true); + setVerifyError(null); + try { + const { + data: { codeSentAt } + } = await auth.sendVerificationCode(user.id); + const elapsed = Math.floor((Date.now() - new Date(codeSentAt).getTime()) / 1000); + const remaining = Math.max(0, COOLDOWN_DURATION - elapsed); + cooldownRef.current = remaining; + setCooldownSeconds(remaining); + + if (!silent && elapsed <= 1) { + enqueueSnackbar(, { + variant: "success" + }); + } + } catch (error) { + if (!silent) { + notificator.error("Failed to send verification code. Please try again later"); + } + } finally { + isSendingRef.current = false; + setIsResending(false); + } + }, + [user?.id, auth, enqueueSnackbar, d.Snackbar, notificator] + ); + + useEffect(() => { + if (!isEmailVerified && user?.id && !hasSentInitialCode.current) { + hasSentInitialCode.current = true; + sendCode({ silent: true }); } - }, [checkSession, enqueueSnackbar, d.Snackbar, notificator]); + }, [isEmailVerified, user?.id, sendCode]); + + const handleVerifyCode = useCallback( + async (code: string) => { + if (!user?.id) return; + + setIsVerifying(true); + setVerifyError(null); + try { + await auth.verifyEmailCode(user.id, code); + await checkSession(); + enqueueSnackbar(, { + variant: "success" + }); + } catch (error) { + setVerifyError(d.extractErrorMessage(error as AppError)); + } finally { + setIsVerifying(false); + } + }, + [user?.id, auth, checkSession, enqueueSnackbar, d.Snackbar, d.extractErrorMessage] + ); const handleContinue = useCallback(() => { if (isEmailVerified) { @@ -83,9 +141,11 @@ export const EmailVerificationContainer: FC = ( {children({ isEmailVerified, isResending, - isChecking, - onResendEmail: handleResendEmail, - onCheckVerification: handleCheckVerification, + isVerifying, + cooldownSeconds, + verifyError, + onResendCode: sendCode, + onVerifyCode: handleVerifyCode, onContinue: handleContinue })} 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..efc0a68a52 100644 --- a/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.tsx +++ b/apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; -import { Alert, Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@akashnetwork/ui/components"; +import React, { useCallback, useRef, useState } from "react"; +import { Alert, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input } from "@akashnetwork/ui/components"; import { Check, Mail, Refresh } from "iconoir-react"; import { Title } from "@src/components/shared/Title"; @@ -8,25 +8,84 @@ import { Title } from "@src/components/shared/Title"; interface EmailVerificationStepProps { isEmailVerified: boolean; isResending: boolean; - isChecking: boolean; - onResendEmail: () => void; - onCheckVerification: () => void; + isVerifying: boolean; + cooldownSeconds: number; + verifyError: string | null; + onResendCode: () => void; + onVerifyCode: (code: string) => void; onContinue: () => void; } export const EmailVerificationStep: React.FunctionComponent = ({ isEmailVerified, isResending, - isChecking, - onResendEmail, - onCheckVerification, + isVerifying, + cooldownSeconds, + verifyError, + onResendCode, + onVerifyCode, onContinue }) => { + const [digits, setDigits] = useState(["", "", "", "", "", ""]); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + const handleDigitChange = useCallback( + (index: number, value: string) => { + if (!/^\d*$/.test(value) || isVerifying) return; + + const newDigits = [...digits]; + newDigits[index] = value.slice(-1); + setDigits(newDigits); + + if (value && index < 5) { + inputRefs.current[index + 1]?.focus(); + } + + const code = newDigits.join(""); + if (code.length === 6) { + onVerifyCode(code); + } + }, + [digits, isVerifying, onVerifyCode] + ); + + const handleKeyDown = useCallback( + (index: number, e: React.KeyboardEvent) => { + if (e.key === "Backspace" && !digits[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }, + [digits] + ); + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + e.preventDefault(); + if (isVerifying) return; + + const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6); + if (!pasted) return; + + const newDigits = [...digits]; + for (let i = 0; i < 6; i++) { + newDigits[i] = pasted[i] || ""; + } + setDigits(newDigits); + + if (pasted.length === 6) { + onVerifyCode(pasted); + } else { + inputRefs.current[pasted.length]?.focus(); + } + }, + [digits, isVerifying, onVerifyCode] + ); + return (
Verify Your Email - {isEmailVerified ? ( + {isEmailVerified && (
@@ -36,16 +95,6 @@ export const EmailVerificationStep: React.FunctionComponentYour email has been successfully verified.

- ) : ( - -
- -
-
-

Email Verification Required

-

Please verify your email address to continue.

-
-
)} @@ -57,22 +106,45 @@ export const EmailVerificationStep: React.FunctionComponent Email Verification - {isEmailVerified ? "Your email has been verified successfully." : "We've sent a verification link to your email address."} + {isEmailVerified ? "Your email has been verified successfully." : "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.

-
- - +
+ {digits.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={e => handleDigitChange(index, e.target.value)} + onKeyDown={e => handleKeyDown(index, e)} + className="h-12 w-12 text-center text-lg font-semibold" + disabled={isVerifying} + /> + ))}
+ + {verifyError && ( + +

{verifyError}

+
+ )} + + {isVerifying &&

Verifying...

} + +

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

+ + ) : (