# 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)

**Security Hardening (applied throughout):**

- Session tokens never logged in full — only last 4 chars (`sessionTokenSuffix`) to prevent log exfiltration
- All auth error responses use a generic "Login failed" message (specific reasons in server logs only) to prevent state enumeration
- `check-session` returns masked email (`ba***@example.com`), not the full address, to limit information disclosure
- Password validation runs BEFORE session consumption — wrong passwords don't burn the one-time session, allowing retries (account lockout in `AuthLoginService` protects against brute-force)
- `gs_verified` cookie cleared on success only — preserved on wrong password so user can retry (stale cookies auto-expire via 5min `maxAge` and `check-session` cleanup)
- Email removed from redirect URL — login page gets it from `check-session` cookie response, avoiding leakage into browser history, logs, and referrer headers
- `"login"` added to `SessionData.usedFor` union type for session tracking

---

### 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(),
  /** Masked email from the session, e.g. "ba***@example.com" (only present when valid) */
  maskedEmail: 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<typeof getStartedLoginRequestSchema>;
export type CheckSessionResponse = z.infer<typeof checkSessionResponseSchema>;

/** Mask an email address for safe display (e.g. "ba***@example.com") */
export function maskEmail(email: string): string {
  const [local, domain] = email.split("@");
  if (!local || !domain) return "***@***";
  const visible = local.slice(0, Math.min(2, local.length));
  return `${visible}${"*".repeat(Math.max(0, local.length - 2))}@${domain}`;
}
```

**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,
  maskEmail,
```

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**

> **Note:** This initial version uses `AuthOrchestrator` which creates a circular dependency. See the **revised version in Task 3 Step 1** which replaces it with `GenerateAuthResultStep`. The revised version is the one to implement.

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.

**Prerequisite:** Add `"login"` to the `usedFor` union type in `apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts` (line 25):

```typescript
usedFor?: "signup_with_eligibility" | "complete_account" | "migrate_whmcs_account" | "login";
```

