From 660b11446870208d37dce0d0876430856f7274ac Mon Sep 17 00:00:00 2001 From: Iaroslav Gryshaiev Date: Mon, 10 Aug 2026 14:59:05 +0000 Subject: [PATCH] refactor(billing): decommission the start-trial endpoint Trial wallet provisioning is fully server-side: the wallet is ensured at registration, activation is dispatched off registration/email-verification, and the UI polls until it is ready. The client-triggered POST /v1/start-trial path is now redundant, and the /signup wizard that used to drive it is already gone; its client plumbing was left dead-exposed in the managed wallet context. Remove POST /v1/start-trial (router, controller shim, request/response schemas, the unused create-UserWallet ability) and its dead client: ManagedWalletHttpService.createWallet, useCreateManagedWalletMutation, and the createWallet/isWalletCreating/walletError plumbing in WalletProvider and useManagedWallet (now reporting-only). Drop the trial_started interceptor and event. Swagger and console-api-types regenerated. Also fix the passwordless email verification flow this leaves as the sole entry point. After the code was verified the handler cleared the persisted email, and the anon->authed transition remounts the auth screen from an ancestor provider, re-reading the now-empty email; the "missing email -> entry" guard then fired and bounced back to the email step, clobbering the success navigation. Gate that guard on the anonymous state, drive the post-verify redirect from the authenticated state, and render the boot loader instead of the auth forms while an authenticated visitor is redirected away (removing the login-form flash). Persist the resolved user settings on the verify session so it carries userId like the OAuth callback path. --- .../auth/services/ability/ability.service.ts | 4 +- .../wallet/wallet.controller.spec.ts | 51 +---- .../controllers/wallet/wallet.controller.ts | 27 +-- .../src/billing/http-schemas/wallet.schema.ts | 29 +-- apps/api/src/billing/routes/index.ts | 1 - .../routes/start-trial/start-trial.router.ts | 43 ---- apps/api/src/routers/open-api-handlers.ts | 2 - apps/api/swagger/openapi.json | 202 ------------------ .../__snapshots__/docs.spec.ts.snap | 127 ----------- .../PasswordlessAuth.spec.tsx | 38 +++- .../PasswordlessAuth/PasswordlessAuth.tsx | 26 ++- .../WalletProvider/WalletProvider.spec.tsx | 34 --- .../context/WalletProvider/WalletProvider.tsx | 24 +-- .../src/hooks/useEnsureTrialStarted.spec.ts | 10 +- .../src/hooks/useEnsureTrialStarted.ts | 4 +- .../src/hooks/useManagedWallet.spec.tsx | 46 +--- apps/deploy-web/src/hooks/useManagedWallet.ts | 52 +---- .../src/hooks/useOnboardingChrome.spec.ts | 18 +- .../src/hooks/useOnboardingChrome.ts | 12 +- .../auth-email-code-verify.spec.ts | 17 +- .../src/pages/api/auth/email-code-verify.ts | 3 +- apps/deploy-web/src/queries/queryKeys.ts | 7 - .../queries/useManagedWalletQuery.spec.tsx | 84 +++----- .../src/queries/useManagedWalletQuery.ts | 19 +- .../services/analytics/analytics.service.ts | 9 - .../app-di-container/app-di-container.ts | 10 +- apps/deploy-web/tests/seeders/wallet.ts | 3 - packages/console-api-types/src/schema.d.ts | 92 -------- .../managed-wallet-http.service.ts | 19 -- 29 files changed, 138 insertions(+), 875 deletions(-) delete mode 100644 apps/api/src/billing/routes/start-trial/start-trial.router.ts diff --git a/apps/api/src/auth/services/ability/ability.service.ts b/apps/api/src/auth/services/ability/ability.service.ts index 2ac8f28f82..458d28fe26 100644 --- a/apps/api/src/auth/services/ability/ability.service.ts +++ b/apps/api/src/auth/services/ability/ability.service.ts @@ -15,7 +15,7 @@ export class AbilityService { private readonly RULES: Record> = { REGULAR_USER: [ - { action: ["create", "read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } }, + { action: ["read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } }, { action: "manage", subject: "WalletSetting", conditions: { userId: "${user.id}" } }, { action: "read", subject: "User", conditions: { id: "${user.id}" } }, { action: "verify-email", subject: "User", conditions: { email: "${user.email}" } }, @@ -28,7 +28,7 @@ export class AbilityService { { action: "manage", subject: "NotificationChannel", conditions: { userId: "${user.id}" } } ], REGULAR_PAYING_USER: [ - { action: ["create", "read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } }, + { action: ["read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } }, { action: "manage", subject: "WalletSetting", conditions: { userId: "${user.id}" } }, { action: "read", subject: "User", conditions: { id: "${user.id}" } }, { action: "verify-email", subject: "User", conditions: { email: "${user.email}" } }, diff --git a/apps/api/src/billing/controllers/wallet/wallet.controller.spec.ts b/apps/api/src/billing/controllers/wallet/wallet.controller.spec.ts index e44749b02d..d4a6db1fa7 100644 --- a/apps/api/src/billing/controllers/wallet/wallet.controller.spec.ts +++ b/apps/api/src/billing/controllers/wallet/wallet.controller.spec.ts @@ -11,9 +11,7 @@ import { ManagedSignerService } from "@src/billing/services"; import { BalancesService } from "@src/billing/services/balances/balances.service"; import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; import { RefillService } from "@src/billing/services/refill/refill.service"; -import { TrialActivationJobService } from "@src/billing/services/trial-activation-job/trial-activation-job.service"; import { TrialValidationService } from "@src/billing/services/trial-validation/trial-validation.service"; -import { WalletInitializerService } from "@src/billing/services/wallet-initializer/wallet-initializer.service"; import { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; import type { UserOutput } from "@src/user/repositories"; import { WalletController } from "./wallet.controller"; @@ -62,54 +60,13 @@ describe("WalletController", () => { }); }); - describe("create", () => { - it("ensures the wallet, enqueues trial activation, and returns the wallet", async () => { - const user = createUser(); - const wallet = { - id: faker.number.int(), - userId: user.id, - address: faker.string.alphanumeric(44), - creditAmount: 0, - isTrialing: true, - createdAt: new Date() - }; - const container = setup({ user, wallet }); - const walletController = container.resolve(WalletController); - - const result = await walletController.create({ data: { userId: user.id } }); - - expect(container.resolve(WalletInitializerService).ensureWallet).toHaveBeenCalledWith(user.id); - expect(container.resolve(TrialActivationJobService).schedule).toHaveBeenCalledWith(user.id); - expect(result).toEqual({ data: { ...wallet, denom: "uakt", topUpMinAmountUsd: 100 } }); - }); - - it("rejects starting a trial for another user without touching their wallet", async () => { - const container = setup({ user: createUser() }); - const walletController = container.resolve(WalletController); - - await expect(walletController.create({ data: { userId: faker.string.uuid() } })).rejects.toMatchObject({ status: 403 }); - - expect(container.resolve(WalletInitializerService).ensureWallet).not.toHaveBeenCalled(); - expect(container.resolve(TrialActivationJobService).schedule).not.toHaveBeenCalled(); - }); - }); - - function setup(input?: { user?: UserOutput; wallets?: UserWalletPublicOutput[]; wallet?: UserWalletPublicOutput }) { - rootContainer.register(AuthService, { - useValue: mock({ - ability: createMongoAbility([{ action: "create", subject: "UserWallet" }]), - currentUser: input?.user ?? createUser() - }) - }); + function setup(input?: { user?: UserOutput; wallets?: UserWalletPublicOutput[] }) { + const authService = mock({ currentUser: input?.user ?? createUser() }); + authService.ability = createMongoAbility([{ action: "read", subject: "UserWallet" }]); + rootContainer.register(AuthService, { useValue: authService }); rootContainer.register(BillingConfigService, { useValue: mock({ get: vi.fn().mockReturnValue("uakt") }) }); - rootContainer.register(WalletInitializerService, { - useValue: mock({ ensureWallet: vi.fn().mockResolvedValue(input?.wallet) }) - }); - rootContainer.register(TrialActivationJobService, { - useValue: mock({ schedule: vi.fn().mockResolvedValue(undefined) }) - }); rootContainer.register(ManagedSignerService, { useValue: mock() }); rootContainer.register(RefillService, { useValue: mock() }); rootContainer.register(WalletReaderService, { diff --git a/apps/api/src/billing/controllers/wallet/wallet.controller.ts b/apps/api/src/billing/controllers/wallet/wallet.controller.ts index 195a809169..4bc74282de 100644 --- a/apps/api/src/billing/controllers/wallet/wallet.controller.ts +++ b/apps/api/src/billing/controllers/wallet/wallet.controller.ts @@ -5,23 +5,19 @@ import { Lifecycle, scoped } from "tsyringe"; import { AuthService, Protected } from "@src/auth/services/auth.service"; import type { GetBalancesResponseOutput } from "@src/billing/http-schemas/balance.schema"; import type { SignTxRequestInput, SignTxResponseOutput } from "@src/billing/http-schemas/tx.schema"; -import type { StartTrialRequestInput, WalletListOutputResponse, WalletOutputResponse } from "@src/billing/http-schemas/wallet.schema"; +import type { WalletListOutputResponse } from "@src/billing/http-schemas/wallet.schema"; import { UserWalletRepository } from "@src/billing/repositories"; import type { GetWalletQuery } from "@src/billing/routes/get-wallet-list/get-wallet-list.router"; import { BalancesService } from "@src/billing/services/balances/balances.service"; import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; import { RefillService } from "@src/billing/services/refill/refill.service"; -import { TrialActivationJobService } from "@src/billing/services/trial-activation-job/trial-activation-job.service"; import { TrialValidationService } from "@src/billing/services/trial-validation/trial-validation.service"; -import { WalletInitializerService } from "@src/billing/services/wallet-initializer/wallet-initializer.service"; import { GetWalletOptions, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; @scoped(Lifecycle.ResolutionScoped) export class WalletController { constructor( - private readonly walletInitializer: WalletInitializerService, - private readonly trialActivationJobService: TrialActivationJobService, private readonly signerService: ManagedSignerService, private readonly refillService: RefillService, private readonly walletReaderService: WalletReaderService, @@ -32,27 +28,6 @@ export class WalletController { private readonly trialValidationService: TrialValidationService ) {} - /** - * Backward-compat shim for `POST /v1/start-trial`: kept so an older deploy-web can still call it during a staged - * rollout where the API ships first. Trial provisioning now runs server-side off registration/verification, so this - * only ensures the wallet exists and (idempotently) enqueues activation, returning the wallet so an old client's - * cache still fills. New clients don't call it. - * - * `ensureWallet` uses the unscoped repository (it also runs from server-side registration with a trusted userId), - * so ownership is enforced here: the request `userId` must be the caller's own, else another user's wallet data - * would leak and their provisioning could be triggered. - */ - @Protected([{ action: "create", subject: "UserWallet" }]) - async create({ data: { userId } }: StartTrialRequestInput): Promise { - assert(userId === this.authService.currentUser.id, 403, "Cannot start a trial for another user"); - const wallet = await this.walletInitializer.ensureWallet(userId); - await this.trialActivationJobService.schedule(userId); - - const publicWallet = this.userWalletRepository.toPublic(wallet); - const denom = this.billingConfigService.get("DEPLOYMENT_GRANT_DENOM"); - return { data: { ...publicWallet, denom, topUpMinAmountUsd: this.trialValidationService.getTopUpMinAmountUsd(publicWallet) } }; - } - @Protected([{ action: "read", subject: "UserWallet" }]) async getWallets(query: GetWalletQuery): Promise { const denom = this.billingConfigService.get("DEPLOYMENT_GRANT_DENOM"); diff --git a/apps/api/src/billing/http-schemas/wallet.schema.ts b/apps/api/src/billing/http-schemas/wallet.schema.ts index ba29d7916f..a8231230f1 100644 --- a/apps/api/src/billing/http-schemas/wallet.schema.ts +++ b/apps/api/src/billing/http-schemas/wallet.schema.ts @@ -19,33 +19,8 @@ const WalletOutputSchema = z.object({ createdAt: z.coerce.date().nullable().openapi({}) }); -const WalletWithOptional3DSSchema = WalletOutputSchema.extend({ - requires3DS: z.boolean().optional(), - clientSecret: z.string().nullable().optional(), - paymentIntentId: z.string().nullable().optional(), - paymentMethodId: z.string().nullable().optional() -}); - -export const WalletResponseOutputSchema = z.object({ - data: WalletWithOptional3DSSchema -}); - -export const WalletResponseNo3DSOutputSchema = z.object({ - data: WalletWithOptional3DSSchema.strict() - .refine(data => !data.requires3DS, { message: "requires3DS must be false or undefined for 200 responses" }) - .refine(data => !data.clientSecret, { message: "clientSecret must be null or undefined for 200 responses" }) - .refine(data => !data.paymentIntentId, { message: "paymentIntentId must be null or undefined for 200 responses" }) - .refine(data => !data.paymentMethodId, { message: "paymentMethodId must be null or undefined for 200 responses" }) -}); - export const WalletListResponseOutputSchema = z.object({ - data: z.array(WalletWithOptional3DSSchema) -}); - -export const StartTrialRequestInputSchema = z.object({ - data: z.object({ - userId: z.string().openapi({}) - }) + data: z.array(WalletOutputSchema) }); export const WalletSettingsOutputSchema = z.object({ @@ -88,8 +63,6 @@ export const UpdateWalletSettingsRequestSchema = z.object({ data: WalletSettingsInputSchema }); -export type StartTrialRequestInput = z.infer; -export type WalletOutputResponse = z.infer; export type WalletListOutputResponse = z.infer; export type WalletSettingsResponse = z.infer; export type CreateWalletSettingsRequest = z.infer; diff --git a/apps/api/src/billing/routes/index.ts b/apps/api/src/billing/routes/index.ts index dff6691a8f..8d07156ebf 100644 --- a/apps/api/src/billing/routes/index.ts +++ b/apps/api/src/billing/routes/index.ts @@ -7,6 +7,5 @@ export * from "@src/billing/routes/stripe-customers/stripe-customers.router"; export * from "@src/billing/routes/stripe-transactions/stripe-transactions.router"; export * from "@src/billing/routes/stripe-payment-methods/stripe-payment-methods.router"; export * from "@src/billing/routes/get-balances/get-balances.router"; -export * from "@src/billing/routes/start-trial/start-trial.router"; export * from "@src/billing/routes/usage/usage.router"; export * from "@src/billing/routes/wallet-settings/wallet-settings.router"; diff --git a/apps/api/src/billing/routes/start-trial/start-trial.router.ts b/apps/api/src/billing/routes/start-trial/start-trial.router.ts deleted file mode 100644 index 8fa394ca09..0000000000 --- a/apps/api/src/billing/routes/start-trial/start-trial.router.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { container } from "tsyringe"; - -import { WalletController } from "@src/billing/controllers/wallet/wallet.controller"; -import { StartTrialRequestInputSchema, WalletResponseNo3DSOutputSchema } from "@src/billing/http-schemas/wallet.schema"; -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"; - -export const startTrialRouter = new OpenApiHonoHandler(); - -const route = createRoute({ - method: "post", - path: "/v1/start-trial", - summary: "Start a trial period for a user", - description: - "Ensures the user's managed wallet exists and enqueues background trial activation. Kept for backward compatibility; trial activation now runs server-side off registration/verification.", - tags: ["Wallet"], - security: SECURITY_NONE, - request: { - body: { - content: { - "application/json": { - schema: StartTrialRequestInputSchema - } - } - } - }, - responses: { - 200: { - description: "Wallet ensured and trial activation enqueued", - content: { - "application/json": { - schema: WalletResponseNo3DSOutputSchema - } - } - } - } -}); -startTrialRouter.openapi(route, async function routeStartTrial(c) { - const result = await container.resolve(WalletController).create(c.req.valid("json")); - - return c.json(result, 200); -}); diff --git a/apps/api/src/routers/open-api-handlers.ts b/apps/api/src/routers/open-api-handlers.ts index ade9b8f7b7..a78e9121d6 100644 --- a/apps/api/src/routers/open-api-handlers.ts +++ b/apps/api/src/routers/open-api-handlers.ts @@ -7,7 +7,6 @@ import { getBalancesRouter, getWalletListRouter, signAndBroadcastTxRouter, - startTrialRouter, stripeCouponsRouter, stripeCustomersRouter, stripePaymentMethodsRouter, @@ -55,7 +54,6 @@ import { getCurrentUserRouter, registerUserRouter, userSettingsRouter, userTempl import { validatorsRouter } from "@src/validator"; export const openApiHonoHandlers: OpenApiHonoHandler[] = [ - startTrialRouter, getWalletListRouter, walletSettingRouter, signAndBroadcastTxRouter, diff --git a/apps/api/swagger/openapi.json b/apps/api/swagger/openapi.json index ef3246c817..a7a61e9eb0 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -11,193 +11,6 @@ "version": "v1" }, "paths": { - "/v1/start-trial": { - "post": { - "summary": "Start a trial period for a user", - "description": "Creates a managed wallet for a user and initiates a trial period. This endpoint handles payment method validation and may require 3D Secure authentication for certain payment methods. Returns wallet information and trial status.", - "tags": [ - "Wallet" - ], - "security": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - }, - "required": [ - "userId" - ] - } - }, - "required": [ - "data" - ] - } - } - } - }, - "responses": { - "200": { - "description": "Trial started successfully and wallet created", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "id": { - "type": "number", - "nullable": true - }, - "userId": { - "type": "string", - "nullable": true - }, - "creditAmount": { - "type": "number" - }, - "address": { - "type": "string", - "nullable": true - }, - "denom": { - "type": "string" - }, - "isTrialing": { - "type": "boolean" - }, - "topUpMinAmountUsd": { - "type": "number", - "description": "Minimum USD amount accepted by the next paid top-up for this wallet." - }, - "createdAt": { - "type": "string", - "nullable": true - }, - "requires3DS": { - "type": "boolean" - }, - "clientSecret": { - "type": "string", - "nullable": true - }, - "paymentIntentId": { - "type": "string", - "nullable": true - }, - "paymentMethodId": { - "type": "string", - "nullable": true - } - }, - "required": [ - "id", - "userId", - "creditAmount", - "address", - "denom", - "isTrialing", - "topUpMinAmountUsd", - "createdAt" - ], - "additionalProperties": false - } - }, - "required": [ - "data" - ] - } - } - } - }, - "202": { - "description": "3D Secure authentication required to complete trial setup", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "id": { - "type": "number", - "nullable": true - }, - "userId": { - "type": "string", - "nullable": true - }, - "creditAmount": { - "type": "number" - }, - "address": { - "type": "string", - "nullable": true - }, - "denom": { - "type": "string" - }, - "isTrialing": { - "type": "boolean" - }, - "topUpMinAmountUsd": { - "type": "number", - "description": "Minimum USD amount accepted by the next paid top-up for this wallet." - }, - "createdAt": { - "type": "string", - "nullable": true - }, - "requires3DS": { - "type": "boolean" - }, - "clientSecret": { - "type": "string", - "nullable": true - }, - "paymentIntentId": { - "type": "string", - "nullable": true - }, - "paymentMethodId": { - "type": "string", - "nullable": true - } - }, - "required": [ - "id", - "userId", - "creditAmount", - "address", - "denom", - "isTrialing", - "topUpMinAmountUsd", - "createdAt" - ], - "additionalProperties": false - } - }, - "required": [ - "data" - ] - } - } - } - } - } - } - }, "/v1/wallets": { "get": { "summary": "Get a list of wallets", @@ -265,21 +78,6 @@ "createdAt": { "type": "string", "nullable": true - }, - "requires3DS": { - "type": "boolean" - }, - "clientSecret": { - "type": "string", - "nullable": true - }, - "paymentIntentId": { - "type": "string", - "nullable": true - }, - "paymentMethodId": { - "type": "string", - "nullable": true } }, "required": [ diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index 9706c27a45..7d28e13417 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -13376,118 +13376,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` ], }, }, - "/v1/start-trial": { - "post": { - "description": "Ensures the user's managed wallet exists and enqueues background trial activation. Kept for backward compatibility; trial activation now runs server-side off registration/verification.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "properties": { - "data": { - "properties": { - "userId": { - "type": "string", - }, - }, - "required": [ - "userId", - ], - "type": "object", - }, - }, - "required": [ - "data", - ], - "type": "object", - }, - }, - }, - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "address": { - "nullable": true, - "type": "string", - }, - "clientSecret": { - "nullable": true, - "type": "string", - }, - "createdAt": { - "nullable": true, - "type": "string", - }, - "creditAmount": { - "type": "number", - }, - "denom": { - "type": "string", - }, - "id": { - "nullable": true, - "type": "number", - }, - "isTrialing": { - "type": "boolean", - }, - "paymentIntentId": { - "nullable": true, - "type": "string", - }, - "paymentMethodId": { - "nullable": true, - "type": "string", - }, - "requires3DS": { - "type": "boolean", - }, - "topUpMinAmountUsd": { - "description": "Minimum USD amount accepted by the next paid top-up for this wallet.", - "type": "number", - }, - "userId": { - "nullable": true, - "type": "string", - }, - }, - "required": [ - "id", - "userId", - "creditAmount", - "address", - "denom", - "isTrialing", - "topUpMinAmountUsd", - "createdAt", - ], - "type": "object", - }, - }, - "required": [ - "data", - ], - "type": "object", - }, - }, - }, - "description": "Wallet ensured and trial activation enqueued", - }, - }, - "security": [], - "summary": "Start a trial period for a user", - "tags": [ - "Wallet", - ], - }, - }, "/v1/templates-list": { "get": { "responses": { @@ -15381,10 +15269,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "nullable": true, "type": "string", }, - "clientSecret": { - "nullable": true, - "type": "string", - }, "createdAt": { "nullable": true, "type": "string", @@ -15402,17 +15286,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "isTrialing": { "type": "boolean", }, - "paymentIntentId": { - "nullable": true, - "type": "string", - }, - "paymentMethodId": { - "nullable": true, - "type": "string", - }, - "requires3DS": { - "type": "boolean", - }, "topUpMinAmountUsd": { "description": "Minimum USD amount accepted by the next paid top-up for this wallet.", "type": "number", diff --git a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx index 48a50adda2..d9f21806f9 100644 --- a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx +++ b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx @@ -61,9 +61,9 @@ describe(PasswordlessAuth.name, () => { expect(replace).toHaveBeenCalledWith(expect.not.stringContaining("step"), undefined, { shallow: true }); }); - it("resets the persisted flow, refreshes the session, and navigates back when EmailCodeVerify calls onVerified", async () => { + it("resets the persisted flow and refreshes the session when EmailCodeVerify calls onVerified", async () => { const EmailCodeVerifyMock = vi.fn(ComponentMock); - const { onFlowReset, checkSession, navigateBack } = setup({ + const { onFlowReset, checkSession } = setup({ step: "verify", initialEmail: "alice@example.com", dependencies: { EmailCodeVerify: EmailCodeVerifyMock as never } @@ -75,9 +75,40 @@ describe(PasswordlessAuth.name, () => { expect(onFlowReset).toHaveBeenCalled(); expect(checkSession).toHaveBeenCalled(); + }); + + it("navigates back once the user is authenticated", () => { + const { navigateBack } = setup({ authenticated: true }); + expect(navigateBack).toHaveBeenCalled(); }); + it("renders the boot loader instead of the auth forms when authenticated", () => { + const EmailCodeStartMock = vi.fn(ComponentMock); + const EmailCodeVerifyMock = vi.fn(ComponentMock); + const BootLoadingMock = vi.fn(ComponentMock); + setup({ + authenticated: true, + step: "verify", + initialEmail: "alice@example.com", + dependencies: { + EmailCodeStart: EmailCodeStartMock as never, + EmailCodeVerify: EmailCodeVerifyMock as never, + BootLoading: BootLoadingMock as never + } + }); + + expect(BootLoadingMock).toHaveBeenCalled(); + expect(EmailCodeStartMock).not.toHaveBeenCalled(); + expect(EmailCodeVerifyMock).not.toHaveBeenCalled(); + }); + + it("does not redirect to entry for an authenticated user even when the email is missing", () => { + const { replace } = setup({ authenticated: true, step: "verify", initialEmail: "" }); + + expect(replace).not.toHaveBeenCalled(); + }); + it("provides a captcha-token getter that resolves to the Turnstile token", async () => { const EmailCodeStartMock = vi.fn(ComponentMock); setup({ dependencies: { EmailCodeStart: EmailCodeStartMock as never } }); @@ -113,6 +144,7 @@ describe(PasswordlessAuth.name, () => { input: { initialEmail?: string; step?: string; + authenticated?: boolean; dependencies?: Partial; } = {} ) { @@ -129,7 +161,7 @@ describe(PasswordlessAuth.name, () => { mock>({ checkSession, isLoading: false, - user: undefined + user: input.authenticated ? mock["user"]>>({ userId: "user-1" }) : undefined }); const useReturnTo: typeof DEPENDENCIES.useReturnTo = () => mock>({ diff --git a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx index 5608f238a0..fd101c03b1 100644 --- a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx +++ b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx @@ -8,6 +8,7 @@ import { useRouter } from "next/router"; import type { TurnstileRef } from "@src/components/turnstile/Turnstile"; import { ClientOnlyTurnstile } from "@src/components/turnstile/Turnstile"; +import { BootLoading } from "@src/context/BootLoadingProvider/BootLoadingProvider"; import { useServices } from "@src/context/ServicesProvider"; import { useReturnTo } from "@src/hooks/useReturnTo/useReturnTo"; import { useUser } from "@src/hooks/useUser"; @@ -18,6 +19,7 @@ import type { PassedFlowProps } from "./withPersistedPasswordlessFlow"; import { withPersistedPasswordlessFlow } from "./withPersistedPasswordlessFlow"; export const DEPENDENCIES = { + BootLoading, EmailCodeStart, EmailCodeVerify, Link, @@ -36,7 +38,7 @@ interface Props extends PassedFlowProps { export function PasswordlessAuth({ dependencies: d = DEPENDENCIES, ...props }: Props) { const { publicConfig, analyticsService } = useServices(); const { navigateBack } = d.useReturnTo({ defaultReturnTo: "/" }); - const { checkSession } = d.useUser(); + const { checkSession, user } = d.useUser(); const router = d.useRouter(); const searchParams = d.useSearchParams(); const [email, setEmail] = useState(props.initialEmail); @@ -73,25 +75,41 @@ export function PasswordlessAuth({ dependencies: d = DEPENDENCIES, ...props }: P router.replace(query ? `?${query}` : router.pathname, undefined, { shallow: true }); }, [router, searchParams]); + /** + * Sends a visitor who reached `?step=verify` without an in-flight email (a deep link, or a reload + * after the flow was cleared) back to the entry screen. Skipped once authenticated: a successful + * verification clears the persisted email and remounts this component (an ancestor provider swaps + * on the anon→authed transition) with an empty `email`; firing here would `router.replace` back to + * entry and clobber the post-verify `navigateBack()`. + */ useEffect( function redirectToEntryWhenEmailMissing() { + if (user) return; if (screen === "verify" && !email) { goBackToEntry(); } }, - [screen, email, goBackToEntry] + [screen, email, goBackToEntry, user] + ); + + useEffect( + function leaveWhenAuthenticated() { + if (user) navigateBack(); + }, + [user, navigateBack] ); const handleVerified = useCallback(async () => { onFlowReset(); await checkSession(); - navigateBack(); - }, [checkSession, navigateBack, onFlowReset]); + }, [checkSession, onFlowReset]); const remountActiveScreen = useCallback(() => { setScreenKey(value => value + 1); }, []); + if (user) return ; + return ( <>
diff --git a/apps/deploy-web/src/context/WalletProvider/WalletProvider.spec.tsx b/apps/deploy-web/src/context/WalletProvider/WalletProvider.spec.tsx index dde22e9267..84b7a714fd 100644 --- a/apps/deploy-web/src/context/WalletProvider/WalletProvider.spec.tsx +++ b/apps/deploy-web/src/context/WalletProvider/WalletProvider.spec.tsx @@ -40,14 +40,6 @@ describe(WalletProvider.name, () => { expect(screen.queryByTestId("app-boot-loading")).not.toBeInTheDocument(); }); - it("keeps children mounted while a trial wallet is being created", () => { - const { probed } = setup({ user: registeredUser(), managedWallet: { isInitializing: false, isCreating: true, wallet: undefined } }); - - expect(screen.getByTestId("child")).toBeInTheDocument(); - expect(screen.queryByTestId("app-boot-loading")).not.toBeInTheDocument(); - expect(probed.current?.isWalletCreating).toBe(true); - }); - it("mounts children when the lookup transitions from loading to settled", () => { const { rerenderWithManagedWallet } = setup({ user: registeredUser(), managedWallet: { isInitializing: true, wallet: undefined } }); @@ -77,36 +69,12 @@ describe(WalletProvider.name, () => { expect(probed.current).toMatchObject({ address: wallet.address, hasWallet: true, - isWalletCreating: false, isTrialing: true, creditAmount: 42, topUpMinAmountUsd: 20 }); }); - it("creates a wallet only when none exists", () => { - const { probed, managedWalletMock } = setup({ user: registeredUser(), managedWallet: { isInitializing: false, wallet: undefined } }); - - probed.current?.createWallet(); - - expect(managedWalletMock.create).toHaveBeenCalled(); - }); - - it("does not create a wallet when one already exists", () => { - const { probed, managedWalletMock } = setup({ user: registeredUser(), managedWallet: { isInitializing: false, wallet: buildManagedWallet() } }); - - probed.current?.createWallet(); - - expect(managedWalletMock.create).not.toHaveBeenCalled(); - }); - - it("surfaces the wallet creation error as walletError", () => { - const createError = new Error("creation failed"); - const { probed } = setup({ user: registeredUser(), managedWallet: { isInitializing: false, wallet: undefined, createError } }); - - expect(probed.current?.walletError).toBe(createError); - }); - function registeredUser() { return mock({ id: "internal-id", userId: "auth-user-id" }); } @@ -119,10 +87,8 @@ describe(WalletProvider.name, () => { return Object.assign(mock(), { wallet: undefined, isLoading: false, - isCreating: false, isInitializing: false, isFetching: false, - createError: null, ...overrides }); } diff --git a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx index 1603b59764..3415b0e30d 100644 --- a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx +++ b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx @@ -8,7 +8,6 @@ import { useManagedWallet } from "@src/hooks/useManagedWallet"; import { useUser } from "@src/hooks/useUser"; import { useWhen } from "@src/hooks/useWhen"; import { useBalances } from "@src/queries/useBalancesQuery"; -import type { AppError } from "@src/types"; import { getStorageManagedWallet, updateStorageManagedWallet } from "@src/utils/walletUtils"; import { BootLoading } from "../BootLoadingProvider/BootLoadingProvider"; import { useServices } from "../ServicesProvider"; @@ -30,15 +29,11 @@ export type ContextType = { address: string; /** True once the server-side wallet record exists. The address may still be empty while provisioning. */ hasWallet: boolean; - /** True while a trial wallet creation is in flight, from any hook instance. */ - isWalletCreating: boolean; - createWallet: () => void; signAndBroadcastTx: (msgs: EncodeObject[]) => Promise; denom: string; isTrialing: boolean; creditAmount?: number; topUpMinAmountUsd: number; - walletError?: AppError; }; /** @@ -57,13 +52,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode; dependencies? const [, setSettingsId] = useAtom(settingsIdAtom); const { user } = d.useUser(); - const { - wallet: managedWallet, - isInitializing: isManagedWalletInitializing, - isCreating: isWalletCreating, - create: createManagedWallet, - createError - } = d.useManagedWallet(); + const { wallet: managedWallet, isInitializing: isManagedWalletInitializing } = d.useManagedWallet(); const walletAddress = managedWallet?.address; const hasWallet = !!managedWallet; const { refetch: refetchBalances } = d.useBalances(walletAddress); @@ -84,12 +73,6 @@ export const WalletProvider: React.FC<{ children: React.ReactNode; dependencies? setSettingsId(walletAddress || null); }, [walletAddress, setSettingsId]); - function createWallet() { - if (!managedWallet) { - createManagedWallet(); - } - } - function syncStorageWallet(): void { if (!managedWallet?.userId || !walletAddress) { return; @@ -114,14 +97,11 @@ export const WalletProvider: React.FC<{ children: React.ReactNode; dependencies? value={{ address: walletAddress as string, hasWallet, - isWalletCreating, - createWallet, signAndBroadcastTx, denom: managedWallet?.denom ?? "", isTrialing: !!managedWallet?.isTrialing, creditAmount: managedWallet?.creditAmount, - topUpMinAmountUsd: managedWallet?.topUpMinAmountUsd ?? 20, - walletError: createError + topUpMinAmountUsd: managedWallet?.topUpMinAmountUsd ?? 20 }} > {isInitializing ? ( diff --git a/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts b/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts index 77904cbebf..f4f5657bab 100644 --- a/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts +++ b/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts @@ -8,7 +8,7 @@ import { renderHook } from "@testing-library/react"; describe(useEnsureTrialStarted.name, () => { it("is ready once the managed wallet has an address", () => { - const { dependencies } = setup({ wallet: { address: "akash1..." }, isLoading: false }); + const { dependencies } = setup({ wallet: { address: "akash1..." }, isInitializing: false }); const { result } = renderHook(() => useEnsureTrialStarted(dependencies)); @@ -16,7 +16,7 @@ describe(useEnsureTrialStarted.name, () => { }); it("is not ready while the wallet row exists without an address yet", () => { - const { dependencies } = setup({ wallet: { address: null }, isLoading: false }); + const { dependencies } = setup({ wallet: { address: null }, isInitializing: false }); const { result } = renderHook(() => useEnsureTrialStarted(dependencies)); @@ -24,7 +24,7 @@ describe(useEnsureTrialStarted.name, () => { }); it("is not ready and reports loading when there is no wallet", () => { - const { dependencies } = setup({ wallet: undefined, isLoading: true }); + const { dependencies } = setup({ wallet: undefined, isInitializing: true }); const { result } = renderHook(() => useEnsureTrialStarted(dependencies)); @@ -32,11 +32,11 @@ describe(useEnsureTrialStarted.name, () => { expect(result.current.isLoading).toBe(true); }); - function setup(input: { wallet: { address: string | null } | undefined; isLoading: boolean }) { + function setup(input: { wallet: { address: string | null } | undefined; isInitializing: boolean }) { const useManagedWallet: typeof DEPENDENCIES.useManagedWallet = () => mock>({ wallet: input.wallet as ReturnType["wallet"], - isLoading: input.isLoading + isInitializing: input.isInitializing }); return { dependencies: { useManagedWallet } }; diff --git a/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts b/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts index 43809fcc0d..e3a9762766 100644 --- a/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts +++ b/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts @@ -20,7 +20,7 @@ export type EnsureTrialStartedResult = { * activation lands. */ export const useEnsureTrialStarted = (d = DEPENDENCIES): EnsureTrialStartedResult => { - const { wallet, isLoading } = d.useManagedWallet(); + const { wallet, isInitializing } = d.useManagedWallet(); - return { isWalletReady: !!wallet?.address, isLoading, wallet: wallet as ApiManagedWalletOutput | undefined }; + return { isWalletReady: !!wallet?.address, isLoading: isInitializing, wallet: wallet as ApiManagedWalletOutput | undefined }; }; diff --git a/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx b/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx index 9e86420afe..bf0e123c29 100644 --- a/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx +++ b/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx @@ -4,34 +4,16 @@ import { UserProvider } from "@auth0/nextjs-auth0/client"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import { useCreateManagedWalletMutation } from "@src/queries/useManagedWalletQuery"; import { getStorageManagedWallet, updateStorageManagedWallet } from "@src/utils/walletUtils"; import { useManagedWallet } from "./useManagedWallet"; -import { act } from "@testing-library/react"; import { setupQuery } from "@tests/unit/query-client"; describe(useManagedWallet.name, () => { - it("is not loading once the wallet query settles and no create is in flight", () => { + it("is not initializing once the wallet query settles", () => { const { result } = setup(); - expect(result.current.managed.isLoading).toBe(false); - }); - - it("reports loading while a managed-wallet create fired by another instance is in flight", async () => { - // The trial is created from the onboarding picker / auto-deploy flow — a different useManagedWallet - // instance than the persistent WalletProvider that reads loading state. The loading signal must still - // reflect that in-flight create so the onboarding redirect guard does not treat the provisioning trial - // as "no wallet" and bounce the user to /signup mid-provision. - const { result } = setup(); - - act(() => { - result.current.createMutation.mutate("user-1"); - }); - - await vi.waitFor(() => { - expect(result.current.managed.isLoading).toBe(true); - }); + expect(result.current.managed.isInitializing).toBe(false); }); it("keeps the stored wallet untouched when the API returns a wallet without an address", async () => { @@ -67,21 +49,6 @@ describe(useManagedWallet.name, () => { }); }); - it("persists the created wallet as selected after a successful create", async () => { - const userId = "user-create"; - const createdWallet = buildApiWallet({ userId, address: "akash1created", creditAmount: 50 }); - - const { result } = setup({ userId, createdWallet }); - - act(() => { - result.current.managed.create(); - }); - - await vi.waitFor(() => { - expect(getStorageManagedWallet(userId)).toMatchObject({ address: "akash1created", creditAmount: 50, selected: true }); - }); - }); - /** Mirrors the real API contract: `address` is nullable while a wallet is mid-provisioning, even though the SDK type claims `string`. */ function buildApiWallet(overrides: { userId: string; address: string | null; creditAmount?: number }) { return { @@ -93,20 +60,17 @@ describe(useManagedWallet.name, () => { } as ApiManagedWalletOutput; } - function setup(input?: { userId?: string; apiWallet?: ApiManagedWalletOutput; createdWallet?: ApiManagedWalletOutput }) { + function setup(input?: { userId?: string; apiWallet?: ApiManagedWalletOutput }) { const managedWalletService = mock({ - getWallet: vi.fn().mockResolvedValue(input?.apiWallet ?? null), - // A never-settling create keeps the mutation pending for the duration of the assertion. - createWallet: input?.createdWallet ? vi.fn().mockResolvedValue(input.createdWallet) : vi.fn().mockReturnValue(new Promise(() => {})) + getWallet: vi.fn().mockResolvedValue(input?.apiWallet ?? null) }); const user = { email: "test@akash.network", id: input?.userId, userId: input?.userId } as UserProfile; return setupQuery( () => { - const createMutation = useCreateManagedWalletMutation(); const managed = useManagedWallet(); - return { createMutation, managed }; + return { managed }; }, { services: { managedWalletService: () => managedWalletService }, diff --git a/apps/deploy-web/src/hooks/useManagedWallet.ts b/apps/deploy-web/src/hooks/useManagedWallet.ts index da44af4118..c83aeb4dde 100644 --- a/apps/deploy-web/src/hooks/useManagedWallet.ts +++ b/apps/deploy-web/src/hooks/useManagedWallet.ts @@ -1,44 +1,22 @@ import { useEffect, useMemo } from "react"; import type { ApiManagedWalletOutput } from "@akashnetwork/http-sdk"; -import { useIsMutating } from "@tanstack/react-query"; import { useUser } from "@src/hooks/useUser"; -import { QueryKeys } from "@src/queries/queryKeys"; -import { useCreateManagedWalletMutation, useManagedWalletQuery } from "@src/queries/useManagedWalletQuery"; +import { useManagedWalletQuery } from "@src/queries/useManagedWalletQuery"; import { ensureUserManagedWalletOwnership, updateStorageManagedWallet } from "@src/utils/walletUtils"; export const useManagedWallet = () => { const { user } = useUser(); const { data: queried, isLoading: isInitialLoading, isFetching, refetch } = useManagedWalletQuery(user?.id); - const { - mutate: create, - data: created, - isPending: isCreating, - isSuccess: isCreated, - error: createError, - reset: resetCreate - } = useCreateManagedWalletMutation(); - // A trial wallet is often created from a different `useManagedWallet` instance (the onboarding picker / - // auto-deploy flow) than the one that reads loading state (the persistent WalletProvider). Observing the - // mutation cache — not just this observer's `isCreating` — makes the loading signal reflect an in-flight - // create regardless of which instance fired it, so consumers (e.g. the onboarding redirect guard) don't - // treat a provisioning trial as "no wallet" and bounce the user to /signup mid-provision. - const isCreatingManagedWallet = useIsMutating({ mutationKey: QueryKeys.getManagedWalletCreateMutationKey() }) > 0; - const wallet = useMemo(() => (queried || created) as ApiManagedWalletOutput, [queried, created]); - const isCreatingFromAnyInstance = isCreating || isCreatingManagedWallet; - const isLoading = isInitialLoading || isCreatingFromAnyInstance; + const wallet = queried as ApiManagedWalletOutput | undefined; useEffect(() => { if (!wallet?.address) { return; } - if (isCreated) { - updateStorageManagedWallet({ ...wallet, selected: true }); - } else { - updateStorageManagedWallet(wallet); - } - }, [isCreated, wallet]); + updateStorageManagedWallet(wallet); + }, [wallet]); useEffect(() => { if (user?.id && !user.userId) { @@ -48,29 +26,15 @@ export const useManagedWallet = () => { return useMemo(() => { return { - create: () => { - if (!user?.id) { - throw new Error("User is not initialized yet"); - } - - create(user.id); - }, wallet: wallet || undefined, - isLoading, - /** - * True while a trial wallet creation is in flight, regardless of which hook instance fired it. - */ - isCreating: isCreatingFromAnyInstance, /** - * True only during the initial wallet-existence lookup — never while a trial wallet is being created. - * Consumers gating on "do we yet know the user's wallet situation?" (the wallet boot gate) use this so a - * provisioning trial reads as known identity and doesn't blank the page with a full-screen loader. + * True only during the initial wallet-existence lookup. Consumers gating on "do we yet know the user's + * wallet situation?" (the wallet boot gate) use this so a provisioning trial reads as known identity and + * doesn't blank the page with a full-screen loader. */ isInitializing: isInitialLoading, isFetching, - createError, - resetCreate, refetch }; - }, [wallet, isLoading, isCreatingFromAnyInstance, isInitialLoading, isFetching, createError, resetCreate, refetch, user?.id, create]); + }, [wallet, isInitialLoading, isFetching, refetch]); }; diff --git a/apps/deploy-web/src/hooks/useOnboardingChrome.spec.ts b/apps/deploy-web/src/hooks/useOnboardingChrome.spec.ts index 1553c33216..2725648377 100644 --- a/apps/deploy-web/src/hooks/useOnboardingChrome.spec.ts +++ b/apps/deploy-web/src/hooks/useOnboardingChrome.spec.ts @@ -3,7 +3,6 @@ import { mock } from "vitest-mock-extended"; import type { DEPENDENCIES } from "@src/hooks/useOnboardingChrome"; import { useOnboardingChrome } from "@src/hooks/useOnboardingChrome"; -import type { AppError } from "@src/types"; import type { CustomUserProfile } from "@src/types/user"; import { renderHook } from "@testing-library/react"; @@ -65,19 +64,6 @@ describe(useOnboardingChrome.name, () => { expect(result.current).toEqual({ isStripped: true }); }); - it("shows full chrome when the wallet errors", () => { - const { dependencies } = setup({ - pathname: "/new-deployment/configure", - leaseCount: 0, - hasWallet: false, - walletError: mock() - }); - - const { result } = renderHook(() => useOnboardingChrome(dependencies)); - - expect(result.current).toEqual({ isStripped: false }); - }); - it("shows full chrome when the leases query errors for an existing wallet", () => { const { dependencies } = setup({ pathname: "/new-deployment/configure", isLeasesError: true }); @@ -100,14 +86,12 @@ describe(useOnboardingChrome.name, () => { isLeasesLoading?: boolean; isLeasesError?: boolean; hasWallet?: boolean; - walletError?: AppError; onboardingSkippedAt?: string | null; }) { const useWallet: typeof DEPENDENCIES.useWallet = () => mock>({ address: "akash1test", - hasWallet: input.hasWallet ?? true, - walletError: input.walletError + hasWallet: input.hasWallet ?? true }); const usePathname: typeof DEPENDENCIES.usePathname = () => input.pathname; const useLeaseExistenceQuery = (() => diff --git a/apps/deploy-web/src/hooks/useOnboardingChrome.ts b/apps/deploy-web/src/hooks/useOnboardingChrome.ts index b6d00c661c..ffa2a5a1d5 100644 --- a/apps/deploy-web/src/hooks/useOnboardingChrome.ts +++ b/apps/deploy-web/src/hooks/useOnboardingChrome.ts @@ -27,7 +27,7 @@ export type OnboardingChromeState = { * is shared (same key) with the gate, so it's usually cached and resolves without a spinner. */ export const useOnboardingChrome = (d: typeof DEPENDENCIES = DEPENDENCIES): OnboardingChromeState => { - const { address, hasWallet, walletError } = d.useWallet(); + const { address, hasWallet } = d.useWallet(); const { user } = d.useUser(); const pathname = d.usePathname(); @@ -39,11 +39,11 @@ export const useOnboardingChrome = (d: typeof DEPENDENCIES = DEPENDENCIES): Onbo const isOnboarded = hasWalletAddress && !!leaseExistenceQuery.data; const leasesErrored = hasWalletAddress && leaseExistenceQuery.isError; - // A wallet error — or a leases error that leaves onboarding unknowable (an undefined result is not "no leases") — - // makes the funnel decision unresolvable, so fail open to the full chrome rather than trap a possibly-onboarded - // user in the stripped funnel. Mirrors the gate's fail-open on a transient chain-API blip. A user who has skipped - // onboarding is treated as onboarded, so their chrome is never stripped when they return to the configure route. - if (!isRelevant || walletError || leasesErrored || hasSkippedOnboarding) { + // A leases error that leaves onboarding unknowable (an undefined result is not "no leases") makes the funnel + // decision unresolvable, so fail open to the full chrome rather than trap a possibly-onboarded user in the + // stripped funnel. Mirrors the gate's fail-open on a transient chain-API blip. A user who has skipped onboarding + // is treated as onboarded, so their chrome is never stripped when they return to the configure route. + if (!isRelevant || leasesErrored || hasSkippedOnboarding) { return { isStripped: false }; } diff --git a/apps/deploy-web/src/lib/nextjs/api-routes-specs/auth-email-code-verify.spec.ts b/apps/deploy-web/src/lib/nextjs/api-routes-specs/auth-email-code-verify.spec.ts index 9cf51a2f6e..eba8fb3f73 100644 --- a/apps/deploy-web/src/lib/nextjs/api-routes-specs/auth-email-code-verify.spec.ts +++ b/apps/deploy-web/src/lib/nextjs/api-routes-specs/auth-email-code-verify.spec.ts @@ -27,6 +27,17 @@ describe("POST /api/auth/email-code-verify", () => { expect(res.status).toHaveBeenCalledWith(204); }); + it("merges the local user settings into the session before persisting it", async () => { + const session = Object.assign(new Session({ sub: "auth0|email|abc", email: "user@example.com" }), { accessToken: "at" }); + const { setSession } = await callHandler({ + body: { email: "user@example.com", code: "123456", captchaToken: "tok" }, + verifyResult: Ok(session), + userSettings: { userId: "user-123", username: "alice", subscribedToNewsletter: true } + }); + + expect(setSession.mock.calls[0][2].user).toEqual(expect.objectContaining({ userId: "user-123", username: "alice", subscribedToNewsletter: true })); + }); + it("sets the account-created cookie when the user is newly created", async () => { const session = Object.assign(new Session({ sub: "auth0|email|abc", email: "user@example.com" }), { accessToken: "at" }); const { res } = await callHandler({ @@ -98,6 +109,7 @@ describe("POST /api/auth/email-code-verify", () => { verifyResult?: Awaited>; createLocalUserError?: Error; isNewUser?: boolean; + userSettings?: Awaited>["userSettings"]; expectThrow?: boolean; }) { const sessionService = mock(); @@ -107,7 +119,10 @@ describe("POST /api/auth/email-code-verify", () => { if (input.createLocalUserError) { sessionService.createLocalUser.mockRejectedValue(input.createLocalUserError); } else { - sessionService.createLocalUser.mockResolvedValue({ userSettings: { username: "user", subscribedToNewsletter: false }, isNewUser: input.isNewUser ?? false }); + sessionService.createLocalUser.mockResolvedValue({ + userSettings: input.userSettings ?? { username: "user", subscribedToNewsletter: false }, + isNewUser: input.isNewUser ?? false + }); } const logger = mock(); diff --git a/apps/deploy-web/src/pages/api/auth/email-code-verify.ts b/apps/deploy-web/src/pages/api/auth/email-code-verify.ts index bf3031c986..c126ba36b5 100644 --- a/apps/deploy-web/src/pages/api/auth/email-code-verify.ts +++ b/apps/deploy-web/src/pages/api/auth/email-code-verify.ts @@ -23,7 +23,8 @@ export default defineApiHandler({ const result = await services.sessionService.verifyEmailCode({ email: body.email, code: body.code }); if (result.ok) { - const { isNewUser } = await services.sessionService.createLocalUser(result.val); + const { userSettings, isNewUser } = await services.sessionService.createLocalUser(result.val); + result.val.user = { ...result.val.user, ...userSettings }; await services.setSession(req, res, result.val); if (isNewUser) setAccountCreatedCookie(res); res.status(204).end(); diff --git a/apps/deploy-web/src/queries/queryKeys.ts b/apps/deploy-web/src/queries/queryKeys.ts index 6d070ea499..444cfbe8d1 100644 --- a/apps/deploy-web/src/queries/queryKeys.ts +++ b/apps/deploy-web/src/queries/queryKeys.ts @@ -76,13 +76,6 @@ export class QueryKeys { static getManagedWalletKey = (userId?: string) => ["MANAGED_WALLET", userId || ""]; - /** - * Mutation key for creating/starting a managed (trial) wallet. It tags the create mutation so an - * in-flight create is discoverable from any component via `useIsMutating`, regardless of which - * `useManagedWallet` instance fired it (e.g. the onboarding picker vs. the persistent WalletProvider). - */ - static getManagedWalletCreateMutationKey = () => ["MANAGED_WALLET_CREATE"]; - static getExportTransactionsCsvKey = (options: { startDate?: Date | null; endDate?: Date | null; timezone: string }) => { const key = ["EXPORT_TRANSACTIONS_CSV", options.timezone]; diff --git a/apps/deploy-web/src/queries/useManagedWalletQuery.spec.tsx b/apps/deploy-web/src/queries/useManagedWalletQuery.spec.tsx index c532ee8bb5..c52467f90e 100644 --- a/apps/deploy-web/src/queries/useManagedWalletQuery.spec.tsx +++ b/apps/deploy-web/src/queries/useManagedWalletQuery.spec.tsx @@ -1,78 +1,42 @@ import type { ManagedWalletHttpService } from "@akashnetwork/http-sdk"; import { faker } from "@faker-js/faker"; -import { useQueryClient } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import { useCreateManagedWalletMutation, useManagedWalletQuery } from "./useManagedWalletQuery"; +import { useManagedWalletQuery } from "./useManagedWalletQuery"; -import { act } from "@testing-library/react"; import { setupQuery } from "@tests/unit/query-client"; describe(useManagedWalletQuery.name, () => { - describe(useManagedWalletQuery.name, () => { - it("should fetch wallet when userId is provided", async () => { - const mockData = { - userId: faker.string.uuid(), - address: faker.finance.ethereumAddress() - }; - const managedWalletService = mock({ - getWallet: vi.fn().mockResolvedValue(mockData) - }); - - const { result } = setupQuery(() => useManagedWalletQuery(mockData.userId), { - services: { managedWalletService: () => managedWalletService } - }); - - await vi.waitFor(() => { - expect(managedWalletService.getWallet).toHaveBeenCalledWith({ userId: mockData.userId }); - expect(result.current.isSuccess).toBe(true); - expect(result.current.data).toEqual(mockData); - }); + it("fetches the wallet when userId is provided", async () => { + const mockData = { + userId: faker.string.uuid(), + address: faker.finance.ethereumAddress() + }; + const managedWalletService = mock({ + getWallet: vi.fn().mockResolvedValue(mockData) }); - it("should not fetch when userId is not provided", () => { - const managedWalletService = mock({ - getWallet: vi.fn().mockResolvedValue({}) - }); - const { result } = setupQuery(() => useManagedWalletQuery(), { - services: { managedWalletService: () => managedWalletService } - }); + const { result } = setupQuery(() => useManagedWalletQuery(mockData.userId), { + services: { managedWalletService: () => managedWalletService } + }); - expect(managedWalletService.getWallet).not.toHaveBeenCalled(); - expect(result.current.isLoading).toBe(false); + await vi.waitFor(() => { + expect(managedWalletService.getWallet).toHaveBeenCalledWith({ userId: mockData.userId }); + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toEqual(mockData); }); }); - describe(useCreateManagedWalletMutation.name, () => { - it("should create wallet and update query cache", async () => { - const mockData = { - userId: faker.string.uuid(), - address: faker.finance.ethereumAddress() - }; - const mockManagedWalletService = mock({ - createWallet: vi.fn().mockResolvedValue(mockData) - }); - - const { result } = setupQuery( - () => { - const mutation = useCreateManagedWalletMutation(); - const queryClient = useQueryClient(); - - return { mutation, queryClient }; - }, - { - services: { managedWalletService: () => mockManagedWalletService } - } - ); - - await act(async () => result.current.mutation.mutateAsync(mockData.userId)); - - await vi.waitFor(() => { - expect(mockManagedWalletService.createWallet).toHaveBeenCalledWith(mockData.userId); - expect(result.current.mutation.isSuccess).toBe(true); - expect(result.current.queryClient.getQueryData(["MANAGED_WALLET", mockData.userId])).toEqual(mockData); - }); + it("does not fetch when userId is not provided", () => { + const managedWalletService = mock({ + getWallet: vi.fn().mockResolvedValue({}) }); + const { result } = setupQuery(() => useManagedWalletQuery(), { + services: { managedWalletService: () => managedWalletService } + }); + + expect(managedWalletService.getWallet).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); }); }); diff --git a/apps/deploy-web/src/queries/useManagedWalletQuery.ts b/apps/deploy-web/src/queries/useManagedWalletQuery.ts index 31fea4e633..42d35d91e1 100644 --- a/apps/deploy-web/src/queries/useManagedWalletQuery.ts +++ b/apps/deploy-web/src/queries/useManagedWalletQuery.ts @@ -1,5 +1,5 @@ import type { QueryKey } from "@tanstack/react-query"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useServices } from "@src/context/ServicesProvider/ServicesProvider"; import { QueryKeys } from "./queryKeys"; @@ -25,20 +25,3 @@ export function useManagedWalletQuery(userId?: string) { } }); } - -export function useCreateManagedWalletMutation() { - const { managedWalletService } = useServices(); - const queryClient = useQueryClient(); - return useMutation({ - mutationKey: QueryKeys.getManagedWalletCreateMutationKey(), - mutationFn: async (userId: string) => await managedWalletService.createWallet(userId), - retry: failureCount => failureCount < 3, - retryDelay: attempt => Math.min(1000 * 2 ** attempt, 30_000), - onSuccess: response => { - // Only update cache if it's a wallet response, not a 3D Secure response - if (!response.requires3DS) { - queryClient.setQueryData(QueryKeys.getManagedWalletKey(response.userId), () => response); - } - } - }); -} diff --git a/apps/deploy-web/src/services/analytics/analytics.service.ts b/apps/deploy-web/src/services/analytics/analytics.service.ts index c50ea52643..bf828232e7 100644 --- a/apps/deploy-web/src/services/analytics/analytics.service.ts +++ b/apps/deploy-web/src/services/analytics/analytics.service.ts @@ -73,7 +73,6 @@ export type AnalyticsEvent = | "user_settings_save" | "anonymous_user_created" | "account_created" - | "trial_started" | "trial_completed" | "create_api_key" | "delete_api_key" @@ -99,14 +98,6 @@ export type AnalyticsEvent = | "redeploy_btn_clk" | "edit_name_btn_clk" | "create_deployment_btn_clk" - | "onboarding_step_started" - | "onboarding_step_completed" - | "onboarding_free_trial_started" - | "onboarding_account_created" - | "onboarding_email_verified" - | "onboarding_payment_method_added" - | "onboarding_completed" - | "onboarding_logout" | "log_collector_enabled" | "log_collector_disabled" | "log_collector_deployed" diff --git a/apps/deploy-web/src/services/app-di-container/app-di-container.ts b/apps/deploy-web/src/services/app-di-container/app-di-container.ts index d2658dd474..253870d222 100644 --- a/apps/deploy-web/src/services/app-di-container/app-di-container.ts +++ b/apps/deploy-web/src/services/app-di-container/app-di-container.ts @@ -145,15 +145,7 @@ export const createAppRootContainer = (config: ServicesConfig) => { apiUrlService: config.apiUrlService, managedWalletService: () => { const httpClient = container.applyAxiosInterceptors(createHttpClient(apiConfig), { - request: [withUserToken], - response: [ - response => { - if (response.config.url === "v1/start-trial" && response.config.method === "post" && response.status === 200) { - container.analyticsService.track("trial_started", { category: "billing", label: "Trial Started" }); - } - return response; - } - ] + request: [withUserToken] }); return new ManagedWalletHttpService(httpClient); }, diff --git a/apps/deploy-web/tests/seeders/wallet.ts b/apps/deploy-web/tests/seeders/wallet.ts index eff98e75cf..87b7c3c147 100644 --- a/apps/deploy-web/tests/seeders/wallet.ts +++ b/apps/deploy-web/tests/seeders/wallet.ts @@ -8,13 +8,10 @@ export const genWalletAddress = () => `akash${faker.string.alphanumeric({ length export const buildWallet = (overrides: Partial = {}): WalletProviderContextType => ({ address: genWalletAddress(), hasWallet: true, - isWalletCreating: false, - createWallet: vi.fn(), signAndBroadcastTx: vi.fn(), denom: "uact", isTrialing: false, creditAmount: faker.number.float({ min: 0, max: 1000 }), topUpMinAmountUsd: 20, - walletError: undefined, ...overrides }); diff --git a/packages/console-api-types/src/schema.d.ts b/packages/console-api-types/src/schema.d.ts index 136778d676..cd6aa6bb89 100644 --- a/packages/console-api-types/src/schema.d.ts +++ b/packages/console-api-types/src/schema.d.ts @@ -4,94 +4,6 @@ */ export interface paths { - "/v1/start-trial": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Start a trial period for a user - * @description Creates a managed wallet for a user and initiates a trial period. This endpoint handles payment method validation and may require 3D Secure authentication for certain payment methods. Returns wallet information and trial status. - */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": { - data: { - userId: string; - }; - }; - }; - }; - responses: { - /** @description Trial started successfully and wallet created */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - data: { - id: number | null; - userId: string | null; - creditAmount: number; - address: string | null; - denom: string; - isTrialing: boolean; - /** @description Minimum USD amount accepted by the next paid top-up for this wallet. */ - topUpMinAmountUsd: number; - createdAt: string | null; - requires3DS?: boolean; - clientSecret?: string | null; - paymentIntentId?: string | null; - paymentMethodId?: string | null; - }; - }; - }; - }; - /** @description 3D Secure authentication required to complete trial setup */ - 202: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - data: { - id: number | null; - userId: string | null; - creditAmount: number; - address: string | null; - denom: string; - isTrialing: boolean; - /** @description Minimum USD amount accepted by the next paid top-up for this wallet. */ - topUpMinAmountUsd: number; - createdAt: string | null; - requires3DS?: boolean; - clientSecret?: string | null; - paymentIntentId?: string | null; - paymentMethodId?: string | null; - }; - }; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/v1/wallets": { parameters: { query?: never; @@ -129,10 +41,6 @@ export interface paths { /** @description Minimum USD amount accepted by the next paid top-up for this wallet. */ topUpMinAmountUsd: number; createdAt: string | null; - requires3DS?: boolean; - clientSecret?: string | null; - paymentIntentId?: string | null; - paymentMethodId?: string | null; }[]; }; }; diff --git a/packages/http-sdk/src/managed-wallet-http/managed-wallet-http.service.ts b/packages/http-sdk/src/managed-wallet-http/managed-wallet-http.service.ts index 52dead55df..b767e725e5 100644 --- a/packages/http-sdk/src/managed-wallet-http/managed-wallet-http.service.ts +++ b/packages/http-sdk/src/managed-wallet-http/managed-wallet-http.service.ts @@ -13,31 +13,12 @@ export interface ApiWalletOutput { createdAt: Date; } -export interface ApiThreeDSecureAuth { - requires3DS: boolean; - clientSecret: string; - paymentIntentId: string; - paymentMethodId: string; -} - -export interface ApiWalletWithOptional3DS extends ApiWalletOutput { - requires3DS?: boolean; - clientSecret?: string; - paymentIntentId?: string; - paymentMethodId?: string; -} - export class ManagedWalletHttpService { readonly #httpClient: HttpClient; constructor(httpClient: HttpClient) { this.#httpClient = httpClient; } - async createWallet(userId: string): Promise { - const response = await this.#httpClient.post>("v1/start-trial", { data: { userId } }, { withCredentials: true }); - - return this.addWalletEssentials(extractData(response).data); - } async getWallet(input: { [key: string]: string; userId: string }): Promise { const response = await this.#httpClient.get>("v1/wallets", { params: input });