feat(auth): add Password Reset Token Service and update token infrastructure
This commit is contained in:
parent
fd15324ef0
commit
971cde9d05
207
apps/bff/src/modules/auth/README.md
Normal file
207
apps/bff/src/modules/auth/README.md
Normal 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 |
|
||||||
12
apps/bff/src/modules/auth/infra/token/index.ts
Normal file
12
apps/bff/src/modules/auth/infra/token/index.ts
Normal 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";
|
||||||
@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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'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'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;
|
||||||
Loading…
x
Reference in New Issue
Block a user