**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, maskEmail } 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<AuthResultInternal> {
    // 1. Validate session exists and is verified (without consuming it)
    const session = await this.sessionService.validateVerifiedSession(sessionToken);

    if (!session) {
      this.logger.warn(
        { sessionTokenSuffix: sessionToken.slice(-4) },
        "Login handoff session invalid or expired"
      );
      throw new UnauthorizedException("Login failed");
    }

    // 2. Verify the session was for a portal_exists account
    if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
      this.logger.warn(
        { sessionTokenSuffix: sessionToken.slice(-4), accountStatus: session.accountStatus },
        "Login handoff session has wrong account status"
      );
      throw new UnauthorizedException("Login failed");
    }

    // 3. Validate password FIRST (handles lockout, audit, failed attempt tracking)
    //    This does NOT consume the session — wrong passwords allow retries.
    //    Brute-force protection comes from AuthLoginService's account lockout.
    const validatedUser = await this.loginService.validateUser(session.email, password);

    if (!validatedUser) {
      this.logger.warn(
        { sessionTokenSuffix: sessionToken.slice(-4), email: session.email },
        "Login handoff password validation failed"
      );
      throw new UnauthorizedException("Login failed");
    }

    // 4. Password correct — now consume the session atomically (prevents replay)
    const lockResult = await this.sessionService.acquireAndMarkAsUsed(sessionToken, "login");

    if (!lockResult.success) {
      this.logger.warn(
        { sessionTokenSuffix: sessionToken.slice(-4), reason: lockResult.reason },
        "Login handoff session consumed during login (race condition)"
      );
      throw new UnauthorizedException("Login failed");
    }

    // 5. Update lastLoginAt and reset failed attempts
    await this.usersService.update(validatedUser.id, {
      lastLoginAt: new Date(),
      failedLoginAttempts: 0,
      lockedUntil: null,
    });

    // 6. Generate auth tokens
    const result = await this.generateAuthResult.execute(validatedUser.id);

    // 7. 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;
  }

  /**
   * Check if a session token is valid for login handoff.
   * Does NOT consume the session.
   * Returns masked email to limit information disclosure.
   */
  async checkSession(
    sessionToken: string
  ): Promise<{ valid: true; maskedEmail: string } | { valid: false }> {
    const session = await this.sessionService.validateVerifiedSession(sessionToken);

    if (!session || session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
      return { valid: false };
    }

    return { valid: true, maskedEmail: maskEmail(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<VerifyCodeResponseDto> {
    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.
   * Returns masked email to limit information disclosure.
   * Clears the stale cookie if the session is no longer valid.
   */
  @Public()
  @Get("check-session")
  async checkSession(
    @Req() req: Request,
    @Res({ passthrough: true }) res: Response
  ): Promise<CheckSessionResponseDto> {
    const sessionToken = req.cookies?.["gs_verified"] as string | undefined;

    if (!sessionToken) {
      return { valid: false };
    }

    const result = await this.loginWorkflow.checkSession(sessionToken);

    // Clear stale cookie if session is no longer valid
    if (!result.valid) {
      res.clearCookie("gs_verified", { path: "/api/auth/get-started" });
    }

    return result;
  }

  /**
   * 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.
   *
   * Security: all errors return generic "Login failed" to prevent state enumeration.
   * The gs_verified cookie is cleared only on SUCCESS. On failure (e.g. wrong
   * password), the cookie is preserved so the user can retry. Stale cookies
   * are harmless — they auto-expire (5min maxAge) and check-session cleans them.
   */
  @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<AuthSuccessResponse> {
    const sessionToken = req.cookies?.["gs_verified"] as string | undefined;

    if (!sessionToken) {
      throw new UnauthorizedException("Login failed");
    }

    const result = await this.loginWorkflow.execute(sessionToken, body.password);

    // Set auth cookies
    setAuthCookies(res, result.tokens);

    // Clear the handoff cookie only on success
    // (on failure the cookie stays so the user can retry with correct password)
    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";

// Also import the auth response schema for the login function
import { authResponseSchema } from "@customer-portal/domain/auth"; // adjust path to match existing pattern
```

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<CheckSessionResponse> {
  const response = await apiClient.GET<CheckSessionResponse>(`${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<AuthResponse> {
  const response = await apiClient.POST<AuthResponse>(`${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.
 *
 * Security: email is NOT included in the URL to avoid leaking into
 * browser history, server logs, and referrer headers. The login page
 * retrieves the (masked) email from the check-session endpoint instead.
 */

"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 { redirectTo, serviceContext } = state.context;

  const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account");

  useEffect(() => {
    const loginUrl =
      `/auth/login?from=get-started` +
      `&redirect=${encodeURIComponent(effectiveRedirectTo)}`;

    window.location.href = loginUrl;
  }, [effectiveRedirectTo]);

  // Show loading state while redirect happens
  return (
    <div className="flex flex-col items-center justify-center space-y-4 py-8">
      <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
      <p className="text-sm text-muted-foreground">Redirecting to sign in...</p>
    </div>
  );
}
```

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 (
  <div className="flex items-center justify-center py-8">
    <p className="text-sm text-muted-foreground">Redirecting to sign in...</p>
  </div>
);
```

**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 (display masked email from response, 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 store the masked email for display
4. Replace the `handleLogin` to call `loginWithGetStartedSession({ password })` when in handoff mode
5. Hide the email input and OTP step when in handoff mode
6. Show an info banner with masked email: "Email verified for ba\*\*\*@example.com — 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 state for the masked email display:

```typescript
const [handoffMaskedEmail, setHandoffMaskedEmail] = useState<string | null>(null);
```

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.maskedEmail) {
        setHandoffMode(true);
        // Store masked email for display (not the full email — that stays in the cookie)
        setHandoffMaskedEmail(result.maskedEmail);
      }
    })
    .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 showing the masked email:

```typescript
{handoffMode && handoffMaskedEmail && (
  <div className="rounded-lg bg-success/10 p-3 text-sm text-success">
    Email verified for <strong>{handoffMaskedEmail}</strong> — enter your password to sign in.
  </div>
)}
```

Hide the email input entirely in handoff mode (the full email is never exposed to the frontend — only the masked version is shown in the banner above):

```typescript
{!handoffMode && (
  <Input {...field} />
)}
```

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.
