- Removed special character requirement from password validation across various components and schemas. - Updated user-facing messages to reflect the new password criteria. - Adjusted related validation logic in the domain and portal to ensure consistency.
812 lines
28 KiB
Markdown
812 lines
28 KiB
Markdown
# 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.
|