From c96270c2237b5062eef5199fe59500cf14055135 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 2 Mar 2026 16:12:42 +0900 Subject: [PATCH] docs: add get-started login handoff implementation plan 7-task plan covering domain schemas, BFF workflow service, controller endpoints, portal API client, AccountStatusStep redirect, and LoginForm handoff mode adaptation. --- ...26-03-02-get-started-login-handoff-plan.md | 830 ++++++++++++++++++ 1 file changed, 830 insertions(+) create mode 100644 docs/plans/2026-03-02-get-started-login-handoff-plan.md diff --git a/docs/plans/2026-03-02-get-started-login-handoff-plan.md b/docs/plans/2026-03-02-get-started-login-handoff-plan.md new file mode 100644 index 00000000..5916aeb4 --- /dev/null +++ b/docs/plans/2026-03-02-get-started-login-handoff-plan.md @@ -0,0 +1,830 @@ +# Get-Started Login Handoff Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** After OTP verification detects an existing portal account, hand off to the login page via a secure cookie so the user only enters their password (no second OTP). + +**Architecture:** BFF sets an HttpOnly `gs_verified` cookie during verify-code when `portal_exists` is detected. Login page calls a check-session endpoint to detect handoff mode, then calls a new login endpoint that validates the session + password and issues auth tokens. Frontend redirects from get-started to login page instead of rendering inline LoginForm. + +**Tech Stack:** NestJS (BFF), Zod (validation), Redis (sessions), Argon2 (password), XState (frontend state), React (portal) + +--- + +### Task 1: Add domain schemas and types for login handoff + +**Files:** + +- Modify: `packages/domain/get-started/schema.ts` +- Modify: `packages/domain/get-started/contract.ts` +- Modify: `packages/domain/get-started/index.ts` + +**Step 1: Add schemas to `packages/domain/get-started/schema.ts`** + +Add after the `migrateWhmcsAccountRequestSchema` (after line 310), before the Session Schema section: + +```typescript +// ============================================================================ +// Login Handoff Schemas (for portal_exists users after OTP verification) +// ============================================================================ + +/** + * Request to log in via verified get-started session + * Session token comes from gs_verified cookie, not request body + */ +export const getStartedLoginRequestSchema = z.object({ + /** Password for the existing portal account */ + password: z.string().min(1, "Password is required"), + /** Whether to set a trusted device cookie for future logins */ + rememberDevice: z.boolean().optional().default(false), +}); + +/** + * Response from check-session endpoint + * Tells the login page whether a valid get-started handoff session exists + */ +export const checkSessionResponseSchema = z.object({ + /** Whether a valid verified session exists */ + valid: z.boolean(), + /** Email from the session (only present when valid) */ + email: z.string().optional(), +}); +``` + +**Step 2: Add types to `packages/domain/get-started/contract.ts`** + +Add the schema imports to the existing import block (line 14-28): + +```typescript +import type { + // ... existing imports ... + getStartedLoginRequestSchema, + checkSessionResponseSchema, +} from "./schema.js"; +``` + +Add types after `MigrateWhmcsAccountRequest` (after line 94): + +```typescript +export type GetStartedLoginRequest = z.infer; +export type CheckSessionResponse = z.infer; +``` + +**Step 3: Add exports to `packages/domain/get-started/index.ts`** + +Add to the contract exports section (after `type GetStartedError` on line 37): + +```typescript + type GetStartedLoginRequest, + type CheckSessionResponse, +``` + +Add to the schemas export section (after `getStartedSessionSchema` on line 64): + +```typescript + // Login handoff schemas (for portal_exists OTP bypass) + getStartedLoginRequestSchema, + checkSessionResponseSchema, +``` + +**Step 4: Build domain and verify** + +Run: `pnpm domain:build && pnpm type-check` +Expected: Clean build, no errors. + +**Step 5: Commit** + +```bash +git add packages/domain/get-started/ +git commit -m "feat(domain): add login handoff schemas for portal_exists OTP bypass" +``` + +--- + +### Task 2: Create the login handoff workflow service + +**Files:** + +- Create: `apps/bff/src/modules/auth/infra/workflows/get-started-login-workflow.service.ts` + +**Step 1: Create the workflow service** + +This service validates a verified get-started session, checks the user's password, and completes the login. It reuses `AuthLoginService` for password validation and `AuthOrchestrator.completeLogin()` for token generation. + +```typescript +/** + * Get-Started Login Workflow + * + * Handles the login-via-verified-session flow for portal_exists users. + * After OTP verification in the get-started flow, this service validates + * the user's password and completes login without requiring a second OTP. + */ +import { Injectable, Inject, UnauthorizedException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { ACCOUNT_STATUS } from "@customer-portal/domain/get-started"; + +import { GetStartedSessionService } from "../otp/get-started-session.service.js"; +import { AuthLoginService } from "../../application/auth-login.service.js"; +import { AuthOrchestrator } from "../../application/auth-orchestrator.service.js"; +import type { AuthResultInternal } from "../../auth.types.js"; + +@Injectable() +export class GetStartedLoginWorkflowService { + constructor( + private readonly sessionService: GetStartedSessionService, + private readonly loginService: AuthLoginService, + private readonly authOrchestrator: AuthOrchestrator, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Validate a verified get-started session and check password. + * Returns auth tokens on success. + * + * @param sessionToken - From the gs_verified cookie + * @param password - User's portal password + */ + async execute(sessionToken: string, password: string): Promise { + // 1. Acquire lock and mark session as used (prevents replay) + const lockResult = await this.sessionService.acquireAndMarkAsUsed(sessionToken, "login"); + + if (!lockResult.success) { + this.logger.warn( + { sessionToken, reason: lockResult.reason }, + "Login handoff session invalid" + ); + throw new UnauthorizedException("Session is invalid or has already been used"); + } + + const session = lockResult.session; + + // 2. Verify the session was for a portal_exists account + if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) { + this.logger.warn( + { sessionToken, accountStatus: session.accountStatus }, + "Login handoff session has wrong account status" + ); + throw new UnauthorizedException("Session is not valid for login"); + } + + // 3. Validate password via existing login service (handles lockout, audit, etc.) + const validatedUser = await this.loginService.validateUser(session.email, password); + + if (!validatedUser) { + throw new UnauthorizedException("Invalid password"); + } + + // 4. Complete login (generates tokens, updates lastLoginAt, audit log) + this.logger.log({ email: session.email }, "Login handoff successful — completing login"); + return this.authOrchestrator.completeLogin(validatedUser); + } + + /** + * Check if a session token is valid for login handoff. + * Does NOT consume the session. + */ + async checkSession( + sessionToken: string + ): Promise<{ valid: true; email: string } | { valid: false }> { + const session = await this.sessionService.validateVerifiedSession(sessionToken); + + if (!session) { + return { valid: false }; + } + + if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) { + return { valid: false }; + } + + return { valid: true, email: session.email }; + } +} +``` + +**Step 2: Verify types compile** + +Run: `pnpm type-check` +Expected: Clean — the new file is not imported yet so it won't affect compilation, but any import errors in the file itself will be caught when registered in the module (Task 3). + +--- + +### Task 3: Register workflow service and add controller endpoints + +**Files:** + +- Modify: `apps/bff/src/modules/auth/get-started/get-started.module.ts` +- Modify: `apps/bff/src/modules/auth/presentation/http/get-started.controller.ts` + +**Step 1: Register the workflow in the module** + +In `apps/bff/src/modules/auth/get-started/get-started.module.ts`: + +Add import after line 15 (after `WhmcsMigrationWorkflowService` import): + +```typescript +import { GetStartedLoginWorkflowService } from "../infra/workflows/get-started-login-workflow.service.js"; +``` + +Add to the providers array (after `WhmcsMigrationWorkflowService` on line 51): + +```typescript + GetStartedLoginWorkflowService, +``` + +Add to the module imports array — the service needs `AuthLoginService` and `AuthOrchestrator`, which live in `LoginModule` and `AuthModule` respectively. Since `GetStartedModule` is imported by `AuthModule`, and `LoginModule` is also imported by `AuthModule`, we need `LoginModule` and the orchestrator available. Check: `LoginModule` exports `AuthLoginService`. The `AuthOrchestrator` is provided in `AuthModule` itself. To avoid circular dependencies, we'll inject `AuthOrchestrator` and `AuthLoginService` directly. `LoginModule` already exports `AuthLoginService`, and `GetStartedModule` already imports from `AuthModule`'s sibling modules. + +Add `LoginModule` to the imports array (after `OtpModule` on line 37): + +```typescript +import { LoginModule } from "../login/login.module.js"; +``` + +And in the `imports` array: + +```typescript + LoginModule, +``` + +The `AuthOrchestrator` is provided in `AuthModule` but not exported. We need to either export it or inject it differently. Looking at the existing pattern: `GetStartedModule` doesn't currently depend on `AuthOrchestrator`. Let's check if we can avoid it. + +Actually, looking more closely at the code, `AuthOrchestrator.completeLogin()` does: + +1. `usersService.update()` — available via `UsersModule` +2. `auditService.logAuthEvent()` — available via `AuditModule` +3. `tokenService.generateTokenPair()` — available via `TokensModule` +4. `updateAccountLastSignIn()` — SF call (nice-to-have) + +The cleanest approach: have the workflow service call `completeLogin` directly using the same dependencies, without depending on `AuthOrchestrator`. But that duplicates logic. + +**Better approach**: Export `AuthOrchestrator` from `AuthModule`. In `apps/bff/src/modules/auth/auth.module.ts`, it's already exported on line 46: `exports: [AuthOrchestrator, TokensModule, SharedAuthModule]`. So this should work via module resolution. + +Wait — that creates a circular dependency: `AuthModule` imports `GetStartedModule`, and `GetStartedModule` would need `AuthModule` for `AuthOrchestrator`. + +**Simplest fix**: Move the token generation into the workflow service directly, reusing `AuthTokenService` (already available via `TokensModule`) and `UsersService` (already available via `UsersModule`). This matches the pattern in `WhmcsMigrationWorkflowService` which uses `GenerateAuthResultStep`. + +Let's use the existing `GenerateAuthResultStep` (already registered in `GetStartedModule` providers): + +```typescript +// In get-started-login-workflow.service.ts, replace AuthOrchestrator with: +import { GenerateAuthResultStep } from "./steps/index.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; +import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; +``` + +**Revised workflow service** — update the `execute` method to use `GenerateAuthResultStep` + `UsersService` instead of `AuthOrchestrator`: + +```typescript +import { Injectable, Inject, UnauthorizedException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { ACCOUNT_STATUS } from "@customer-portal/domain/get-started"; + +import { GetStartedSessionService } from "../otp/get-started-session.service.js"; +import { AuthLoginService } from "../../application/auth-login.service.js"; +import { GenerateAuthResultStep } from "./steps/index.js"; +import { UsersService } from "@bff/modules/users/application/users.service.js"; +import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; +import type { AuthResultInternal } from "../../auth.types.js"; + +@Injectable() +export class GetStartedLoginWorkflowService { + constructor( + private readonly sessionService: GetStartedSessionService, + private readonly loginService: AuthLoginService, + private readonly generateAuthResult: GenerateAuthResultStep, + private readonly usersService: UsersService, + private readonly auditService: AuditService, + @Inject(Logger) private readonly logger: Logger + ) {} + + async execute(sessionToken: string, password: string): Promise { + // 1. Acquire lock and mark session as used (prevents replay) + const lockResult = await this.sessionService.acquireAndMarkAsUsed(sessionToken, "login"); + + if (!lockResult.success) { + this.logger.warn( + { sessionToken, reason: lockResult.reason }, + "Login handoff session invalid" + ); + throw new UnauthorizedException("Session is invalid or has already been used"); + } + + const session = lockResult.session; + + // 2. Verify the session was for a portal_exists account + if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) { + this.logger.warn( + { sessionToken, accountStatus: session.accountStatus }, + "Login handoff session has wrong account status" + ); + throw new UnauthorizedException("Session is not valid for login"); + } + + // 3. Validate password (handles lockout, audit, failed attempt tracking) + const validatedUser = await this.loginService.validateUser(session.email, password); + + if (!validatedUser) { + throw new UnauthorizedException("Invalid password"); + } + + // 4. Update lastLoginAt and reset failed attempts + await this.usersService.update(validatedUser.id, { + lastLoginAt: new Date(), + failedLoginAttempts: 0, + lockedUntil: null, + }); + + // 5. Generate auth tokens + const result = await this.generateAuthResult.execute(validatedUser.id); + + // 6. Audit log + await this.auditService.logAuthEvent(AuditAction.LOGIN_SUCCESS, validatedUser.id, { + email: session.email, + method: "get_started_handoff", + }); + + this.logger.log({ email: session.email }, "Login handoff completed"); + return result; + } + + async checkSession( + sessionToken: string + ): Promise<{ valid: true; email: string } | { valid: false }> { + const session = await this.sessionService.validateVerifiedSession(sessionToken); + + if (!session || session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) { + return { valid: false }; + } + + return { valid: true, email: session.email }; + } +} +``` + +**Step 2: Add controller endpoints** + +In `apps/bff/src/modules/auth/presentation/http/get-started.controller.ts`: + +Add imports at top (after existing schema imports around line 24): + +```typescript +import { + // ... existing imports ... + getStartedLoginRequestSchema, + checkSessionResponseSchema, +} from "@customer-portal/domain/get-started"; +``` + +Add DTO classes (after `MigrateWhmcsAccountRequestDto` around line 40): + +```typescript +class GetStartedLoginRequestDto extends createZodDto(getStartedLoginRequestSchema) {} +class CheckSessionResponseDto extends createZodDto(checkSessionResponseSchema) {} +``` + +Import the new workflow service (after `GetStartedCoordinator` import on line 27): + +```typescript +import { GetStartedLoginWorkflowService } from "../../infra/workflows/get-started-login-workflow.service.js"; +``` + +Add to the controller constructor: + +```typescript +constructor( + private readonly workflow: GetStartedCoordinator, + private readonly loginWorkflow: GetStartedLoginWorkflowService +) {} +``` + +Modify the `verifyCode` method to set the handoff cookie when `portal_exists` is detected. Add `@Res({ passthrough: true })` to the method signature: + +```typescript + @Public() + @Post("verify-code") + @HttpCode(200) + @UseGuards(RateLimitGuard) + @RateLimit({ limit: 10, ttl: 300 }) + async verifyCode( + @Body() body: VerifyCodeRequestDto, + @Req() req: Request, + @Res({ passthrough: true }) res: Response + ): Promise { + const fingerprint = getRateLimitFingerprint(req); + const result = await this.workflow.verifyCode(body, fingerprint); + + // Set handoff cookie for portal_exists users + if (result.verified && result.accountStatus === "portal_exists" && result.sessionToken) { + const isProduction = process.env["NODE_ENV"] === "production"; + res.cookie("gs_verified", result.sessionToken, { + httpOnly: true, + secure: isProduction, + sameSite: "strict", + path: "/api/auth/get-started", + maxAge: 5 * 60 * 1000, // 5 minutes + }); + } + + return result; + } +``` + +Add the two new endpoints (after the `migrateWhmcsAccount` method, before the closing `}` of the class): + +```typescript + /** + * Check if a valid get-started handoff session exists + * Used by the login page to detect OTP-bypass mode + */ + @Public() + @Get("check-session") + async checkSession( + @Req() req: Request + ): Promise { + const sessionToken = req.cookies?.["gs_verified"] as string | undefined; + + if (!sessionToken) { + return { valid: false }; + } + + return this.loginWorkflow.checkSession(sessionToken); + } + + /** + * Login with verified get-started session + password + * + * For portal_exists users after OTP verification in get-started flow. + * Session token comes from the gs_verified HttpOnly cookie. + * Skips login OTP since email was already verified via get-started OTP. + */ + @Public() + @Post("login") + @HttpCode(200) + @UseGuards(RateLimitGuard) + @RateLimit({ limit: 5, ttl: 300 }) + async loginWithSession( + @Body() body: GetStartedLoginRequestDto, + @Req() req: Request, + @Res({ passthrough: true }) res: Response + ): Promise { + const sessionToken = req.cookies?.["gs_verified"] as string | undefined; + + if (!sessionToken) { + throw new UnauthorizedException("No verified session found"); + } + + const result = await this.loginWorkflow.execute(sessionToken, body.password); + + // Set auth cookies + setAuthCookies(res, result.tokens); + + // Clear the handoff cookie + res.clearCookie("gs_verified", { + path: "/api/auth/get-started", + }); + + return { + user: result.user, + session: buildSessionInfo(result.tokens), + }; + } +``` + +Add the `Get` and `UnauthorizedException` imports from `@nestjs/common` (line 1): + +```typescript +import { Controller, Post, Get, Body, UseGuards, Res, Req, HttpCode } from "@nestjs/common"; +``` + +Also add `UnauthorizedException`: + +```typescript +import { + Controller, + Post, + Get, + Body, + UseGuards, + Res, + Req, + HttpCode, + UnauthorizedException, +} from "@nestjs/common"; +``` + +**Step 3: Build and verify** + +Run: `pnpm domain:build && pnpm type-check` +Expected: Clean build. + +**Step 4: Commit** + +```bash +git add apps/bff/src/modules/auth/ +git commit -m "feat(bff): add login handoff endpoints and workflow service" +``` + +--- + +### Task 4: Add get-started API client functions (portal) + +**Files:** + +- Modify: `apps/portal/src/features/get-started/api/get-started.api.ts` + +**Step 1: Add API functions** + +Add imports for the new types (extend existing import from `@customer-portal/domain/get-started`): + +```typescript +import { + // ... existing imports ... + checkSessionResponseSchema, + type GetStartedLoginRequest, + type CheckSessionResponse, +} from "@customer-portal/domain/get-started"; +``` + +Add two new functions at the bottom of the file (before the closing): + +```typescript +/** + * Check if a valid get-started handoff session exists + * Used by login page to detect OTP-bypass mode + */ +export async function checkGetStartedSession(): Promise { + const response = await apiClient.GET(`${BASE_PATH}/check-session`); + const data = getDataOrThrow(response, "Failed to check session"); + return checkSessionResponseSchema.parse(data); +} + +/** + * Login with verified get-started session + password + * Session token is sent via HttpOnly cookie automatically + */ +export async function loginWithGetStartedSession( + request: GetStartedLoginRequest +): Promise { + const response = await apiClient.POST(`${BASE_PATH}/login`, { + body: request, + }); + const data = getDataOrThrow(response, "Failed to log in"); + return authResponseSchema.parse(data); +} +``` + +**Step 2: Verify** + +Run: `pnpm type-check` +Expected: Clean. + +**Step 3: Commit** + +```bash +git add apps/portal/src/features/get-started/api/get-started.api.ts +git commit -m "feat(portal): add login handoff API client functions" +``` + +--- + +### Task 5: Update AccountStatusStep to redirect to login page + +**Files:** + +- Modify: `apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx` + +**Step 1: Replace the component** + +Replace the entire component. Both inline and full-page modes now redirect to the login page (the cookie is already set by the BFF): + +```typescript +/** + * AccountStatusStep - Redirects portal_exists users to login page + * + * After OTP verification detects an existing account, the BFF sets + * a gs_verified HttpOnly cookie. This component redirects to the + * login page, which detects the cookie and shows password-only mode. + */ + +"use client"; + +import { useEffect } from "react"; +import { Loader2 } from "lucide-react"; +import { getSafeRedirect } from "@/features/auth/utils/route-protection"; +import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine"; + +export function AccountStatusStep() { + const { state } = useGetStartedMachine(); + const { formData, redirectTo, serviceContext } = state.context; + + const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account"); + + useEffect(() => { + const loginUrl = + `/auth/login?email=${encodeURIComponent(formData.email)}` + + `&from=get-started` + + `&redirect=${encodeURIComponent(effectiveRedirectTo)}`; + + window.location.href = loginUrl; + }, [formData.email, effectiveRedirectTo]); + + // Show loading state while redirect happens + return ( +
+ +

