feat(auth): add Password Reset Token Service and update token infrastructure

This commit is contained in:
barsa 2026-01-14 13:58:24 +09:00
parent fd15324ef0
commit 971cde9d05
5 changed files with 1012 additions and 0 deletions

View File

@ -0,0 +1,207 @@
# Auth Module
Authentication and authorization for the Customer Portal.
## Architecture Overview
```
auth/
├── application/ # Orchestration layer
│ ├── auth.facade.ts # Main entry point for auth operations
│ ├── auth-login.service.ts
│ └── auth-health.service.ts
├── decorators/
│ └── public.decorator.ts # @Public, @PublicNoSession, @OptionalAuth
├── presentation/http/
│ ├── auth.controller.ts # Login, signup, password endpoints
│ ├── get-started.controller.ts # OTP-based onboarding
│ ├── guards/
│ │ ├── global-auth.guard.ts # JWT validation (APP_GUARD)
│ │ ├── local-auth.guard.ts # Credential validation
│ │ └── failed-login-throttle.guard.ts
│ └── interceptors/
│ └── login-result.interceptor.ts
└── infra/ # Infrastructure services
├── token/ # JWT & token management
├── otp/ # OTP & session management
├── rate-limiting/ # Auth-specific rate limits
└── workflows/ # Multi-step auth flows
```
## Security Features
### Token Management
| Feature | Description |
| ----------------------------- | --------------------------------------- |
| **Access Token** | 15-min expiry, HS256 HMAC-signed |
| **Refresh Token** | 7-day expiry with family-based rotation |
| **Single-Use Password Reset** | Redis-tracked tokens prevent replay |
| **Token Blacklist** | Immediate revocation on logout |
### OTP Security
| Feature | Description |
| ----------------------- | ----------------------------------------------- |
| **Code Generation** | Cryptographically secure 6-digit codes |
| **Expiration** | 10 minutes (configurable) |
| **Max Attempts** | 3 attempts before invalidation |
| **Fingerprint Binding** | Logs warning if verified from different context |
### Rate Limiting
| Endpoint | Limit | Window |
| -------------- | ----------- | ---------- |
| Login | 5 attempts | 15 minutes |
| Signup | 5 attempts | 15 minutes |
| Password Reset | 5 attempts | 15 minutes |
| OTP Send | 5 codes | 5 minutes |
| OTP Verify | 10 attempts | 5 minutes |
## Authentication Flows
### 1. Login Flow
```
POST /auth/login
LocalAuthGuard (validates credentials)
FailedLoginThrottleGuard (rate limiting)
AuthFacade.login()
├─► Generate token pair
├─► Set httpOnly cookies
└─► Return user + session metadata
```
### 2. Password Reset Flow (Single-Use)
```
POST /auth/request-password-reset
PasswordResetTokenService.create()
├─► Generate JWT with tokenId
├─► Store tokenId in Redis (15-min TTL)
└─► Send email with reset link
POST /auth/reset-password
PasswordResetTokenService.consume()
├─► Verify JWT signature & expiry
├─► Check tokenId exists in Redis
├─► Delete tokenId (single-use enforcement)
└─► If already deleted → "Token already used"
```
### 3. Get Started Flow (OTP)
```
POST /auth/get-started/send-code
OtpService.generateAndStore(email, fingerprint)
├─► Generate 6-digit code
├─► Store in Redis with fingerprint
└─► Send email
POST /auth/get-started/verify-code
OtpService.verify(email, code, fingerprint)
├─► Check fingerprint (warn if mismatch)
├─► Validate code
└─► Determine account status
Account Status:
├─► PORTAL_EXISTS → Redirect to login
├─► WHMCS_UNMAPPED → Redirect to migration
├─► SF_UNMAPPED → Complete account
└─► NEW_CUSTOMER → Full signup
```
## Configuration
### Environment Variables
```bash
# JWT Configuration
JWT_SECRET= # Required: HMAC signing key
JWT_SECRET_PREVIOUS= # Optional: Previous keys for rotation (comma-separated)
JWT_ISSUER= # Optional: Token issuer claim
JWT_AUDIENCE= # Optional: Token audience claim
# Token Expiry
ACCESS_TOKEN_EXPIRY=900 # 15 minutes (seconds)
REFRESH_TOKEN_EXPIRY=604800 # 7 days (seconds)
PASSWORD_RESET_TTL_SECONDS=900 # 15 minutes
# OTP Configuration
OTP_TTL_SECONDS=600 # 10 minutes
OTP_MAX_ATTEMPTS=3 # Max verification attempts
# Rate Limiting
LOGIN_RATE_LIMIT_LIMIT=5 # Max attempts
LOGIN_RATE_LIMIT_TTL=900000 # Window in ms (15 min)
LOGIN_CAPTCHA_AFTER_ATTEMPTS=3 # CAPTCHA threshold
# Redis Behavior
AUTH_ALLOW_REDIS_TOKEN_FAILOPEN=false # Continue without Redis
AUTH_REQUIRE_REDIS_FOR_TOKENS=false # Strict Redis requirement
AUTH_MAINTENANCE_MODE=false # Disable auth service
```
## Route Protection
### Decorators
```typescript
// Anyone can access, optionally attach user if token exists
@Public()
// Strict public - no session attachment, no cookies read
@PublicNoSession()
// No token = allow (user=null), invalid token = 401
@OptionalAuth()
// Default: requires valid access token (401 if missing/invalid)
```
### Guard Behavior
| Decorator | Missing Token | Invalid Token | Valid Token |
| ------------------ | ------------- | ------------- | --------------- |
| (none) | 401 | 401 | ✓ user attached |
| @Public() | ✓ | ✓ | ✓ user attached |
| @PublicNoSession() | ✓ | ✓ | ✓ no user |
| @OptionalAuth() | ✓ null user | 401 | ✓ user attached |
## External Integrations
The auth module integrates with:
- **WHMCS**: Account discovery, client creation, credential validation
- **Salesforce**: Account lookup, portal status updates
- **Redis**: Token storage, OTP codes, session management
- **Email (SendGrid)**: OTP codes, password reset links
## Directory Reference
| Directory | Purpose |
| --------------------------- | ---------------------------------------------- |
| `infra/token/` | JWT signing, refresh token rotation, blacklist |
| `infra/otp/` | OTP codes, get-started sessions |
| `infra/rate-limiting/` | Auth-specific rate limiters |
| `infra/workflows/` | Multi-step flows (signup, password, migration) |
| `presentation/http/guards/` | Request validation, token verification |

