refactor: update password validation requirements and messaging

- 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.
This commit is contained in:
barsa 2026-03-02 17:40:47 +09:00
parent c96270c223
commit 29b511e44c
8 changed files with 148 additions and 157 deletions

View File

@ -177,9 +177,13 @@ export class VerificationWorkflowService {
}; };
} }
private async determineAccountStatus( private async determineAccountStatus(email: string): Promise<{
email: string status: AccountStatus;
): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> { sfAccountId?: string;
whmcsClientId?: number;
whmcsFirstName?: string;
whmcsLastName?: string;
}> {
// Check Portal user first // Check Portal user first
const portalUser = await this.usersService.findByEmailInternal(email); const portalUser = await this.usersService.findByEmailInternal(email);
if (portalUser) { if (portalUser) {
@ -196,7 +200,12 @@ export class VerificationWorkflowService {
if (mapping) { if (mapping) {
return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; return { status: ACCOUNT_STATUS.PORTAL_EXISTS };
} }
return { status: ACCOUNT_STATUS.WHMCS_UNMAPPED, whmcsClientId: whmcsClient.id }; return {
status: ACCOUNT_STATUS.WHMCS_UNMAPPED,
whmcsClientId: whmcsClient.id,
...(whmcsClient.firstname && { whmcsFirstName: whmcsClient.firstname }),
...(whmcsClient.lastname && { whmcsLastName: whmcsClient.lastname }),
};
} }
// Check Salesforce account // Check Salesforce account
@ -214,8 +223,20 @@ export class VerificationWorkflowService {
private getPrefillData( private getPrefillData(
email: string, email: string,
accountStatus: { status: AccountStatus; sfAccountId?: string } accountStatus: {
status: AccountStatus;
sfAccountId?: string;
whmcsFirstName?: string;
whmcsLastName?: string;
}
): VerifyCodeResponse["prefill"] { ): VerifyCodeResponse["prefill"] {
if (accountStatus.status === ACCOUNT_STATUS.WHMCS_UNMAPPED) {
return {
email,
...(accountStatus.whmcsFirstName && { firstName: accountStatus.whmcsFirstName }),
...(accountStatus.whmcsLastName && { lastName: accountStatus.whmcsLastName }),
};
}
if (accountStatus.status === ACCOUNT_STATUS.SF_UNMAPPED && accountStatus.sfAccountId) { if (accountStatus.status === ACCOUNT_STATUS.SF_UNMAPPED && accountStatus.sfAccountId) {
return { email }; return { email };
} }

View File

@ -76,8 +76,7 @@ export function PasswordChangeCard({
</button> </button>
</div> </div>
<p className="text-xs text-gray-500 mt-3"> <p className="text-xs text-gray-500 mt-3">
Password must be at least 8 characters and include uppercase, lowercase, number, and special Password must be at least 8 characters and include uppercase, lowercase, and a number.
character.
</p> </p>
</SubCard> </SubCard>
); );

View File

@ -30,7 +30,7 @@ export function PasswordRequirements({ checks, showHint = false }: PasswordRequi
if (showHint) { if (showHint) {
return ( return (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
At least 8 characters with uppercase, lowercase, numbers, and a special character At least 8 characters with uppercase, lowercase, and numbers
</p> </p>
); );
} }
@ -41,7 +41,6 @@ export function PasswordRequirements({ checks, showHint = false }: PasswordRequi
<RequirementCheck met={checks.hasUppercase} label="Uppercase letter" /> <RequirementCheck met={checks.hasUppercase} label="Uppercase letter" />
<RequirementCheck met={checks.hasLowercase} label="Lowercase letter" /> <RequirementCheck met={checks.hasLowercase} label="Lowercase letter" />
<RequirementCheck met={checks.hasNumber} label="Number" /> <RequirementCheck met={checks.hasNumber} label="Number" />
<RequirementCheck met={checks.hasSpecialChar} label="Special character" />
</div> </div>
); );
} }

View File

@ -5,7 +5,6 @@ export interface PasswordChecks {
hasUppercase: boolean; hasUppercase: boolean;
hasLowercase: boolean; hasLowercase: boolean;
hasNumber: boolean; hasNumber: boolean;
hasSpecialChar: boolean;
} }
export interface PasswordValidation { export interface PasswordValidation {
@ -20,7 +19,6 @@ export function validatePasswordRules(password: string): string | undefined {
if (!/[A-Z]/.test(password)) return "Password must contain an uppercase letter"; if (!/[A-Z]/.test(password)) return "Password must contain an uppercase letter";
if (!/[a-z]/.test(password)) return "Password must contain a lowercase letter"; if (!/[a-z]/.test(password)) return "Password must contain a lowercase letter";
if (!/[0-9]/.test(password)) return "Password must contain a number"; if (!/[0-9]/.test(password)) return "Password must contain a number";
if (!/[^A-Za-z0-9]/.test(password)) return "Password must contain a special character";
return undefined; return undefined;
} }
@ -31,15 +29,10 @@ export function usePasswordValidation(password: string): PasswordValidation {
hasUppercase: /[A-Z]/.test(password), hasUppercase: /[A-Z]/.test(password),
hasLowercase: /[a-z]/.test(password), hasLowercase: /[a-z]/.test(password),
hasNumber: /[0-9]/.test(password), hasNumber: /[0-9]/.test(password),
hasSpecialChar: /[^A-Za-z0-9]/.test(password),
}; };
const isValid = const isValid =
checks.minLength && checks.minLength && checks.hasUppercase && checks.hasLowercase && checks.hasNumber;
checks.hasUppercase &&
checks.hasLowercase &&
checks.hasNumber &&
checks.hasSpecialChar;
const error = validatePasswordRules(password); const error = validatePasswordRules(password);
return { checks, isValid, error }; return { checks, isValid, error };

View File

@ -8,6 +8,16 @@
**Tech Stack:** NestJS (BFF), Zod (validation), Redis (sessions), Argon2 (password), XState (frontend state), React (portal) **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 ### Task 1: Add domain schemas and types for login handoff
@ -45,8 +55,8 @@ export const getStartedLoginRequestSchema = z.object({
export const checkSessionResponseSchema = z.object({ export const checkSessionResponseSchema = z.object({
/** Whether a valid verified session exists */ /** Whether a valid verified session exists */
valid: z.boolean(), valid: z.boolean(),
/** Email from the session (only present when valid) */ /** Masked email from the session, e.g. "ba***@example.com" (only present when valid) */
email: z.string().optional(), maskedEmail: z.string().optional(),
}); });
``` ```
@ -67,6 +77,14 @@ Add types after `MigrateWhmcsAccountRequest` (after line 94):
```typescript ```typescript
export type GetStartedLoginRequest = z.infer<typeof getStartedLoginRequestSchema>; export type GetStartedLoginRequest = z.infer<typeof getStartedLoginRequestSchema>;
export type CheckSessionResponse = z.infer<typeof checkSessionResponseSchema>; 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`** **Step 3: Add exports to `packages/domain/get-started/index.ts`**
@ -76,6 +94,7 @@ Add to the contract exports section (after `type GetStartedError` on line 37):
```typescript ```typescript
type GetStartedLoginRequest, type GetStartedLoginRequest,
type CheckSessionResponse, type CheckSessionResponse,
maskEmail,
``` ```
Add to the schemas export section (after `getStartedSessionSchema` on line 64): Add to the schemas export section (after `getStartedSessionSchema` on line 64):
@ -108,96 +127,14 @@ git commit -m "feat(domain): add login handoff schemas for portal_exists OTP byp
**Step 1: Create the workflow service** **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. 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 ```typescript
/** usedFor?: "signup_with_eligibility" | "complete_account" | "migrate_whmcs_account" | "login";
* 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** **Step 2: Verify types compile**
@ -275,7 +212,7 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
```typescript ```typescript
import { Injectable, Inject, UnauthorizedException } from "@nestjs/common"; import { Injectable, Inject, UnauthorizedException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ACCOUNT_STATUS } from "@customer-portal/domain/get-started"; import { ACCOUNT_STATUS, maskEmail } from "@customer-portal/domain/get-started";
import { GetStartedSessionService } from "../otp/get-started-session.service.js"; import { GetStartedSessionService } from "../otp/get-started-session.service.js";
import { AuthLoginService } from "../../application/auth-login.service.js"; import { AuthLoginService } from "../../application/auth-login.service.js";
@ -296,46 +233,61 @@ export class GetStartedLoginWorkflowService {
) {} ) {}
async execute(sessionToken: string, password: string): Promise<AuthResultInternal> { async execute(sessionToken: string, password: string): Promise<AuthResultInternal> {
// 1. Acquire lock and mark session as used (prevents replay) // 1. Validate session exists and is verified (without consuming it)
const lockResult = await this.sessionService.acquireAndMarkAsUsed(sessionToken, "login"); const session = await this.sessionService.validateVerifiedSession(sessionToken);
if (!lockResult.success) { if (!session) {
this.logger.warn( this.logger.warn(
{ sessionToken, reason: lockResult.reason }, { sessionTokenSuffix: sessionToken.slice(-4) },
"Login handoff session invalid" "Login handoff session invalid or expired"
); );
throw new UnauthorizedException("Session is invalid or has already been used"); throw new UnauthorizedException("Login failed");
} }
const session = lockResult.session;
// 2. Verify the session was for a portal_exists account // 2. Verify the session was for a portal_exists account
if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) { if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
this.logger.warn( this.logger.warn(
{ sessionToken, accountStatus: session.accountStatus }, { sessionTokenSuffix: sessionToken.slice(-4), accountStatus: session.accountStatus },
"Login handoff session has wrong account status" "Login handoff session has wrong account status"
); );
throw new UnauthorizedException("Session is not valid for login"); throw new UnauthorizedException("Login failed");
} }
// 3. Validate password (handles lockout, audit, failed attempt tracking) // 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); const validatedUser = await this.loginService.validateUser(session.email, password);
if (!validatedUser) { if (!validatedUser) {
throw new UnauthorizedException("Invalid password"); this.logger.warn(
{ sessionTokenSuffix: sessionToken.slice(-4), email: session.email },
"Login handoff password validation failed"
);
throw new UnauthorizedException("Login failed");
} }
// 4. Update lastLoginAt and reset failed attempts // 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, { await this.usersService.update(validatedUser.id, {
lastLoginAt: new Date(), lastLoginAt: new Date(),
failedLoginAttempts: 0, failedLoginAttempts: 0,
lockedUntil: null, lockedUntil: null,
}); });
// 5. Generate auth tokens // 6. Generate auth tokens
const result = await this.generateAuthResult.execute(validatedUser.id); const result = await this.generateAuthResult.execute(validatedUser.id);
// 6. Audit log // 7. Audit log
await this.auditService.logAuthEvent(AuditAction.LOGIN_SUCCESS, validatedUser.id, { await this.auditService.logAuthEvent(AuditAction.LOGIN_SUCCESS, validatedUser.id, {
email: session.email, email: session.email,
method: "get_started_handoff", method: "get_started_handoff",
@ -345,16 +297,21 @@ export class GetStartedLoginWorkflowService {
return result; 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( async checkSession(
sessionToken: string sessionToken: string
): Promise<{ valid: true; email: string } | { valid: false }> { ): Promise<{ valid: true; maskedEmail: string } | { valid: false }> {
const session = await this.sessionService.validateVerifiedSession(sessionToken); const session = await this.sessionService.validateVerifiedSession(sessionToken);
if (!session || session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) { if (!session || session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
return { valid: false }; return { valid: false };
} }
return { valid: true, email: session.email }; return { valid: true, maskedEmail: maskEmail(session.email) };
} }
} }
``` ```
@ -431,13 +388,16 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl
```typescript ```typescript
/** /**
* Check if a valid get-started handoff session exists * Check if a valid get-started handoff session exists.
* Used by the login page to detect OTP-bypass mode * 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() @Public()
@Get("check-session") @Get("check-session")
async checkSession( async checkSession(
@Req() req: Request @Req() req: Request,
@Res({ passthrough: true }) res: Response
): Promise<CheckSessionResponseDto> { ): Promise<CheckSessionResponseDto> {
const sessionToken = req.cookies?.["gs_verified"] as string | undefined; const sessionToken = req.cookies?.["gs_verified"] as string | undefined;
@ -445,7 +405,14 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl
return { valid: false }; return { valid: false };
} }
return this.loginWorkflow.checkSession(sessionToken); 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;
} }
/** /**
@ -454,6 +421,11 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl
* For portal_exists users after OTP verification in get-started flow. * For portal_exists users after OTP verification in get-started flow.
* Session token comes from the gs_verified HttpOnly cookie. * Session token comes from the gs_verified HttpOnly cookie.
* Skips login OTP since email was already verified via get-started OTP. * 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() @Public()
@Post("login") @Post("login")
@ -468,7 +440,7 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl
const sessionToken = req.cookies?.["gs_verified"] as string | undefined; const sessionToken = req.cookies?.["gs_verified"] as string | undefined;
if (!sessionToken) { if (!sessionToken) {
throw new UnauthorizedException("No verified session found"); throw new UnauthorizedException("Login failed");
} }
const result = await this.loginWorkflow.execute(sessionToken, body.password); const result = await this.loginWorkflow.execute(sessionToken, body.password);
@ -476,10 +448,9 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl
// Set auth cookies // Set auth cookies
setAuthCookies(res, result.tokens); setAuthCookies(res, result.tokens);
// Clear the handoff cookie // Clear the handoff cookie only on success
res.clearCookie("gs_verified", { // (on failure the cookie stays so the user can retry with correct password)
path: "/api/auth/get-started", res.clearCookie("gs_verified", { path: "/api/auth/get-started" });
});
return { return {
user: result.user, user: result.user,
@ -541,6 +512,9 @@ import {
type GetStartedLoginRequest, type GetStartedLoginRequest,
type CheckSessionResponse, type CheckSessionResponse,
} from "@customer-portal/domain/get-started"; } 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): Add two new functions at the bottom of the file (before the closing):
@ -602,6 +576,10 @@ Replace the entire component. Both inline and full-page modes now redirect to th
* After OTP verification detects an existing account, the BFF sets * After OTP verification detects an existing account, the BFF sets
* a gs_verified HttpOnly cookie. This component redirects to the * a gs_verified HttpOnly cookie. This component redirects to the
* login page, which detects the cookie and shows password-only mode. * 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"; "use client";
@ -613,18 +591,17 @@ import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
export function AccountStatusStep() { export function AccountStatusStep() {
const { state } = useGetStartedMachine(); const { state } = useGetStartedMachine();
const { formData, redirectTo, serviceContext } = state.context; const { redirectTo, serviceContext } = state.context;
const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account"); const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account");
useEffect(() => { useEffect(() => {
const loginUrl = const loginUrl =
`/auth/login?email=${encodeURIComponent(formData.email)}` + `/auth/login?from=get-started` +
`&from=get-started` +
`&redirect=${encodeURIComponent(effectiveRedirectTo)}`; `&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
window.location.href = loginUrl; window.location.href = loginUrl;
}, [formData.email, effectiveRedirectTo]); }, [effectiveRedirectTo]);
// Show loading state while redirect happens // Show loading state while redirect happens
return ( return (
@ -670,7 +647,7 @@ This is the most involved frontend change. The LoginForm needs to:
1. Detect `from=get-started` URL param on mount 1. Detect `from=get-started` URL param on mount
2. Call `GET /auth/get-started/check-session` to validate the cookie 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) 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 4. On submit: call `POST /auth/get-started/login` instead of normal login
5. If invalid: fall back to normal login flow 5. If invalid: fall back to normal login flow
@ -691,10 +668,10 @@ 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 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 2. If found, call `checkGetStartedSession()` from the get-started API
3. If valid, set `handoffMode = true` and pre-fill email from response 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 4. Replace the `handleLogin` to call `loginWithGetStartedSession({ password })` when in handoff mode
5. Hide the OTP step when in handoff mode 5. Hide the email input and OTP step when in handoff mode
6. Show an info banner: "Email verified — enter your password to sign in" 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: Import the handoff API functions:
@ -712,6 +689,12 @@ const [handoffMode, setHandoffMode] = useState(false);
const [handoffChecking, setHandoffChecking] = 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): Add useEffect for handoff detection (near mount):
```typescript ```typescript
@ -722,10 +705,10 @@ useEffect(() => {
setHandoffChecking(true); setHandoffChecking(true);
checkGetStartedSession() checkGetStartedSession()
.then(result => { .then(result => {
if (result.valid && result.email) { if (result.valid && result.maskedEmail) {
setHandoffMode(true); setHandoffMode(true);
// Pre-fill email from session // Store masked email for display (not the full email — that stays in the cookie)
form.setValue("email", result.email); setHandoffMaskedEmail(result.maskedEmail);
} }
}) })
.catch(() => { .catch(() => {
@ -750,24 +733,22 @@ if (handoffMode) {
// ... existing login flow // ... existing login flow
``` ```
When `handoffMode` is true, render an info banner above the password field: When `handoffMode` is true, render an info banner above the password field showing the masked email:
```typescript ```typescript
{handoffMode && ( {handoffMode && handoffMaskedEmail && (
<div className="rounded-lg bg-success/10 p-3 text-sm text-success"> <div className="rounded-lg bg-success/10 p-3 text-sm text-success">
Email verified — enter your password to sign in. Email verified for <strong>{handoffMaskedEmail}</strong> — enter your password to sign in.
</div> </div>
)} )}
``` ```
Make the email field read-only in handoff mode: 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 ```typescript
<Input {!handoffMode && (
{...field} <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). Hide the OTP section when in handoff mode (the existing `step === "otp"` check should never trigger since we bypass OTP).

View File

@ -18,7 +18,6 @@ export const PASSWORD_REQUIREMENTS = [
{ key: "uppercase", label: "One uppercase letter", regex: /[A-Z]/ }, { key: "uppercase", label: "One uppercase letter", regex: /[A-Z]/ },
{ key: "lowercase", label: "One lowercase letter", regex: /[a-z]/ }, { key: "lowercase", label: "One lowercase letter", regex: /[a-z]/ },
{ key: "number", label: "One number", regex: /[0-9]/ }, { key: "number", label: "One number", regex: /[0-9]/ },
{ key: "special", label: "One special character", regex: /[^A-Za-z0-9]/ },
] as const; ] as const;
export type PasswordRequirementKey = (typeof PASSWORD_REQUIREMENTS)[number]["key"]; export type PasswordRequirementKey = (typeof PASSWORD_REQUIREMENTS)[number]["key"];

View File

@ -18,8 +18,7 @@ export const passwordSchema = z
.min(8, "Password must be at least 8 characters") .min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter") .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter") .regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number") .regex(/[0-9]/, "Password must contain at least one number");
.regex(/[^A-Za-z0-9]/, "Password must contain at least one special character");
export const nameSchema = z export const nameSchema = z
.string() .string()

View File

@ -56,7 +56,7 @@ function normalizeAddress(client: WhmcsRawClient): Address | undefined {
country: client.country ?? null, country: client.country ?? null,
countryCode: client.countrycode ?? null, countryCode: client.countrycode ?? null,
phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? null, phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? null,
phoneCountryCode: client.phonecc ?? null, phoneCountryCode: client.phonecc == null ? null : String(client.phonecc),
}); });
const hasValues = Object.values(address).some(v => v !== undefined && v !== null && v !== ""); const hasValues = Object.values(address).some(v => v !== undefined && v !== null && v !== "");