Redirecting to sign in...

+
+ ); +} +``` + +Check if `lucide-react` is available (it should be since the project uses shadcn/ui). If not, use a simple spinner or the existing `ArrowRightIcon` spinner pattern. Alternatively, just use text: + +```typescript +return ( +
+

Redirecting to sign in...

+
+); +``` + +**Step 2: Verify** + +Run: `pnpm type-check` +Expected: Clean. No more `LoginForm` import needed. + +**Step 3: Commit** + +```bash +git add apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx +git commit -m "feat(portal): redirect portal_exists users to login page with cookie handoff" +``` + +--- + +### Task 6: Adapt LoginForm for handoff mode + +**Files:** + +- Modify: `apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx` + +This is the most involved frontend change. The LoginForm needs to: + +1. Detect `from=get-started` URL param on mount +2. Call `GET /auth/get-started/check-session` to validate the cookie +3. If valid: show password-only mode (pre-filled email, no OTP step) +4. On submit: call `POST /auth/get-started/login` instead of normal login +5. If invalid: fall back to normal login flow + +**Step 1: Read the current LoginForm to understand its exact structure** + +Read: `apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx` + +The key modification is adding a handoff mode that: + +- Adds state: `handoffMode: boolean`, `handoffLoading: boolean` +- On mount: checks URL params, calls check-session API +- When `handoffMode=true`: skips the OTP step, uses the handoff login API +- When handoff fails: clears handoff state, falls back to normal + +**Step 2: Add the handoff detection and password-only login** + +This depends on the exact LoginForm structure. The implementation should: + +1. Add `useEffect` at the top of the component that checks for `from=get-started` URL param +2. If found, call `checkGetStartedSession()` from the get-started API +3. If valid, set `handoffMode = true` and pre-fill email from response +4. Replace the `handleLogin` to call `loginWithGetStartedSession({ password })` when in handoff mode +5. Hide the OTP step when in handoff mode +6. Show an info banner: "Email verified — enter your password to sign in" + +Import the handoff API functions: + +```typescript +import { + checkGetStartedSession, + loginWithGetStartedSession, +} from "@/features/get-started/api/get-started.api"; +``` + +Add state for handoff mode: + +```typescript +const [handoffMode, setHandoffMode] = useState(false); +const [handoffChecking, setHandoffChecking] = useState(false); +``` + +Add useEffect for handoff detection (near mount): + +```typescript +useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get("from") !== "get-started") return; + + setHandoffChecking(true); + checkGetStartedSession() + .then(result => { + if (result.valid && result.email) { + setHandoffMode(true); + // Pre-fill email from session + form.setValue("email", result.email); + } + }) + .catch(() => { + // Cookie invalid or expired — fall back to normal login + }) + .finally(() => setHandoffChecking(false)); +}, []); +``` + +In the login handler, add handoff path: + +```typescript +// Inside handleLogin or the form submit handler +if (handoffMode) { + const result = await loginWithGetStartedSession({ + password: data.password, + rememberDevice: data.rememberMe ?? false, + }); + // Handle success (redirect, update auth state) + return; +} +// ... existing login flow +``` + +When `handoffMode` is true, render an info banner above the password field: + +```typescript +{handoffMode && ( +
+ Email verified — enter your password to sign in. +
+)} +``` + +Make the email field read-only in handoff mode: + +```typescript + +``` + +Hide the OTP section when in handoff mode (the existing `step === "otp"` check should never trigger since we bypass OTP). + +**Step 3: Verify** + +Run: `pnpm type-check` +Expected: Clean. + +**Step 4: Commit** + +```bash +git add apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx +git commit -m "feat(portal): add handoff mode to LoginForm for get-started OTP bypass" +``` + +--- + +### Task 7: Verify full flow and type-check + +**Step 1: Run full type-check** + +Run: `pnpm domain:build && pnpm type-check` +Expected: Clean build across all packages. + +**Step 2: Run lint** + +Run: `pnpm lint` +Expected: No new lint errors. Fix any that arise. + +**Step 3: Final commit (if any fixes needed)** + +```bash +git add -A +git commit -m "fix: address lint and type-check issues from login handoff" +``` + +--- + +## Dependency Graph + +``` +Task 1 (domain schemas) + │ + ├──→ Task 2 (workflow service) + │ │ + │ └──→ Task 3 (module + controller) + │ │ + │ └──→ Task 7 (verify) + │ + ├──→ Task 4 (portal API client) + │ │ + │ ├──→ Task 5 (AccountStatusStep redirect) + │ │ + │ └──→ Task 6 (LoginForm handoff mode) + │ │ + │ └──→ Task 7 (verify) +``` + +Tasks 2+3 (backend) and Tasks 4+5+6 (frontend) can be done in parallel after Task 1.