View File

@ -0,0 +1,12 @@
/**
* Token Infrastructure Services
*
* Provides JWT token management, storage, and revocation capabilities.
*/
export { AuthTokenService } from "./token.service.js";
export { JoseJwtService } from "./jose-jwt.service.js";
export { TokenBlacklistService } from "./token-blacklist.service.js";
export { TokenStorageService } from "./token-storage.service.js";
export { TokenRevocationService } from "./token-revocation.service.js";
export { PasswordResetTokenService } from "./password-reset-token.service.js";

View File

@ -0,0 +1,179 @@
import { randomUUID } from "crypto";
import { BadRequestException, Inject, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { CacheService } from "@/infra/cache/cache.service.js";
import { JoseJwtService } from "./jose-jwt.service.js";
/**
* Data stored in Redis for password reset token tracking
*/
interface PasswordResetTokenData {
/** User ID the token belongs to */
userId: string;
/** Timestamp when the token was created */
createdAt: number;
}
/**
* JWT payload structure for password reset tokens
* Extends JWTPayload to satisfy jose library constraints
*/
interface PasswordResetTokenPayload {
/** User ID (standard JWT subject) */
sub: string;
/** Unique token identifier for single-use tracking */
tokenId: string;
/** Purpose claim to distinguish from other token types */
purpose: "password_reset";
/** Index signature for JWTPayload compatibility */
[key: string]: unknown;
}
/**
* Password Reset Token Service
*
* Manages password reset tokens with single-use enforcement.
* Tokens are JWTs that contain a unique tokenId, which is tracked in Redis.
* Once a token is consumed (used), it's removed from Redis and cannot be reused.
*
* Security features:
* - Single-use enforcement via Redis tracking
* - 15-minute expiration (configurable)
* - Purpose claim prevents token type confusion
* - Token ID in JWT enables revocation without database
*/
@Injectable()
export class PasswordResetTokenService {
private readonly REDIS_PREFIX = "pwd-reset:";
private readonly DEFAULT_TTL_SECONDS = 900; // 15 minutes
private readonly ttlSeconds: number;
constructor(
private readonly cache: CacheService,
private readonly jwtService: JoseJwtService,
private readonly config: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
this.ttlSeconds = this.config.get<number>(
"PASSWORD_RESET_TTL_SECONDS",
this.DEFAULT_TTL_SECONDS
);
}
/**
* Create a new password reset token for a user
*
* @param userId - The user ID to create a token for
* @returns The signed JWT token
*/
async create(userId: string): Promise<string> {
const tokenId = randomUUID();
const token = await this.jwtService.sign(
{
sub: userId,
tokenId,
purpose: "password_reset",
} satisfies PasswordResetTokenPayload,
this.ttlSeconds
);
// Track token in Redis for single-use enforcement
const tokenData: PasswordResetTokenData = {
userId,
createdAt: Date.now(),
};
await this.cache.set(this.buildKey(tokenId), tokenData, this.ttlSeconds);
this.logger.log({ userId, tokenId }, "Password reset token created");
return token;
}
/**
* Consume (use) a password reset token
*
* This method verifies the token and marks it as used (removes from Redis).
* A token can only be consumed once - subsequent attempts will fail.
*
* @param token - The JWT token to consume
* @returns The user ID from the token
* @throws BadRequestException if token is invalid, expired, or already used
*/
async consume(token: string): Promise<{ userId: string }> {
// Verify JWT signature and expiration
let payload: PasswordResetTokenPayload;
try {
payload = await this.jwtService.verify<PasswordResetTokenPayload>(token);
} catch (error) {
this.logger.warn({ error }, "Password reset token verification failed");
throw new BadRequestException("Invalid or expired token");
}
// Validate token purpose
if (payload.purpose !== "password_reset") {
this.logger.warn({ purpose: payload.purpose }, "Invalid token purpose for password reset");
throw new BadRequestException("Invalid token");
}
const key = this.buildKey(payload.tokenId);
// Get and delete atomically (get first, then delete)
// This ensures the token can only be used once
const tokenData = await this.cache.get<PasswordResetTokenData>(key);
if (!tokenData) {
this.logger.warn(
{ tokenId: payload.tokenId, userId: payload.sub },
"Password reset token already used or expired"
);
throw new BadRequestException("Token already used or expired");
}
// Delete the token from Redis (mark as consumed)
await this.cache.del(key);
this.logger.log(
{ userId: payload.sub, tokenId: payload.tokenId },
"Password reset token consumed"
);
return { userId: payload.sub };
}
/**
* Invalidate a password reset token without using it
*
* Useful for cleanup or when a user requests a new token
*
* @param tokenId - The token ID to invalidate
*/
async invalidate(tokenId: string): Promise<void> {
await this.cache.del(this.buildKey(tokenId));
this.logger.log({ tokenId }, "Password reset token invalidated");
}
/**
* Invalidate all password reset tokens for a user
*
* Useful when password is changed through another method
*
* @param userId - The user ID to invalidate tokens for
*/
async invalidateAllForUser(userId: string): Promise<void> {
// Use pattern-based deletion
await this.cache.delPattern(`${this.REDIS_PREFIX}*`);
this.logger.log({ userId }, "All password reset tokens invalidated for user");
}
/**
* Build Redis key for token storage
*/
private buildKey(tokenId: string): string {
return `${this.REDIS_PREFIX}${tokenId}`;
}
}

View File

@ -0,0 +1,17 @@
/**
* Public Internet Availability Check Page
*
* Allows guests to check internet availability without creating an account first.
*/
import { PublicEligibilityCheckView } from "@/features/services/views/PublicEligibilityCheck";
import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices";
export default function PublicEligibilityCheckPage() {
return (
<>
<RedirectAuthenticatedToAccountServices targetPath="/account/services/internet/request" />
<PublicEligibilityCheckView />
</>
);
}

View File

@ -0,0 +1,597 @@
/**
* PublicEligibilityCheckView - Dedicated page for guest internet eligibility check
*
* Flow:
* 1. Enter name, email, address
* 2. Verify email with OTP
* 3. Creates SF Account + Case immediately on verification
* 4. Shows options: "Create Account Now" or "Maybe Later"
*/
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { CheckCircle, Mail, ArrowRight, ArrowLeft, Clock, MapPin } from "lucide-react";
import { Button, Input, Label } from "@/components/atoms";
import {
JapanAddressForm,
type JapanAddressFormData,
} from "@/features/address/components/JapanAddressForm";
import { OtpInput } from "@/features/get-started/components/OtpInput/OtpInput";
import {
sendVerificationCode,
verifyCode,
quickEligibilityCheck,
} from "@/features/get-started/api/get-started.api";
import { prepareWhmcsAddressFields } from "@customer-portal/domain/address";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
// Helper to extract error message
const getErrorMessage = (err: unknown): string => {
if (err instanceof Error) return err.message;
return "An unexpected error occurred";
};
// ============================================================================
// Types
// ============================================================================
type Step = "form" | "otp" | "success";
interface FormData {
firstName: string;
lastName: string;
email: string;
address: JapanAddressFormData | null;
}
interface FormErrors {
firstName?: string;
lastName?: string;
email?: string;
address?: string;
}
// ============================================================================
// Step Components
// ============================================================================
interface FormStepProps {
formData: FormData;
formErrors: FormErrors;
isAddressComplete: boolean;
loading: boolean;
error: string | null;
onFormDataChange: (data: Partial<FormData>) => void;
onAddressChange: (data: JapanAddressFormData, isComplete: boolean) => void;
onClearError: (field: keyof FormErrors) => void;
onSubmit: () => void;
}
function FormStep({
formData,
formErrors,
loading,
error,
onFormDataChange,
onAddressChange,
onClearError,
onSubmit,
}: FormStepProps) {
return (
<div className="space-y-6">
{/* Name fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">
First Name <span className="text-danger">*</span>
</Label>
<Input
id="firstName"
value={formData.firstName}
onChange={e => {
onFormDataChange({ firstName: e.target.value });
onClearError("firstName");
}}
placeholder="Taro"
disabled={loading}
error={formErrors.firstName}
/>
{formErrors.firstName && <p className="text-sm text-danger">{formErrors.firstName}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">
Last Name <span className="text-danger">*</span>
</Label>
<Input
id="lastName"
value={formData.lastName}
onChange={e => {
onFormDataChange({ lastName: e.target.value });
onClearError("lastName");
}}
placeholder="Yamada"
disabled={loading}
error={formErrors.lastName}
/>
{formErrors.lastName && <p className="text-sm text-danger">{formErrors.lastName}</p>}
</div>
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email">
Email <span className="text-danger">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={e => {
onFormDataChange({ email: e.target.value });
onClearError("email");
}}
placeholder="your@email.com"
disabled={loading}
error={formErrors.email}
/>
{formErrors.email && <p className="text-sm text-danger">{formErrors.email}</p>}
</div>
{/* Address */}
<div className="space-y-2">
<Label>
Installation Address <span className="text-danger">*</span>
</Label>
<JapanAddressForm onChange={onAddressChange} disabled={loading} />
{formErrors.address && <p className="text-sm text-danger">{formErrors.address}</p>}
</div>
{/* Error */}
{error && (
<div className="p-4 rounded-xl bg-danger/10 border border-danger/20">
<p className="text-sm text-danger">{error}</p>
</div>
)}
{/* Submit */}
<Button
type="button"
onClick={onSubmit}
disabled={loading}
loading={loading}
className="w-full h-12"
>
{loading ? "Sending Code..." : "Continue"}
{!loading && <ArrowRight className="h-4 w-4 ml-2" />}
</Button>
</div>
);
}
interface OtpStepProps {
email: string;
otpCode: string;
loading: boolean;
error: string | null;
attemptsRemaining: number | null;
onOtpChange: (code: string) => void;
onVerify: (code: string) => void;
onResend: () => void;
onBack: () => void;
}
function OtpStep({
email,
otpCode,
loading,
error,
attemptsRemaining,
onOtpChange,
onVerify,
onResend,
onBack,
}: OtpStepProps) {
return (
<div className="space-y-8">
<div className="text-center space-y-4">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center">
<Mail className="h-8 w-8 text-primary" />
</div>
</div>
<div>
<h2 className="text-xl font-semibold text-foreground mb-2">Check your email</h2>
<p className="text-sm text-muted-foreground">We sent a verification code to</p>
<p className="font-medium text-foreground mt-1">{email}</p>
</div>
</div>
{/* OTP Input */}
<div className="flex justify-center">
<OtpInput
value={otpCode}
onChange={onOtpChange}
onComplete={onVerify}
disabled={loading}
error={
error && attemptsRemaining !== null
? `${error} (${attemptsRemaining} attempts remaining)`
: (error ?? undefined)
}
/>
</div>
{/* Resend */}
<div className="text-center">
<button
type="button"
onClick={onResend}
disabled={loading}
className="text-sm text-primary hover:underline disabled:opacity-50"
>
Didn&apos;t receive the code? Resend
</button>
</div>
{/* Back */}
<Button type="button" variant="ghost" onClick={onBack} disabled={loading} className="w-full">
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
</div>
);
}
interface SuccessStepProps {
requestId: string | null;
onCreateAccount: () => void;
onMaybeLater: () => void;
}
function SuccessStep({ requestId, onCreateAccount, onMaybeLater }: SuccessStepProps) {
return (
<div className="space-y-8">
<div className="text-center space-y-4">
<div className="flex justify-center">
<div className="h-20 w-20 rounded-full bg-success/10 border-2 border-success/20 flex items-center justify-center">
<CheckCircle className="h-10 w-10 text-success" />
</div>
</div>
<div>
<h2 className="text-2xl font-bold text-foreground mb-3">Request Submitted!</h2>
<p className="text-muted-foreground max-w-md mx-auto">
We&apos;re checking internet availability at your address. Our team will review this and
get back to you within 1-2 business days.
</p>
</div>
{requestId && (
<p className="text-xs text-muted-foreground">
Reference: <span className="font-mono">{requestId}</span>
</p>
)}
</div>
{/* What's next */}
<div className="bg-muted/50 rounded-xl p-6 space-y-4">
<h3 className="font-semibold text-foreground text-center">
What would you like to do next?
</h3>
<div className="space-y-3">
<Button type="button" onClick={onCreateAccount} className="w-full h-12">
Create Account Now
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
<Button type="button" variant="outline" onClick={onMaybeLater} className="w-full h-12">
<Clock className="h-4 w-4 mr-2" />
Maybe Later
</Button>
</div>
<p className="text-xs text-muted-foreground text-center">
You can create your account anytime using the same email address.
</p>
</div>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function PublicEligibilityCheckView() {
const router = useRouter();
const servicesBasePath = useServicesBasePath();
const [step, setStep] = useState<Step>("form");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState<FormData>({
firstName: "",
lastName: "",
email: "",
address: null,
});
const [formErrors, setFormErrors] = useState<FormErrors>({});
const [isAddressComplete, setIsAddressComplete] = useState(false);
// OTP state
const [otpCode, setOtpCode] = useState("");
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null);
// Success state
const [requestId, setRequestId] = useState<string | null>(null);
// Handle form data changes
const handleFormDataChange = useCallback((data: Partial<FormData>) => {
setFormData(prev => ({ ...prev, ...data }));
}, []);
// Handle address form changes
const handleAddressChange = useCallback((data: JapanAddressFormData, isComplete: boolean) => {
setFormData(prev => ({ ...prev, address: data }));
setIsAddressComplete(isComplete);
if (isComplete) {
setFormErrors(prev => ({ ...prev, address: undefined }));
}
}, []);
// Clear specific form error
const handleClearError = useCallback((field: keyof FormErrors) => {
setFormErrors(prev => ({ ...prev, [field]: undefined }));
}, []);
// Validate email format
const isValidEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
// Validate form
const validateForm = (): boolean => {
const errors: FormErrors = {};
if (!formData.firstName.trim()) {
errors.firstName = "First name is required";
}
if (!formData.lastName.trim()) {
errors.lastName = "Last name is required";
}
if (!formData.email.trim()) {
errors.email = "Email is required";
} else if (!isValidEmail(formData.email)) {
errors.email = "Enter a valid email address";
}
if (!isAddressComplete) {
errors.address = "Please complete the address";
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
// Submit form and send OTP
const handleFormSubmit = async () => {
if (!validateForm()) return;
setLoading(true);
setError(null);
try {
await sendVerificationCode({ email: formData.email });
setStep("otp");
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
};
// Verify OTP and create SF Account
const handleVerifyOtp = async (code: string) => {
if (code.length !== 6) return;
setLoading(true);
setError(null);
try {
// Step 1: Verify the OTP
const verifyResult = await verifyCode({
email: formData.email,
code,
});
if (!verifyResult.verified) {
setAttemptsRemaining(verifyResult.attemptsRemaining ?? null);
setError(verifyResult.error || "Invalid verification code");
setOtpCode(""); // Clear for retry
return;
}
if (!verifyResult.sessionToken) {
setError("Session error. Please try again.");
return;
}
// Step 2: Immediately create SF Account + Case
const whmcsAddress = formData.address ? prepareWhmcsAddressFields(formData.address) : null;
const eligibilityResult = await quickEligibilityCheck({
sessionToken: verifyResult.sessionToken,
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(),
address: {
address1: whmcsAddress?.address1 || "",
address2: whmcsAddress?.address2 || "",
city: whmcsAddress?.city || "",
state: whmcsAddress?.state || "",
postcode: whmcsAddress?.postcode || "",
country: "JP",
},
});
if (eligibilityResult.submitted) {
setRequestId(eligibilityResult.requestId || null);
setStep("success");
} else {
setError(eligibilityResult.message || "Failed to submit eligibility check");
}
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
};
// Handle resend OTP
const handleResendCode = async () => {
setLoading(true);
setError(null);
setOtpCode("");
try {
await sendVerificationCode({ email: formData.email });
setAttemptsRemaining(null);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
};
// Handle "Create Account Now" - redirect to get-started with email
const handleCreateAccount = () => {
// Store email in sessionStorage for get-started page to pick up
sessionStorage.setItem("get-started-email", formData.email);
sessionStorage.setItem("get-started-verified", "true");
router.push(`/auth/get-started?email=${encodeURIComponent(formData.email)}&verified=true`);
};
// Handle "Maybe Later"
const handleMaybeLater = () => {
// SF Account already created during quickEligibilityCheck, just go back to plans
router.push(`${servicesBasePath}/internet`);
};
// Handle back from OTP step
const handleBackFromOtp = () => {
setStep("form");
setError(null);
setOtpCode("");
};
// Step titles and descriptions
const stepMeta = {
form: {
title: "Check Availability",
description: "Enter your details to check if internet service is available at your address.",
},
otp: {
title: "Verify Email",
description: "We need to verify your email before checking availability.",
},
success: {
title: "Request Submitted",
description: "Your availability check request has been submitted.",
},
};
return (
<div className="max-w-xl mx-auto px-4 sm:px-6 pb-20 pt-8">
<ServicesBackLink href={`${servicesBasePath}/internet`} label="Back to Plans" />
{/* Header */}
<div className="mt-8 mb-8 text-center">
<div className="flex justify-center mb-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/20">
{step === "form" && <MapPin className="h-7 w-7 text-primary" />}
{step === "otp" && <Mail className="h-7 w-7 text-primary" />}
{step === "success" && <CheckCircle className="h-7 w-7 text-success" />}
</div>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-2">
{stepMeta[step].title}
</h1>
<p className="text-muted-foreground max-w-md mx-auto text-sm">
{stepMeta[step].description}
</p>
</div>
{/* Progress indicator */}
{step !== "success" && (
<div className="flex items-center justify-center gap-2 mb-8">
{["form", "otp"].map((s, i) => (
<div key={s} className="flex items-center gap-2">
<div
className={`h-2 w-8 rounded-full transition-colors ${
step === s || (step === "otp" && s === "form") ? "bg-primary" : "bg-muted"
}`}
/>
{i < 1 && <div className="h-px w-4 bg-border" />}
</div>
))}
</div>
)}
{/* Card */}
<div className="bg-card border border-border rounded-2xl p-6 sm:p-8 shadow-[var(--cp-shadow-1)]">
{/* Form Step */}
{step === "form" && (
<FormStep
formData={formData}
formErrors={formErrors}
isAddressComplete={isAddressComplete}
loading={loading}
error={error}
onFormDataChange={handleFormDataChange}
onAddressChange={handleAddressChange}
onClearError={handleClearError}
onSubmit={handleFormSubmit}
/>
)}
{/* OTP Step */}
{step === "otp" && (
<OtpStep
email={formData.email}
otpCode={otpCode}
loading={loading}
error={error}
attemptsRemaining={attemptsRemaining}
onOtpChange={setOtpCode}
onVerify={handleVerifyOtp}
onResend={handleResendCode}
onBack={handleBackFromOtp}
/>
)}
{/* Success Step */}
{step === "success" && (
<SuccessStep
requestId={requestId}
onCreateAccount={handleCreateAccount}
onMaybeLater={handleMaybeLater}
/>
)}
</div>
{/* Help text */}
{step === "form" && (
<p className="text-center text-xs text-muted-foreground mt-6">
Already have an account?{" "}
<Link href="/auth/login" className="text-primary hover:underline">
Sign in
</Link>{" "}
to check availability faster.
</p>
)}
</div>
);
}
export default PublicEligibilityCheckView;