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.
This commit is contained in:
barsa 2026-03-02 16:12:42 +09:00
parent 65bdadc5c8
commit c96270c223

View File

@ -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<typeof getStartedLoginRequestSchema>;
export type CheckSessionResponse = z.infer<typeof checkSessionResponseSchema>;
```
**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<AuthResultInternal> {
// 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<AuthResultInternal> {
// 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<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
*/
@Public()
@Get("check-session")
async checkSession(
@Req() req: Request
): Promise<CheckSessionResponseDto> {
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<AuthSuccessResponse> {
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<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.
*/
"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 (
<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 (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 && (
<div className="rounded-lg bg-success/10 p-3 text-sm text-success">
Email verified — enter your password to sign in.
</div>
)}
```
Make the email field read-only in handoff mode:
```typescript
<Input
{...field}
readOnly={handoffMode}
className={handoffMode ? "bg-muted" : ""}
/>
```
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.