Enhance authentication and password management features

- Added new endpoint for retrieving account status by email in AuthController.
- Implemented change password functionality with validation in AuthService.
- Updated password strength validation to require special characters across relevant DTOs.
- Introduced optional API Access Key in environment configuration for WHMCS.
- Refactored user address update logic in UsersController to improve clarity and maintainability.
- Enhanced error handling in various services to provide more user-friendly messages.
- Updated frontend components to support new password change and account status features.
This commit is contained in:
T. Narantuya 2025-09-02 13:52:13 +09:00
parent 0f7d680782
commit cc2a6a3046
42 changed files with 1493 additions and 569 deletions

View File

@ -47,6 +47,8 @@ CORS_ORIGIN="http://localhost:3000"
WHMCS_BASE_URL="https://accounts.asolutions.co.jp"
WHMCS_API_IDENTIFIER="your_whmcs_api_identifier"
WHMCS_API_SECRET="your_whmcs_api_secret"
# Optional: Some deployments require an API Access Key in addition to credentials
# WHMCS_API_ACCESS_KEY="your_whmcs_api_access_key"
# Salesforce Integration (use your actual credentials)
SF_LOGIN_URL="https://asolutions.my.salesforce.com"
@ -90,4 +92,3 @@ NODE_OPTIONS="--no-deprecation"
# 2. Frontend and Backend run locally (outside containers) for hot-reloading
# 3. Only database and cache services run in containers

View File

@ -8,9 +8,14 @@ import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
import { SignupDto } from "./dto/signup.dto";
import { RequestPasswordResetDto } from "./dto/request-password-reset.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto";
import { ChangePasswordDto } from "./dto/change-password.dto";
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
import { SetPasswordDto } from "./dto/set-password.dto";
import { ValidateSignupDto } from "./dto/validate-signup.dto";
import {
AccountStatusRequestDto,
AccountStatusResponseDto,
} from "./dto/account-status.dto";
import { Public } from "./decorators/public.decorator";
@ApiTags("auth")
@ -39,6 +44,16 @@ export class AuthController {
return this.authService.healthCheck();
}
@Public()
@Post("account-status")
@ApiOperation({ summary: "Get account status by email" })
@ApiResponse({ status: 200, description: "Account status", type: Object })
async accountStatus(
@Body() body: AccountStatusRequestDto
): Promise<AccountStatusResponseDto> {
return this.authService.getAccountStatus(body.email);
}
@Public()
@Post("signup")
@UseGuards(AuthThrottleGuard)
@ -131,6 +146,17 @@ export class AuthController {
return this.authService.resetPassword(body.token, body.password);
}
@Post("change-password")
@Throttle({ default: { limit: 5, ttl: 300000 } })
@ApiOperation({ summary: "Change password (authenticated)" })
@ApiResponse({ status: 200, description: "Password changed successfully" })
async changePassword(
@Req() req: Request & { user: { id: string } },
@Body() body: ChangePasswordDto
) {
return this.authService.changePassword(req.user.id, body.currentPassword, body.newPassword);
}
@Get("me")
@ApiOperation({ summary: "Get current authentication status" })
getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role?: string } }) {

View File

@ -3,6 +3,7 @@ import {
UnauthorizedException,
ConflictException,
BadRequestException,
NotFoundException,
Inject,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
@ -199,18 +200,25 @@ export class AuthService {
// Enhanced input validation
this.validateSignupData(signupData);
// Check if user already exists
// Check if a portal user already exists
const existingUser: PrismaUser | null = await this.usersService.findByEmailInternal(email);
if (existingUser) {
// Determine whether the user has a completed mapping (registered) or not
const mapped = await this.mappingsService.hasMapping(existingUser.id);
const message = mapped
? "You already have an account. Please sign in."
: "You already have an account with us. Please sign in to continue setup.";
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ email, reason: "User already exists" },
existingUser.id,
{ email, reason: mapped ? "mapped_user_exists" : "unmapped_user_exists" },
request,
false,
"User with this email already exists"
message
);
throw new ConflictException("User with this email already exists");
throw new ConflictException(message);
}
// Hash password with environment-based configuration
@ -246,6 +254,30 @@ export class AuthService {
// 2. Create client in WHMCS
let whmcsClient: { clientId: number };
try {
// 2.0 Pre-check for existing WHMCS client by email to avoid duplicates and guide UX
try {
const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email);
if (existingWhmcs) {
// If a mapping already exists for this WHMCS client, user should sign in
const existingMapping = await this.mappingsService.findByWhmcsClientId(
existingWhmcs.id
);
if (existingMapping) {
throw new ConflictException("You already have an account. Please sign in.");
}
// Otherwise, instruct to link the existing billing account instead of creating a new one
throw new ConflictException(
"We found an existing billing account for this email. Please link your account instead."
);
}
} catch (pre) {
// Continue only if the client was not found; rethrow other errors
if (!(pre instanceof NotFoundException)) {
throw pre;
}
}
// Prepare WHMCS custom fields (IDs configurable via env)
const customerNumberFieldId = this.configService.get<string>(
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
@ -450,6 +482,21 @@ export class AuthService {
throw new UnauthorizedException("WHMCS client not found with this email address");
}
// 1.a If this WHMCS client is already mapped, direct the user to sign in instead
try {
const existingMapping = await this.mappingsService.findByWhmcsClientId(
clientDetails.id
);
if (existingMapping) {
throw new ConflictException(
"This billing account is already linked. Please sign in."
);
}
} catch (mapErr) {
if (mapErr instanceof ConflictException) throw mapErr;
// ignore not-found mapping cases; proceed with linking
}
// 2. Validate the password using ValidateLogin
try {
this.logger.debug(`About to validate WHMCS password for ${email}`);
@ -822,6 +869,125 @@ export class AuthService {
}
}
async getAccountStatus(email: string) {
// Normalize email
const normalized = email?.toLowerCase().trim();
if (!normalized || !normalized.includes("@")) {
throw new BadRequestException("Valid email is required");
}
let portalUser: PrismaUser | null = null;
let mapped = false;
let whmcsExists = false;
let needsPasswordSet = false;
try {
portalUser = await this.usersService.findByEmailInternal(normalized);
if (portalUser) {
mapped = await this.mappingsService.hasMapping(portalUser.id);
needsPasswordSet = !portalUser.passwordHash;
}
} catch (e) {
this.logger.warn("Account status: portal lookup failed", { error: getErrorMessage(e) });
}
// If already mapped, we can assume a WHMCS client exists
if (mapped) {
whmcsExists = true;
} else {
// Try a direct WHMCS lookup by email (best-effort)
try {
const client = await this.whmcsService.getClientDetailsByEmail(normalized);
whmcsExists = !!client;
} catch (e) {
// Treat not found as no; other errors as unknown (leave whmcsExists false)
this.logger.debug("Account status: WHMCS lookup", { error: getErrorMessage(e) });
}
}
let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none";
if (portalUser && mapped) state = "both_mapped";
else if (portalUser) state = "portal_only";
else if (whmcsExists) state = "whmcs_only";
const recommendedAction = (() => {
switch (state) {
case "both_mapped":
return "sign_in" as const;
case "portal_only":
return needsPasswordSet ? ("set_password" as const) : ("sign_in" as const);
case "whmcs_only":
return "link_account" as const;
case "none":
default:
return "sign_up" as const;
}
})();
return {
state,
portalUserExists: !!portalUser,
whmcsClientExists: whmcsExists,
mapped,
needsPasswordSet,
recommendedAction,
};
}
async changePassword(userId: string, currentPassword: string, newPassword: string) {
// Fetch raw user with passwordHash
const user = await this.usersService.findByIdInternal(userId);
if (!user) {
throw new UnauthorizedException("User not found");
}
if (!user.passwordHash) {
throw new BadRequestException("No password set. Please set a password first.");
}
const isCurrentValid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!isCurrentValid) {
await this.auditService.logAuthEvent(
AuditAction.PASSWORD_CHANGE,
user.id,
{ action: "change_password", reason: "invalid_current_password" },
undefined,
false,
"Invalid current password"
);
throw new BadRequestException("Current password is incorrect");
}
// Validate new password strength (reusing signup policy)
if (newPassword.length < 8 || !/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(newPassword)) {
throw new BadRequestException(
"Password must be at least 8 characters and include uppercase, lowercase, number, and special character."
);
}
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
const saltRounds =
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
const updatedUser = await this.usersService.update(user.id, { passwordHash });
await this.auditService.logAuthEvent(
AuditAction.PASSWORD_CHANGE,
user.id,
{ action: "change_password" },
undefined,
true
);
// Issue fresh tokens
const tokens = this.generateTokens(updatedUser);
return {
user: this.sanitizeUser(updatedUser),
...tokens,
};
}
private validateSignupData(signupData: SignupDto) {
const { email, password, firstName, lastName } = signupData;
@ -839,10 +1005,10 @@ export class AuthService {
throw new BadRequestException("Password must be at least 8 characters long.");
}
// Password must contain at least one uppercase letter, one lowercase letter, and one number
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) {
// Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(password)) {
throw new BadRequestException(
"Password must contain at least one uppercase letter, one lowercase letter, and one number."
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."
);
}
}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail } from "class-validator";
export class AccountStatusRequestDto {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
}
export type AccountState = "none" | "portal_only" | "whmcs_only" | "both_mapped";
export type RecommendedAction = "sign_up" | "sign_in" | "link_account" | "set_password";
export interface AccountStatusResponseDto {
state: AccountState;
portalUserExists: boolean;
whmcsClientExists: boolean;
mapped: boolean;
needsPasswordSet?: boolean;
recommendedAction: RecommendedAction;
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MinLength, Matches } from "class-validator";
export class ChangePasswordDto {
@ApiProperty({ example: "CurrentPassword123!" })
@IsString()
@MinLength(1)
currentPassword!: string;
@ApiProperty({ example: "NewSecurePassword123!" })
@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/)
newPassword!: string;
}

View File

@ -9,6 +9,6 @@ export class ResetPasswordDto {
@ApiProperty({ example: "SecurePassword123!" })
@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/)
password!: string;
}

View File

@ -13,7 +13,7 @@ export class SetPasswordDto {
})
@IsString()
@MinLength(8, { message: "Password must be at least 8 characters long" })
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/, {
message:
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character",
})

View File

@ -55,7 +55,7 @@ export class SignupDto {
})
@IsString()
@MinLength(8, { message: "Password must be at least 8 characters long" })
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/, {
message:
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character",
})

View File

@ -33,6 +33,7 @@ export const envSchema = z.object({
WHMCS_BASE_URL: z.string().url().optional(),
WHMCS_API_IDENTIFIER: z.string().optional(),
WHMCS_API_SECRET: z.string().optional(),
WHMCS_API_ACCESS_KEY: z.string().optional(),
WHMCS_WEBHOOK_SECRET: z.string().optional(),
// Salesforce Configuration

View File

@ -9,6 +9,33 @@ import {
} from "class-validator";
import { Type } from "class-transformer";
// Allow order payload to include an address snapshot override
export class OrderAddress {
@IsOptional()
@IsString()
street?: string | null;
@IsOptional()
@IsString()
streetLine2?: string | null;
@IsOptional()
@IsString()
city?: string | null;
@IsOptional()
@IsString()
state?: string | null;
@IsOptional()
@IsString()
postalCode?: string | null;
@IsOptional()
@IsString()
country?: string | null;
}
export class OrderConfigurations {
// Activation (All order types)
@IsOptional()
@ -79,6 +106,12 @@ export class OrderConfigurations {
portingDateOfBirth?: string;
// VPN region is inferred from product VPN_Region__c field, no user input needed
// Optional address override captured at checkout
@IsOptional()
@ValidateNested()
@Type(() => OrderAddress)
address?: OrderAddress;
}
export class CreateOrderDto {

View File

@ -0,0 +1,41 @@
import { IsOptional, IsString, Length } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class UpdateAddressDto {
@ApiProperty({ description: "Street address", required: false })
@IsOptional()
@IsString()
@Length(0, 200)
street?: string;
@ApiProperty({ description: "Street address line 2", required: false })
@IsOptional()
@IsString()
@Length(0, 200)
streetLine2?: string;
@ApiProperty({ description: "City", required: false })
@IsOptional()
@IsString()
@Length(0, 100)
city?: string;
@ApiProperty({ description: "State/Prefecture", required: false })
@IsOptional()
@IsString()
@Length(0, 100)
state?: string;
@ApiProperty({ description: "Postal code", required: false })
@IsOptional()
@IsString()
@Length(0, 20)
postalCode?: string;
@ApiProperty({ description: "Country (ISO alpha-2)", required: false })
@IsOptional()
@IsString()
@Length(0, 100)
country?: string;
}

View File

@ -10,7 +10,8 @@ import {
import { UsersService } from "./users.service";
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
import * as UserDto from "./dto/update-user.dto";
import * as BillingDto from "./dto/update-billing.dto";
// import * as BillingDto from "./dto/update-billing.dto"; // No longer exposed as an endpoint
import { UpdateAddressDto } from "./dto/update-address.dto";
import type { RequestWithUser } from "../auth/auth.types";
@ApiTags("users")
@ -53,15 +54,16 @@ export class UsersController {
return this.usersService.getBillingInfo(req.user.id);
}
@Patch("billing")
@ApiOperation({ summary: "Update billing information" })
@ApiResponse({ status: 200, description: "Billing information updated successfully" })
// Removed PATCH /me/billing in favor of PATCH /me/address to keep address updates explicit.
@Patch("address")
@ApiOperation({ summary: "Update mailing address" })
@ApiResponse({ status: 200, description: "Address updated successfully" })
@ApiResponse({ status: 400, description: "Invalid input data" })
@ApiResponse({ status: 401, description: "Unauthorized" })
async updateBilling(
@Req() req: RequestWithUser,
@Body() billingData: BillingDto.UpdateBillingDto
) {
return this.usersService.updateBillingInfo(req.user.id, billingData);
async updateAddress(@Req() req: RequestWithUser, @Body() address: UpdateAddressDto) {
await this.usersService.updateAddress(req.user.id, address);
// Return fresh billing info snapshot (source of truth from WHMCS)
return this.usersService.getBillingInfo(req.user.id);
}
}

View File

@ -1,5 +1,6 @@
import { getErrorMessage } from "../common/utils/error.util";
import { Injectable, Inject, NotFoundException } from "@nestjs/common";
import type { UpdateAddressDto } from "./dto/update-address.dto";
import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { PrismaService } from "../common/prisma/prisma.service";
import { User, Activity } from "@customer-portal/shared";
@ -162,6 +163,20 @@ export class UsersService {
}
}
// Internal method for auth service - returns raw user by ID with sensitive fields
async findByIdInternal(id: string): Promise<PrismaUser | null> {
const validId = this.validateUserId(id);
try {
return await this.prisma.user.findUnique({ where: { id: validId } });
} catch (error) {
this.logger.error("Failed to find user by ID (internal)", {
error: getErrorMessage(error),
});
throw new Error("Failed to find user");
}
}
async findById(id: string): Promise<EnhancedUser | null> {
const validId = this.validateUserId(id);
@ -646,25 +661,40 @@ export class UsersService {
whmcsUpdateData.companyname = billingData.company;
}
// No-op if nothing to update
if (Object.keys(whmcsUpdateData).length === 0) {
this.logger.debug({ userId }, "No billing fields provided; skipping WHMCS update");
return;
}
// Update in WHMCS (authoritative source)
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdateData);
this.logger.log({ userId }, "Successfully updated billing information in WHMCS");
} catch (error) {
this.logger.error({ userId, error }, "Failed to update billing information");
const msg = getErrorMessage(error);
this.logger.error({ userId, error: msg }, "Failed to update billing information");
// Provide user-friendly error message without exposing sensitive details
if (error instanceof Error && error.message.includes("403")) {
throw new Error(
"Access denied. Please contact support to update your billing information."
);
} else if (error instanceof Error && error.message.includes("404")) {
throw new Error("Account not found. Please contact support.");
} else {
throw new Error(
"Unable to update billing information. Please try again later or contact support."
);
// Surface API error details when available as 400 instead of 500
if (msg.includes("WHMCS API Error")) {
throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
}
if (msg.includes("HTTP ")) {
throw new BadRequestException("Upstream billing system error. Please try again.");
}
if (msg.includes("Missing required WHMCS configuration")) {
throw new BadRequestException("Billing system not configured. Please contact support.");
}
throw new BadRequestException("Unable to update billing information.");
}
}
/**
* Update only address fields in WHMCS (alias used by checkout)
*/
async updateAddress(userId: string, address: UpdateAddressDto): Promise<void> {
// Reuse the billing updater since WHMCS stores address on the client record
return this.updateBillingInfo(userId, address as unknown as UpdateBillingDto);
}
}

View File

@ -186,6 +186,22 @@ export class WhmcsCacheService {
}
}
/**
* Invalidate client data cache for a specific client
*/
async invalidateClientCache(clientId: number): Promise<void> {
try {
const key = this.buildClientKey(clientId);
await this.cacheService.del(key);
this.logger.log(`Invalidated client cache for client ${clientId}`);
} catch (error) {
this.logger.error(`Failed to invalidate client cache for client ${clientId}`, {
error: getErrorMessage(error),
});
}
}
/**
* Invalidate cache by tags
*/

View File

@ -110,8 +110,8 @@ export class WhmcsClientService {
try {
await this.connectionService.updateClient(clientId, updateData);
// Invalidate cache after update
await this.cacheService.invalidateUserCache(clientId.toString());
// Invalidate client cache after update
await this.cacheService.invalidateClientCache(clientId);
this.logger.log(`Successfully updated WHMCS client ${clientId}`);
} catch (error: unknown) {

View File

@ -37,6 +37,7 @@ export interface WhmcsApiConfig {
@Injectable()
export class WhmcsConnectionService {
private readonly config: WhmcsApiConfig;
private readonly accessKey?: string;
constructor(
@Inject(Logger) private readonly logger: Logger,
@ -50,6 +51,8 @@ export class WhmcsConnectionService {
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 3),
retryDelay: this.configService.get<number>("WHMCS_API_RETRY_DELAY", 1000),
};
// Optional API Access Key (used by some WHMCS deployments alongside API Credentials)
this.accessKey = this.configService.get<string | undefined>("WHMCS_API_ACCESS_KEY");
this.validateConfig();
}
@ -79,11 +82,19 @@ export class WhmcsConnectionService {
): Promise<T> {
const url = `${this.config.baseUrl}/includes/api.php`;
const requestParams = {
// Use WHMCS API Credential fields (identifier/secret). Do not send as username/password.
// WHMCS expects `identifier` and `secret` when authenticating with API Credentials.
const baseParams: Record<string, string> = {
action,
username: this.config.identifier,
password: this.config.secret,
identifier: this.config.identifier,
secret: this.config.secret,
responsetype: "json",
};
if (this.accessKey) {
baseParams.accesskey = this.accessKey;
}
const requestParams: Record<string, string> = {
...baseParams,
...this.sanitizeParams(params),
};
@ -101,6 +112,7 @@ export class WhmcsConnectionService {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json, text/plain, */*",
"User-Agent": "Customer-Portal/1.0",
},
body: formData,
@ -109,11 +121,16 @@ export class WhmcsConnectionService {
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseText = await response.text();
if (!response.ok) {
// Try to include a snippet of body in the error for diagnostics
const snippet = responseText?.slice(0, 300);
const error = new Error(
`HTTP ${response.status}: ${response.statusText}${snippet ? ` | Body: ${snippet}` : ""}`
);
throw error;
}
let data: WhmcsApiResponse<T>;
try {
@ -222,7 +239,15 @@ export class WhmcsConnectionService {
const sanitized = { ...params };
// Remove sensitive data from logs
const sensitiveFields = ["password", "password2", "secret", "token", "key"];
const sensitiveFields = [
"password",
"password2",
"secret",
"identifier",
"accesskey",
"token",
"key",
];
sensitiveFields.forEach(field => {
if (sanitized[field]) {
sanitized[field] = "[REDACTED]";

View File

@ -3,6 +3,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Disable Next DevTools to avoid manifest errors in dev
devtools: { enabled: false },
// Enable standalone output only for production deployment
output: process.env.NODE_ENV === "production" ? "standalone" : undefined,

View File

@ -122,7 +122,7 @@ export default function BillingPage() {
setError(null);
// Update address via API
await authenticatedApi.patch("/me/billing", {
const updated = await authenticatedApi.patch<BillingInfo>("/me/address", {
street: editedAddress.street,
streetLine2: editedAddress.streetLine2,
city: editedAddress.city,
@ -131,14 +131,8 @@ export default function BillingPage() {
country: editedAddress.country,
});
// Update local state
if (billingInfo) {
setBillingInfo({
...billingInfo,
address: editedAddress,
isComplete: true,
});
}
// Update local state from authoritative response
setBillingInfo(updated);
setEditing(false);
setEditedAddress(null);

View File

@ -60,9 +60,12 @@ export default function ProfilePage() {
const [isEditingAddress, setIsEditingAddress] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSavingAddress, setIsSavingAddress] = useState(false);
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pwdError, setPwdError] = useState<string | null>(null);
const [pwdSuccess, setPwdSuccess] = useState<string | null>(null);
const [formData, setFormData] = useState({
firstName: user?.firstName || "",
@ -80,6 +83,12 @@ export default function ProfilePage() {
country: "",
});
const [pwdForm, setPwdForm] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
// Fetch billing info on component mount
useEffect(() => {
const fetchBillingInfo = async () => {
@ -195,7 +204,7 @@ export default function ProfilePage() {
return;
}
await authenticatedApi.patch("/me/billing", {
const updated = await authenticatedApi.patch<BillingInfo>("/me/address", {
street: addressData.street,
streetLine2: addressData.streetLine2,
city: addressData.city,
@ -204,14 +213,8 @@ export default function ProfilePage() {
country: addressData.country,
});
// Update local state
if (billingInfo) {
setBillingInfo({
...billingInfo,
address: addressData,
isComplete: true,
});
}
// Update local state from authoritative response
setBillingInfo(updated);
setIsEditingAddress(false);
} catch (error) {
@ -237,6 +240,33 @@ export default function ProfilePage() {
}));
};
const handleChangePassword = async () => {
setIsChangingPassword(true);
setPwdError(null);
setPwdSuccess(null);
try {
if (!pwdForm.currentPassword || !pwdForm.newPassword) {
setPwdError("Please fill in all password fields");
return;
}
if (pwdForm.newPassword !== pwdForm.confirmPassword) {
setPwdError("New password and confirmation do not match");
return;
}
await useAuthStore.getState().changePassword(
pwdForm.currentPassword,
pwdForm.newPassword
);
setPwdSuccess("Password changed successfully.");
setPwdForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
} catch (err) {
setPwdError(err instanceof Error ? err.message : "Failed to change password");
} finally {
setIsChangingPassword(false);
}
};
// Update form data when user data changes (e.g., when Salesforce data loads)
useEffect(() => {
if (user && !isEditing) {
@ -660,6 +690,77 @@ export default function ProfilePage() {
)}
</div>
</div>
{/* Change Password */}
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
<div className="px-6 py-5 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Change Password</h2>
</div>
<div className="p-6">
{pwdSuccess && (
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
{pwdSuccess}
</div>
)}
{pwdError && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{pwdError}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Current Password
</label>
<input
type="password"
value={pwdForm.currentPassword}
onChange={e => setPwdForm(p => ({ ...p, currentPassword: e.target.value }))}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<input
type="password"
value={pwdForm.newPassword}
onChange={e => setPwdForm(p => ({ ...p, newPassword: e.target.value }))}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="New secure password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password
</label>
<input
type="password"
value={pwdForm.confirmPassword}
onChange={e => setPwdForm(p => ({ ...p, confirmPassword: e.target.value }))}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Re-enter new password"
/>
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200">
<button
onClick={() => {
void handleChangePassword();
}}
disabled={isChangingPassword}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isChangingPassword ? "Changing..." : "Change Password"}
</button>
</div>
<p className="text-xs text-gray-500 mt-3">
Password must be at least 8 characters and include uppercase, lowercase, number, and special character.
</p>
</div>
</div>
</div>
</div>
</div>

View File

@ -95,6 +95,12 @@ export default function LoginPage() {
Create one here
</Link>
</p>
<p className="text-sm text-gray-600">
Forgot your password?{" "}
<Link href="/auth/forgot-password" className="text-blue-600 hover:text-blue-500">
Reset it
</Link>
</p>
<p className="text-sm text-gray-600">
Had an account with us before?{" "}
<Link href="/auth/link-whmcs" className="text-blue-600 hover:text-blue-500">

View File

@ -16,7 +16,7 @@ const schema = z
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/),
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/),
confirmPassword: z.string(),
})
.refine(v => v.password === v.confirmPassword, {

View File

@ -17,7 +17,7 @@ const setPasswordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/,
"Password must contain uppercase, lowercase, number, and special character"
),
confirmPassword: z.string().min(1, "Please confirm your password"),

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useForm } from "react-hook-form";
@ -29,7 +29,7 @@ const step2Schema = z
.string()
.min(8, "Password must be at least 8 characters")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/,
"Password must contain uppercase, lowercase, number, and special character"
),
confirmPassword: z.string(),
@ -86,7 +86,7 @@ interface SignupData {
export default function SignupPage() {
const router = useRouter();
const { signup, isLoading } = useAuthStore();
const { signup, isLoading, checkPasswordNeeded } = useAuthStore();
const [currentStep, setCurrentStep] = useState(1);
const [error, setError] = useState<string | null>(null);
const [validationStatus, setValidationStatus] = useState<{
@ -94,6 +94,12 @@ export default function SignupPage() {
whAccountValid: boolean;
sfAccountId?: string;
} | null>(null);
const [emailCheckStatus, setEmailCheckStatus] = useState<{
userExists: boolean;
needsPasswordSet: boolean;
showActions: boolean;
} | null>(null);
const emailCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Step 1 Form
const step1Form = useForm<Step1Form>({
@ -155,6 +161,35 @@ export default function SignupPage() {
}
};
// Check email when user enters it (debounced)
const handleEmailCheck = useCallback(async (email: string) => {
if (!email || !email.includes("@")) {
setEmailCheckStatus(null);
return;
}
try {
const result = await checkPasswordNeeded(email);
setEmailCheckStatus({
userExists: result.userExists,
needsPasswordSet: result.needsPasswordSet,
showActions: result.userExists,
});
} catch (err) {
// Silently fail email check - don't block the flow
setEmailCheckStatus(null);
}
}, [checkPasswordNeeded]);
const debouncedEmailCheck = useCallback((email: string) => {
if (emailCheckTimeoutRef.current) {
clearTimeout(emailCheckTimeoutRef.current);
}
emailCheckTimeoutRef.current = setTimeout(() => {
void handleEmailCheck(email);
}, 500);
}, [handleEmailCheck]);
// Step 2: Personal Information
const onStep2Submit = () => {
setCurrentStep(3);
@ -332,7 +367,13 @@ export default function SignupPage() {
<div>
<Label htmlFor="email">Email address</Label>
<Input
{...step2Form.register("email")}
{...step2Form.register("email", {
onChange: (e) => {
const email = e.target.value;
step2Form.setValue("email", email);
debouncedEmailCheck(email);
}
})}
id="email"
type="email"
autoComplete="email"
@ -342,6 +383,59 @@ export default function SignupPage() {
{step2Form.formState.errors.email && (
<p className="mt-1 text-sm text-red-600">{step2Form.formState.errors.email.message}</p>
)}
{/* Email Check Status */}
{emailCheckStatus?.showActions && (
<div className="mt-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-blue-800">
We found an existing account with this email
</h4>
<div className="mt-2 space-y-2">
{emailCheckStatus.needsPasswordSet ? (
<div>
<p className="text-sm text-blue-700 mb-2">
You need to set a password for your account.
</p>
<Link
href="/auth/set-password"
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Set Password
</Link>
</div>
) : (
<div>
<p className="text-sm text-blue-700 mb-2">
Please sign in to your existing account.
</p>
<div className="flex space-x-2">
<Link
href="/auth/login"
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Sign In
</Link>
<Link
href="/auth/forgot-password"
className="inline-flex items-center px-3 py-2 border border-blue-300 text-sm leading-4 font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50"
>
Forgot Password?
</Link>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
<div>

View File

@ -5,6 +5,8 @@ import { useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { SubCard } from "@/components/ui/sub-card";
import { StatusPill } from "@/components/ui/status-pill";
import { useAuthStore } from "@/lib/auth/store";
import {
ArrowLeftIcon,
@ -172,7 +174,7 @@ export default function InvoiceDetailPage() {
</div>
{/* Invoice Card */}
<div className="bg-white rounded-lg shadow border">
<div className="bg-white rounded-2xl shadow border">
{/* Invoice Header */}
<div className="px-8 py-6 border-b border-gray-200">
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
@ -180,7 +182,19 @@ export default function InvoiceDetailPage() {
<div>
<div className="flex items-center gap-3 mb-3">
<h1 className="text-2xl font-bold text-gray-900">Invoice #{invoice.number}</h1>
<InvoiceStatusBadge status={invoice.status} />
{/* Harmonize with StatusPill while keeping existing badge for now */}
<StatusPill
label={invoice.status}
variant={
invoice.status === "Paid"
? "success"
: invoice.status === "Overdue"
? "error"
: invoice.status === "Unpaid"
? "warning"
: "neutral"
}
/>
</div>
<div className="flex flex-col sm:flex-row gap-4 text-sm">
@ -276,11 +290,10 @@ export default function InvoiceDetailPage() {
</div>
{/* Invoice Body */}
<div className="px-8 py-6">
<div className="px-8 py-6 space-y-6">
{/* Items */}
{invoice.items && invoice.items.length > 0 && (
<div className="mb-6">
<h3 className="text-base font-semibold text-gray-900 mb-3">Items & Services</h3>
<SubCard title="Items & Services">
{invoice.items && invoice.items.length > 0 ? (
<div className="space-y-2">
{invoice.items.map((item: import("@customer-portal/shared").InvoiceItem) => (
<InvoiceItemRow
@ -294,11 +307,13 @@ export default function InvoiceDetailPage() {
/>
))}
</div>
</div>
)}
) : (
<div className="text-sm text-gray-600">No items found on this invoice.</div>
)}
</SubCard>
{/* Total Section */}
<div className="border-t border-gray-200 pt-4">
{/* Totals */}
<SubCard title="Totals">
<div className="max-w-xs ml-auto">
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600">
@ -321,7 +336,43 @@ export default function InvoiceDetailPage() {
</div>
</div>
</div>
</div>
</SubCard>
{/* Actions */}
{(invoice.status === "Unpaid" || invoice.status === "Overdue") && (
<SubCard title="Payment">
<div className="flex flex-wrap gap-2">
<button
onClick={handleManagePaymentMethods}
disabled={loadingPaymentMethods}
className="inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-xs font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 transition-colors whitespace-nowrap"
>
{loadingPaymentMethods ? (
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-600 mr-1.5"></div>
) : (
<ServerIcon className="h-3 w-3 mr-1.5" />
)}
Payment Methods
</button>
<button
onClick={() => handleCreateSsoLink("pay")}
disabled={loadingPayment}
className={`inline-flex items-center justify-center px-5 py-2.5 border border-transparent text-sm font-semibold rounded-lg text-white transition-all duration-200 shadow-md whitespace-nowrap ${
invoice.status === "Overdue"
? "bg-red-600 hover:bg-red-700 ring-2 ring-red-200 hover:ring-red-300"
: "bg-blue-600 hover:bg-blue-700 hover:shadow-lg"
}`}
>
{loadingPayment ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
) : (
<ArrowTopRightOnSquareIcon className="h-4 w-4 mr-2" />
)}
{invoice.status === "Overdue" ? "Pay Overdue" : "Pay Now"}
</button>
</div>
</SubCard>
)}
</div>
</div>
</div>

View File

@ -3,6 +3,8 @@
import React, { useState, useMemo } from "react";
import Link from "next/link";
import { PageLayout } from "@/components/layout/page-layout";
import { SubCard } from "@/components/ui/sub-card";
import { StatusPill } from "@/components/ui/status-pill";
import { DataTable } from "@/components/ui/data-table";
import { SearchFilterBar } from "@/components/ui/search-filter-bar";
import {
@ -65,18 +67,18 @@ export default function InvoicesPage() {
}
};
const getStatusColor = (status: string) => {
const getStatusVariant = (status: string) => {
switch (status) {
case "Paid":
return "bg-green-100 text-green-800";
return "success" as const;
case "Unpaid":
return "bg-yellow-100 text-yellow-800";
return "warning" as const;
case "Overdue":
return "bg-red-100 text-red-800";
return "error" as const;
case "Cancelled":
return "bg-gray-100 text-gray-800";
return "neutral" as const;
default:
return "bg-gray-100 text-gray-800";
return "neutral" as const;
}
};
@ -104,13 +106,7 @@ export default function InvoicesPage() {
{
key: "status",
header: "Status",
render: (invoice: Invoice) => (
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(invoice.status)}`}
>
{invoice.status}
</span>
),
render: (invoice: Invoice) => <StatusPill label={invoice.status} variant={getStatusVariant(invoice.status)} />,
},
{
key: "amount",
@ -208,22 +204,74 @@ export default function InvoicesPage() {
title="Invoices"
description="Manage and view your billing invoices"
>
{/* Search and Filters */}
<SearchFilterBar
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Search invoices..."
filterValue={statusFilter}
onFilterChange={value => {
setStatusFilter(value);
setCurrentPage(1); // Reset to first page when filtering
}}
filterOptions={statusFilterOptions}
filterLabel="Filter by status"
/>
{/* Invoice Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
{/* Invoice Table with integrated header filters */}
<SubCard
header={
<SearchFilterBar
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Search invoices..."
filterValue={statusFilter}
onFilterChange={value => {
setStatusFilter(value);
setCurrentPage(1); // Reset to first page when filtering
}}
filterOptions={statusFilterOptions}
filterLabel="Filter by status"
/>
}
headerClassName="bg-gray-50 rounded md:p-2 p-1 mb-1"
footer={
pagination && filteredInvoices.length > 0 ? (
<div className="px-1 sm:px-0 py-1 flex items-center justify-between">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setCurrentPage(Math.min(pagination?.totalPages || 1, currentPage + 1))}
disabled={currentPage === (pagination?.totalPages || 1)}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{(currentPage - 1) * itemsPerPage + 1}</span>{" "}
to{" "}
<span className="font-medium">{Math.min(currentPage * itemsPerPage, pagination?.totalItems || 0)}</span>{" "}
of <span className="font-medium">{pagination?.totalItems || 0}</span> results
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setCurrentPage(Math.min(pagination?.totalPages || 1, currentPage + 1))}
disabled={currentPage === (pagination?.totalPages || 1)}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
</div>
</div>
) : undefined
}
>
<DataTable
data={filteredInvoices}
columns={invoiceColumns}
@ -237,64 +285,10 @@ export default function InvoicesPage() {
}}
onRowClick={invoice => (window.location.href = `/billing/invoices/${invoice.id}`)}
/>
</div>
</SubCard>
{/* Pagination */}
{pagination && filteredInvoices.length > 0 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6 rounded-b-lg">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setCurrentPage(Math.min(pagination?.totalPages || 1, currentPage + 1))}
disabled={currentPage === (pagination?.totalPages || 1)}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{(currentPage - 1) * itemsPerPage + 1}</span>{" "}
to{" "}
<span className="font-medium">
{Math.min(currentPage * itemsPerPage, pagination?.totalItems || 0)}
</span>{" "}
of <span className="font-medium">{pagination?.totalItems || 0}</span> results
</p>
</div>
<div>
<nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() =>
setCurrentPage(Math.min(pagination?.totalPages || 1, currentPage + 1))
}
disabled={currentPage === (pagination?.totalPages || 1)}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
</div>
</div>
)}
{/* Pagination moved to SubCard footer above */}
</PageLayout>
);
}

View File

@ -1,20 +1,32 @@
"use client";
import { logger } from "@/lib/logger";
import { useState } from "react";
import { useState, useEffect } from "react";
import { PageLayout } from "@/components/layout/page-layout";
import { useAuthStore } from "@/lib/auth/store";
import { authenticatedApi, ApiError } from "@/lib/api";
import { usePaymentRefresh } from "@/hooks/usePaymentRefresh";
import {
CreditCardIcon,
ArrowTopRightOnSquareIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { InlineToast } from "@/components/ui/inline-toast";
export default function PaymentMethodsPage() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { token } = useAuthStore();
const paymentRefresh = usePaymentRefresh({
// Lightweight refresh: we only care about the count here
refetch: async () => ({
data: await authenticatedApi.get<{ totalCount: number; paymentMethods: unknown[] }>(
"/invoices/payment-methods"
),
}),
hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,
attachFocusListeners: true,
});
const openPaymentMethods = async () => {
try {
@ -46,6 +58,12 @@ export default function PaymentMethodsPage() {
}
};
// When returning from WHMCS tab, auto-refresh payment methods and show a short toast
useEffect(() => {
// token gate only (hook already checks focus listeners)
if (!token) return;
}, [token]);
// Show error state
if (error) {
return (
@ -79,6 +97,7 @@ export default function PaymentMethodsPage() {
title="Payment Methods"
description="Manage your saved payment methods and billing information"
>
<InlineToast visible={paymentRefresh.toast.visible} text={paymentRefresh.toast.text} tone={paymentRefresh.toast.tone} />
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Payment Methods Card */}

View File

@ -3,10 +3,15 @@
import { useState, useEffect, useMemo, useCallback, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { PageLayout } from "@/components/layout/page-layout";
import { ShieldCheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { ShieldCheckIcon, ExclamationTriangleIcon, CreditCardIcon } from "@heroicons/react/24/outline";
import { authenticatedApi } from "@/lib/api";
import { AddressConfirmation } from "@/components/checkout/address-confirmation";
import { usePaymentMethods } from "@/hooks/useInvoices";
import { usePaymentRefresh } from "@/hooks/usePaymentRefresh";
import { logger } from "@/lib/logger";
import { SubCard } from "@/components/ui/sub-card";
import { StatusPill } from "@/components/ui/status-pill";
import { InlineToast } from "@/components/ui/inline-toast";
import {
InternetPlan,
@ -38,6 +43,9 @@ function CheckoutContent() {
const [submitting, setSubmitting] = useState(false);
const [addressConfirmed, setAddressConfirmed] = useState(false);
const [confirmedAddress, setConfirmedAddress] = useState<Address | null>(null);
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: "info" | "success" | "warning" }>(
{ visible: false, text: "", tone: "info" }
);
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
loading: true,
error: null,
@ -53,6 +61,12 @@ function CheckoutContent() {
refetch: refetchPaymentMethods,
} = usePaymentMethods();
const paymentRefresh = usePaymentRefresh({
refetch: refetchPaymentMethods,
hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,
attachFocusListeners: true,
});
const orderType = (() => {
const type = params.get("type") || "internet";
// Map to backend expected values
@ -246,11 +260,13 @@ function CheckoutContent() {
};
const handleAddressConfirmed = useCallback((address?: Address) => {
logger.info("Address confirmed in checkout", { address });
setAddressConfirmed(true);
setConfirmedAddress(address || null);
}, []);
const handleAddressIncomplete = useCallback(() => {
logger.info("Address marked as incomplete in checkout");
setAddressConfirmed(false);
setConfirmedAddress(null);
}, []);
@ -276,7 +292,7 @@ function CheckoutContent() {
>
<div className="text-center py-12">
<p className="text-red-600 mb-4">{checkoutState.error}</p>
<button onClick={() => router.back()} className="text-blue-600 hover:text-blue-800">
<button type="button" onClick={() => router.back()} className="text-blue-600 hover:text-blue-800">
Go Back
</button>
</div>
@ -286,42 +302,131 @@ function CheckoutContent() {
return (
<PageLayout
title="Submit Order"
description="Submit your order for review and approval"
title="Checkout"
description="Verify your address, review totals, and submit your order"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-2xl mx-auto">
{/* Address Confirmation */}
<AddressConfirmation
onAddressConfirmed={handleAddressConfirmed}
onAddressIncomplete={handleAddressIncomplete}
orderType={orderType}
/>
<div className="max-w-2xl mx-auto space-y-8">
<InlineToast visible={paymentRefresh.toast.visible} text={paymentRefresh.toast.text} tone={paymentRefresh.toast.tone} />
{/* Confirm Details - single card with Address + Payment */}
<div className="bg-gray-50 border border-gray-200 rounded-2xl p-6 md:p-7 shadow-sm">
<div className="flex items-center gap-3 mb-6">
<ShieldCheckIcon className="w-6 h-6 text-blue-600" />
<h2 className="text-lg font-semibold text-gray-900">Confirm Details</h2>
</div>
<div className="space-y-5">
{/* Sub-card: Installation Address */}
<SubCard>
<AddressConfirmation
embedded
onAddressConfirmed={handleAddressConfirmed}
onAddressIncomplete={handleAddressIncomplete}
orderType={orderType}
/>
</SubCard>
{/* Order Submission Message */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-6 mb-6 text-center">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
{/* Sub-card: Billing & Payment */}
<SubCard
title="Billing & Payment"
icon={<CreditCardIcon className="w-5 h-5 text-blue-600" />}
right={
paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
<StatusPill label="Verified" variant="success" />
) : undefined
}
>
{paymentMethodsLoading ? (
<div className="flex items-center gap-3 text-sm text-gray-600">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
Checking payment methods...
</div>
) : paymentMethodsError ? (
<div className="bg-amber-50 border border-amber-200 rounded-md p-3">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-amber-800 text-sm font-medium">Unable to verify payment methods</p>
<p className="text-amber-700 text-sm mt-1">If you just added a payment method, try refreshing.</p>
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => {
void paymentRefresh.triggerRefresh();
}}
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
>
Check Again
</button>
<button
type="button"
onClick={() => router.push("/billing/payments")}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors"
>
Add Payment Method
</button>
</div>
</div>
</div>
</div>
) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
<p className="text-sm text-green-700">Payment will be processed using your card on file after approval.</p>
) : (
<div className="bg-red-50 border border-red-200 rounded-md p-3">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-red-800 text-sm font-medium">No payment method on file</p>
<p className="text-red-700 text-sm mt-1">Add a payment method to submit your order.</p>
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => {
void paymentRefresh.triggerRefresh();
}}
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
>
Check Again
</button>
<button
type="button"
onClick={() => router.push("/billing/payments")}
className="bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors"
>
Add Payment Method
</button>
</div>
</div>
</div>
</div>
)}
</SubCard>
</div>
</div>
{/* Review & Submit - prominent card with guidance */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-2xl p-6 md:p-7 text-center">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm">
<ShieldCheckIcon className="w-8 h-8 text-blue-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Submit Your Order for Review</h2>
<p className="text-gray-600 mb-4">
You&apos;ve configured your service and reviewed all details. Your order will be
submitted for review and approval.
<h2 className="text-2xl font-bold text-gray-900 mb-2">Review & Submit</h2>
<p className="text-gray-700 mb-4 max-w-xl mx-auto">
Youre almost done. Confirm your details above, then submit your order. Well review and notify you when everything is ready.
</p>
<div className="bg-white rounded-lg p-4 border border-blue-200">
<h3 className="font-semibold text-gray-900 mb-2">What happens next?</h3>
<div className="text-sm text-gray-600 space-y-1">
<p> Your order will be reviewed by our team</p>
<p> We&apos;ll set up your services in our system</p>
<p> Payment will be processed using your card on file</p>
<p> You&apos;ll receive confirmation once everything is ready</p>
<div className="bg-white rounded-lg p-4 border border-blue-200 text-left max-w-2xl mx-auto">
<h3 className="font-semibold text-gray-900 mb-2">What to expect</h3>
<div className="text-sm text-gray-700 space-y-1">
<p> Our team reviews your order and schedules setup if needed</p>
<p> We may contact you to confirm details or availability</p>
<p> We only charge your card after the order is approved</p>
<p> Youll receive confirmation and next steps by email</p>
</div>
</div>
{/* Quick Totals Summary */}
<div className="mt-4 bg-white rounded-lg p-4 border border-gray-200">
{/* Totals Summary */}
<div className="mt-4 bg-white rounded-lg p-4 border border-gray-200 max-w-2xl mx-auto">
<div className="flex justify-between items-center">
<span className="font-medium text-gray-700">Total:</span>
<span className="font-medium text-gray-700">Estimated Total</span>
<div className="text-right">
<div className="text-xl font-bold text-gray-900">
¥{checkoutState.totals.monthlyTotal.toLocaleString()}/mo
@ -336,130 +441,10 @@ function CheckoutContent() {
</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Billing Information</h3>
{paymentMethodsLoading ? (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<span className="text-gray-600 text-sm">Checking payment methods...</span>
</div>
</div>
) : paymentMethodsError ? (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-amber-800 text-sm font-medium">
Unable to verify payment methods
</p>
<p className="text-amber-700 text-sm mt-1">
We couldn&apos;t check your payment methods. If you just added a payment method,
try refreshing.
</p>
<div className="flex gap-2 mt-2">
<button
onClick={() => {
void (async () => {
try {
await authenticatedApi.post("/invoices/payment-methods/refresh");
} catch (error) {
console.warn(
"Backend cache refresh failed, using frontend refresh:",
error
);
}
try {
await refetchPaymentMethods();
} catch (error) {
console.error("Frontend refresh also failed:", error);
}
})();
}}
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
>
Refresh Cache
</button>
<button
onClick={() => router.push("/billing/payments")}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors"
>
Add Payment Method
</button>
</div>
</div>
</div>
</div>
) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<p className="text-green-800 text-sm font-medium">Payment method verified</p>
<p className="text-green-700 text-sm mt-1">
After order approval, payment will be automatically processed using your
existing payment method on file. No additional payment steps required.
</p>
</div>
</div>
</div>
) : (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-red-800 text-sm font-medium">No payment method on file</p>
<p className="text-red-700 text-sm mt-1">
You need to add a payment method before submitting your order. Please add a
credit card or other payment method to proceed.
</p>
<button
onClick={() => router.push("/billing/payments")}
className="mt-2 bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors"
>
Add Payment Method
</button>
</div>
</div>
</div>
)}
</div>
{/* Debug Info - Remove in production */}
<div className="bg-gray-100 border rounded-lg p-3 mb-4 text-xs text-gray-600">
<strong>Debug Info:</strong> Address Confirmed: {addressConfirmed ? "✅" : "❌"} (
{String(addressConfirmed)}) | Payment Methods:{" "}
{paymentMethodsLoading
? "⏳ Loading..."
: paymentMethodsError
? "❌ Error"
: paymentMethods
? `${paymentMethods.paymentMethods.length} found`
: "❌ None"}{" "}
| Order Items: {checkoutState.orderItems.length} | Can Submit:{" "}
{!(
submitting ||
checkoutState.orderItems.length === 0 ||
!addressConfirmed ||
paymentMethodsLoading ||
!paymentMethods ||
paymentMethods.paymentMethods.length === 0
)
? "✅"
: "❌"}{" "}
| Render Time: {new Date().toLocaleTimeString()}
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => {
// Construct the configure URL with current parameters to preserve data
// Add step parameter to go directly to review step
@ -479,6 +464,7 @@ function CheckoutContent() {
</button>
<button
type="button"
onClick={() => void handleSubmitOrder()}
disabled={
submitting ||
@ -515,13 +501,13 @@ function CheckoutContent() {
Submitting Order...
</span>
) : !addressConfirmed ? (
"📍 Complete Address to Continue"
"📍 Confirm Installation Address"
) : paymentMethodsLoading ? (
"⏳ Verifying Payment Method..."
) : !paymentMethods || paymentMethods.paymentMethods.length === 0 ? (
"💳 Add Payment Method to Continue"
) : (
"📋 Submit Order for Review"
"📋 Submit Order"
)}
</button>
</div>

View File

@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../styles/tokens.css";
@custom-variant dark (&:is(.dark *));

View File

@ -4,6 +4,8 @@ import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/layout/page-layout";
import { ClipboardDocumentCheckIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { SubCard } from "@/components/ui/sub-card";
import { StatusPill } from "@/components/ui/status-pill";
import { authenticatedApi } from "@/lib/api";
interface OrderItem {
@ -218,6 +220,15 @@ export default function OrderStatusPage() {
);
const serviceIcon = getServiceTypeIcon(data.orderType);
const statusVariant =
statusInfo.label.includes("Active")
? "success"
: statusInfo.label.includes("Review") ||
statusInfo.label.includes("Setting Up") ||
statusInfo.label.includes("Scheduled")
? "info"
: "neutral";
return (
<div className="bg-white border rounded-2xl p-8 mb-8">
{/* Service Header */}
@ -281,70 +292,31 @@ export default function OrderStatusPage() {
})()}
</div>
{/* Status Card */}
<div className={`border-2 rounded-xl p-6 ${statusInfo.bgColor}`}>
<div className="flex items-start gap-4">
<div
className={`w-12 h-12 rounded-full ${statusInfo.bgColor.replace("bg-", "bg-").replace("-50", "-100")} flex items-center justify-center`}
>
{data.status === "Activated" ? (
<svg
className="w-6 h-6 text-green-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className={`w-6 h-6 ${statusInfo.color}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
{/* Status Card (standardized) */}
<SubCard
title="Status"
right={<StatusPill label={statusInfo.label} variant={statusVariant as any} />}
>
<div className="text-gray-700 mb-2">{statusInfo.description}</div>
{statusInfo.nextAction && (
<div className="bg-gray-50 rounded-lg p-3 mb-3 border border-gray-200">
<p className="font-medium text-gray-900 text-sm">Next Steps</p>
<p className="text-gray-700 text-sm">{statusInfo.nextAction}</p>
</div>
<div className="flex-1">
<h3 className={`text-xl font-bold ${statusInfo.color} mb-2`}>
{statusInfo.label}
</h3>
<p className="text-gray-700 mb-2">{statusInfo.description}</p>
{statusInfo.nextAction && (
<div className="bg-white bg-opacity-60 rounded-lg p-3 mb-3">
<p className="font-medium text-gray-900 text-sm">Next Steps:</p>
<p className="text-gray-700 text-sm">{statusInfo.nextAction}</p>
</div>
)}
{statusInfo.timeline && (
<p className="text-sm text-gray-600">
<span className="font-medium">Timeline:</span> {statusInfo.timeline}
</p>
)}
</div>
</div>
</div>
)}
{statusInfo.timeline && (
<p className="text-sm text-gray-600">
<span className="font-medium">Timeline:</span> {statusInfo.timeline}
</p>
)}
</SubCard>
</div>
);
})()}
{/* Service Details */}
{data?.items && data.items.length > 0 && (
<div className="bg-white border rounded-xl p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Your Services & Products</h2>
<SubCard title="Your Services & Products">
<div className="space-y-3">
{data.items.map(item => {
// Use the actual Item_Class__c values from Salesforce documentation
@ -447,7 +419,7 @@ export default function OrderStatusPage() {
);
})}
</div>
</div>
</SubCard>
)}
{/* Pricing Summary */}
@ -457,9 +429,7 @@ export default function OrderStatusPage() {
const totals = calculateDetailedTotals(data.items);
return (
<div className="bg-white border rounded-xl p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Pricing Summary</h2>
<SubCard title="Pricing Summary">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{totals.monthlyTotal > 0 && (
<div className="bg-blue-50 rounded-lg p-4 text-center">
@ -494,15 +464,14 @@ export default function OrderStatusPage() {
</div>
</div>
</div>
</div>
</SubCard>
);
})()}
{/* Support Contact */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<SubCard title="Need Help?">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-blue-900">Need Help?</h3>
<p className="text-blue-800 text-sm">
<p className="text-gray-700 text-sm">
Questions about your order? Contact our support team.
</p>
</div>
@ -521,7 +490,7 @@ export default function OrderStatusPage() {
</a>
</div>
</div>
</div>
</SubCard>
</PageLayout>
);
}

View File

@ -4,6 +4,7 @@ import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/layout/page-layout";
import { ClipboardDocumentListIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { StatusPill } from "@/components/ui/status-pill";
import { authenticatedApi } from "@/lib/api";
interface OrderSummary {
@ -218,11 +219,16 @@ export default function OrdersPage() {
</div>
<div className="text-right">
<span
className={`px-4 py-2 rounded-full text-sm font-semibold ${statusInfo.bgColor} ${statusInfo.color}`}
>
{statusInfo.label}
</span>
<StatusPill
label={statusInfo.label}
variant={
statusInfo.label === "Active"
? "success"
: statusInfo.label === "Setting Up" || statusInfo.label === "Under Review"
? "info"
: "neutral"
}
/>
</div>
</div>

View File

@ -4,6 +4,8 @@ import { useState, useMemo } from "react";
import Link from "next/link";
import { PageLayout } from "@/components/layout/page-layout";
import { DataTable } from "@/components/ui/data-table";
import { StatusPill } from "@/components/ui/status-pill";
import { SubCard } from "@/components/ui/sub-card";
import { SearchFilterBar } from "@/components/ui/search-filter-bar";
import {
ServerIcon,
@ -14,11 +16,12 @@ import {
CalendarIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/react/24/outline";
// (duplicate SubCard import removed)
import { format } from "date-fns";
import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
import type { Subscription } from "@customer-portal/shared";
import { SubscriptionStatusBadge } from "@/features/subscriptions/components";
// Removed unused SubscriptionStatusBadge in favor of StatusPill
export default function SubscriptionsPage() {
const [searchTerm, setSearchTerm] = useState("");
@ -85,6 +88,22 @@ export default function SubscriptionsPage() {
{ value: "Terminated", label: "Terminated" },
];
const getStatusVariant = (status: string) => {
switch (status) {
case "Active":
return "success" as const;
case "Suspended":
return "warning" as const;
case "Pending":
return "info" as const;
case "Cancelled":
case "Terminated":
return "neutral" as const;
default:
return "neutral" as const;
}
};
const subscriptionColumns = [
{
key: "service",
@ -103,7 +122,7 @@ export default function SubscriptionsPage() {
key: "status",
header: "Status",
render: (subscription: Subscription) => (
<SubscriptionStatusBadge status={subscription.status} />
<StatusPill label={subscription.status} variant={getStatusVariant(subscription.status)} />
),
},
{
@ -146,7 +165,7 @@ export default function SubscriptionsPage() {
key: "nextDue",
header: "Next Due",
render: (subscription: Subscription) => (
<div className="flex items.center">
<div className="flex items-center">
<CalendarIcon className="h-4 w-4 text-gray-400 mr-2" />
<span className="text-sm text-gray-500">
{subscription.nextDue ? formatDate(subscription.nextDue) : "N/A"}
@ -218,97 +237,92 @@ export default function SubscriptionsPage() {
icon={<ServerIcon />}
title="Subscriptions"
description="Manage your active services and subscriptions"
>
<div className="mb-4 flex justify-end">
actions={
<Link
href="/catalog"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 whitespace-nowrap"
>
Order Services
</Link>
</div>
}
>
{/* Stats Cards */}
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-8 w-8 text-green-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Active</dt>
<dd className="text-lg font-medium text-gray-900">{stats.active}</dd>
</dl>
</div>
<SubCard>
<div className="flex items-center">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-8 w-8 text-green-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Active</dt>
<dd className="text-lg font-medium text-gray-900">{stats.active}</dd>
</dl>
</div>
</div>
</div>
</SubCard>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-8 w-8 text-yellow-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text.gray-500 truncate">Suspended</dt>
<dd className="text-lg font-medium text-gray-900">{stats.suspended}</dd>
</dl>
</div>
<SubCard>
<div className="flex items-center">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-8 w-8 text-yellow-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Suspended</dt>
<dd className="text-lg font-medium text-gray-900">{stats.suspended}</dd>
</dl>
</div>
</div>
</div>
</SubCard>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items.center">
<div className="flex-shrink-0">
<ClockIcon className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Pending</dt>
<dd className="text-lg font-medium text-gray-900">{stats.pending}</dd>
</dl>
</div>
<SubCard>
<div className="flex items-center">
<div className="flex-shrink-0">
<ClockIcon className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Pending</dt>
<dd className="text-lg font-medium text-gray-900">{stats.pending}</dd>
</dl>
</div>
</div>
</div>
</SubCard>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<XCircleIcon className="h-8 w-8 text-gray-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Cancelled</dt>
<dd className="text-lg font-medium text-gray-900">{stats.cancelled}</dd>
</dl>
</div>
<SubCard>
<div className="flex items-center">
<div className="flex-shrink-0">
<XCircleIcon className="h-8 w-8 text-gray-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Cancelled</dt>
<dd className="text-lg font-medium text-gray-900">{stats.cancelled}</dd>
</dl>
</div>
</div>
</div>
</SubCard>
</div>
)}
{/* Search and Filters */}
<SearchFilterBar
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Search subscriptions..."
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterOptions={statusFilterOptions}
filterLabel="Filter by status"
/>
{/* Subscriptions Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
{/* Subscriptions Table with integrated header + CTA */}
<SubCard
header={
<SearchFilterBar
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Search subscriptions..."
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterOptions={statusFilterOptions}
filterLabel="Filter by status"
/>
}
headerClassName="bg-gray-50 rounded md:p-2 p-1 mb-1"
>
<DataTable
data={filteredSubscriptions}
columns={subscriptionColumns}
@ -322,7 +336,7 @@ export default function SubscriptionsPage() {
}}
onRowClick={subscription => (window.location.href = `/subscriptions/${subscription.id}`)}
/>
</div>
</SubCard>
</PageLayout>
);
}

View File

@ -2,6 +2,8 @@
import { useState, useEffect, useCallback } from "react";
import { authenticatedApi } from "@/lib/api";
import { logger } from "@/lib/logger";
import { StatusPill } from "@/components/ui/status-pill";
import {
MapPinIcon,
PencilIcon,
@ -31,12 +33,14 @@ interface AddressConfirmationProps {
onAddressConfirmed: (address?: Address) => void;
onAddressIncomplete: () => void;
orderType?: string; // Add order type to customize behavior
embedded?: boolean; // When true, render without outer card wrapper
}
export function AddressConfirmation({
onAddressConfirmed,
onAddressIncomplete,
orderType,
embedded = false,
}: AddressConfirmationProps) {
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
const [loading, setLoading] = useState(true);
@ -73,10 +77,14 @@ export function AddressConfirmation({
}, [requiresAddressVerification, onAddressIncomplete, onAddressConfirmed]);
useEffect(() => {
logger.info("Address confirmation component mounted");
void fetchBillingInfo();
}, [fetchBillingInfo]);
const handleEdit = () => {
const handleEdit = (e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
setEditing(true);
setEditedAddress(
billingInfo?.address || {
@ -90,7 +98,10 @@ export function AddressConfirmation({
);
};
const handleSave = () => {
const handleSave = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (!editedAddress) return;
// Validate required fields
@ -107,59 +118,91 @@ export function AddressConfirmation({
return;
}
try {
setError(null);
// Use the edited address for the order (will be flagged as changed)
onAddressConfirmed(editedAddress);
setEditing(false);
setAddressConfirmed(true);
(async () => {
try {
setError(null);
// Update local state to show the new address
if (billingInfo) {
setBillingInfo({
...billingInfo,
address: editedAddress,
isComplete: true,
});
// Build minimal PATCH payload with only provided fields
const payload: Record<string, string> = {};
if (editedAddress.street) payload.street = editedAddress.street;
if (editedAddress.streetLine2) payload.streetLine2 = editedAddress.streetLine2;
if (editedAddress.city) payload.city = editedAddress.city;
if (editedAddress.state) payload.state = editedAddress.state;
if (editedAddress.postalCode) payload.postalCode = editedAddress.postalCode;
if (editedAddress.country) payload.country = editedAddress.country;
// Persist to server (WHMCS via BFF)
const updated = await authenticatedApi.patch<BillingInfo>("/me/address", payload);
// Update local state using authoritative response
setBillingInfo(updated);
// Use the edited address for the order (will be flagged as changed)
onAddressConfirmed(updated.address);
setEditing(false);
setAddressConfirmed(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update address");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update address");
}
})();
};
const handleConfirmAddress = () => {
const handleConfirmAddress = (e: React.MouseEvent<HTMLButtonElement>) => {
// Defensively prevent any parent form/link behaviors from triggering navigation
if (e && typeof e.preventDefault === "function") e.preventDefault();
if (e && typeof e.stopPropagation === "function") e.stopPropagation();
logger.info("Confirm installation address clicked");
// Ensure we have an address before confirming
if (billingInfo?.address) {
logger.info("Address confirmed", { address: billingInfo.address });
onAddressConfirmed(billingInfo.address);
setAddressConfirmed(true);
} else {
logger.warn("No billing info or address available");
setAddressConfirmed(false);
}
};
const handleCancel = () => {
const handleCancel = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setEditing(false);
setEditedAddress(null);
setError(null);
};
const statusVariant = addressConfirmed || !requiresAddressVerification ? "success" : "warning";
// Note: Avoid defining wrapper components inside render to prevent remounts (focus loss)
const wrap = (node: React.ReactNode) =>
embedded ? (
<>{node}</>
) : (
<div className="bg-white border rounded-xl p-6 mb-6">{node}</div>
);
if (loading) {
return (
<div className="bg-white border rounded-xl p-6 mb-6">
<div className="flex items-center space-x-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<span className="text-gray-600">Loading address information...</span>
</div>
return wrap(
<div className="flex items-center space-x-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<span className="text-gray-600">Loading address information...</span>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-xl p-6 mb-6">
return wrap(
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start space-x-3">
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-medium text-red-800">Address Error</h3>
<p className="text-sm text-red-700 mt-1">{error}</p>
<button
type="button"
onClick={() => void fetchBillingInfo()}
className="text-sm text-red-600 hover:text-red-500 font-medium mt-2"
>
@ -173,48 +216,31 @@ export function AddressConfirmation({
if (!billingInfo) return null;
return (
<div className="bg-white border rounded-xl p-6 mb-6">
return wrap(
<>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<MapPinIcon className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">
{isInternetOrder
? "Verify Installation Address"
: billingInfo.isComplete
? "Confirm Service Address"
: "Complete Your Address"}
</h3>
</div>
{billingInfo.isComplete && !editing && (
<button
onClick={handleEdit}
className="flex items-center space-x-2 text-blue-600 hover:text-blue-700 text-sm font-medium"
>
<PencilIcon className="h-4 w-4" />
<span>Edit</span>
</button>
)}
</div>
{/* Address should always be complete since it's required at signup */}
{isInternetOrder && !addressConfirmed && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex items-start space-x-3">
<MapPinIcon className="h-5 w-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-blue-800">
<strong>Internet Installation Address Verification Required</strong>
</p>
<p className="text-sm text-blue-700 mt-1">
Please verify this is the correct address for your internet installation. A
technician will visit this location for setup.
</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{isInternetOrder
? "Installation Address"
: billingInfo.isComplete
? "Service Address"
: "Complete Your Address"}
</h3>
</div>
</div>
)}
<div className="flex items-center gap-2">
{/* Consistent status pill placement (right side) */}
<StatusPill
label={statusVariant === "success" ? "Verified" : "Pending confirmation"}
variant={statusVariant}
/>
</div>
</div>
{/* Consolidated single card without separate banner */}
{editing ? (
<div className="space-y-4">
@ -316,6 +342,7 @@ export function AddressConfirmation({
<div className="flex items-center space-x-3 pt-4">
<button
type="button"
onClick={handleSave}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
@ -323,6 +350,7 @@ export function AddressConfirmation({
<span>Save Address</span>
</button>
<button
type="button"
onClick={handleCancel}
className="flex items-center space-x-2 bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 transition-colors"
>
@ -334,56 +362,78 @@ export function AddressConfirmation({
) : (
<div>
{billingInfo.address.street ? (
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-gray-900">
<p className="font-medium">{billingInfo.address.street}</p>
{billingInfo.address.streetLine2 && <p>{billingInfo.address.streetLine2}</p>}
<p>
{billingInfo.address.city}, {billingInfo.address.state}{" "}
{billingInfo.address.postalCode}
<div className="space-y-4">
<div className="text-gray-900 space-y-1">
<p className="font-semibold text-base">{billingInfo.address.street}</p>
{billingInfo.address.streetLine2 && (
<p className="text-gray-700">{billingInfo.address.streetLine2}</p>
)}
<p className="text-gray-700">
{billingInfo.address.city}, {billingInfo.address.state} {billingInfo.address.postalCode}
</p>
<p>{billingInfo.address.country}</p>
<p className="text-gray-600">{billingInfo.address.country}</p>
</div>
{/* Address Confirmation for Internet Orders */}
{/* Status message for Internet orders when pending */}
{isInternetOrder && !addressConfirmed && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<ExclamationTriangleIcon className="h-4 w-4 text-amber-500" />
<span className="text-sm text-amber-700 font-medium">
Verification Required
</span>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800">Verification Required</p>
<p className="text-sm text-amber-700 mt-1">
Please confirm this is the correct installation address for your internet service.
</p>
</div>
<button
onClick={handleConfirmAddress}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors font-medium"
>
Confirm Installation Address
</button>
</div>
</div>
)}
{/* Address Confirmed Status */}
{addressConfirmed && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex items-center space-x-2">
<CheckIcon className="h-4 w-4 text-green-500" />
<span className="text-sm text-green-700 font-medium">
{isInternetOrder ? "Installation Address Confirmed" : "Address Confirmed"}
</span>
</div>
{/* Action buttons */}
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="flex items-center gap-3">
{/* Primary action when pending for Internet orders */}
{isInternetOrder && !addressConfirmed && !editing && (
<button
type="button"
onClick={handleConfirmAddress}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
}
}}
className="bg-blue-600 text-white px-6 py-2.5 rounded-lg hover:bg-blue-700 transition-colors font-medium shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Confirm Address
</button>
)}
</div>
)}
{/* Edit button - always on the right */}
{billingInfo.isComplete && !editing && (
<button
type="button"
onClick={handleEdit}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg border border-blue-200 hover:border-blue-300 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 shadow-sm hover:shadow-md"
>
<PencilIcon className="h-4 w-4" />
Edit Address
</button>
)}
</div>
</div>
) : (
<div className="text-center py-8">
<MapPinIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No address on file</p>
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<MapPinIcon className="h-8 w-8 text-gray-400" />
</div>
<h4 className="text-lg font-medium text-gray-900 mb-2">No Address on File</h4>
<p className="text-gray-600 mb-6">Please add your installation address to continue.</p>
<button
type="button"
onClick={handleEdit}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
className="bg-blue-600 text-white px-6 py-2.5 rounded-lg hover:bg-blue-700 transition-colors font-medium shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Add Address
</button>
@ -391,6 +441,6 @@ export function AddressConfirmation({
)}
</div>
)}
</div>
</>
);
}

View File

@ -110,7 +110,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
}
return (
<div className="h-screen flex overflow-hidden bg-gray-100">
<div className="h-screen flex overflow-hidden bg-gray-50">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div className="fixed inset-0 flex z-40 md:hidden">

View File

@ -5,22 +5,26 @@ interface PageLayoutProps {
icon: ReactNode;
title: string;
description: string;
actions?: ReactNode; // optional right-aligned header actions (e.g., CTA button)
children: ReactNode;
}
export function PageLayout({ icon, title, description, children }: PageLayoutProps) {
export function PageLayout({ icon, title, description, actions, children }: PageLayoutProps) {
return (
<DashboardLayout>
<div className="py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center">
<div className="h-8 w-8 text-blue-600 mr-3">{icon}</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
<p className="text-gray-600">{description}</p>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center">
<div className="h-8 w-8 text-blue-600 mr-3">{icon}</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
<p className="text-gray-600">{description}</p>
</div>
</div>
{actions ? <div className="flex-shrink-0">{actions}</div> : null}
</div>
</div>

View File

@ -0,0 +1,34 @@
import type { HTMLAttributes } from "react";
type Tone = "info" | "success" | "warning" | "error";
interface InlineToastProps extends HTMLAttributes<HTMLDivElement> {
visible: boolean;
text: string;
tone?: Tone;
}
export function InlineToast({ visible, text, tone = "info", className = "", ...rest }: InlineToastProps) {
const toneClasses =
tone === "success"
? "bg-green-50 border-green-200 text-green-800"
: tone === "warning"
? "bg-amber-50 border-amber-200 text-amber-800"
: tone === "error"
? "bg-red-50 border-red-200 text-red-800"
: "bg-blue-50 border-blue-200 text-blue-800";
return (
<div
className={`fixed bottom-6 right-6 z-50 transition-all duration-200 ${
visible ? "opacity-100 translate-y-0" : "opacity-0 pointer-events-none translate-y-2"
} ${className}`}
{...rest}
>
<div className={`flex items-center gap-2 rounded-md border px-3 py-2 shadow-lg min-w-[220px] text-sm ${toneClasses}`}>
<span>{text}</span>
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import type { HTMLAttributes } from "react";
type Variant = "success" | "warning" | "info" | "neutral" | "error";
interface StatusPillProps extends HTMLAttributes<HTMLSpanElement> {
label: string;
variant?: Variant;
}
export function StatusPill({ label, variant = "neutral", className = "", ...rest }: StatusPillProps) {
const tone =
variant === "success"
? "bg-green-50 text-green-700 ring-green-600/20"
: variant === "warning"
? "bg-amber-50 text-amber-700 ring-amber-600/20"
: variant === "info"
? "bg-blue-50 text-blue-700 ring-blue-600/20"
: variant === "error"
? "bg-red-50 text-red-700 ring-red-600/20"
: "bg-gray-50 text-gray-700 ring-gray-400/30";
return (
<span
className={`inline-flex items-center rounded-[var(--cp-pill-radius)] px-[var(--cp-pill-px)] py-[var(--cp-pill-py)] text-xs font-medium ring-1 ring-inset ${tone} ${className}`}
{...rest}
>
{label}
</span>
);
}

View File

@ -0,0 +1,47 @@
import type { ReactNode } from "react";
interface SubCardProps {
title?: string;
icon?: ReactNode;
right?: ReactNode;
header?: ReactNode; // Optional custom header content (overrides title/icon/right layout)
footer?: ReactNode; // Optional footer section separated by a subtle divider
children: ReactNode;
className?: string;
headerClassName?: string;
bodyClassName?: string;
}
export function SubCard({
title,
icon,
right,
header,
footer,
children,
className = "",
headerClassName = "",
bodyClassName = "",
}: SubCardProps) {
return (
<div
className={`border border-gray-200 bg-white shadow-[var(--cp-card-shadow)] rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] ${className}`}
>
{header ? (
<div className={`${headerClassName || "mb-3"}`}>{header}</div>
) : title ? (
<div className={`flex items-center justify-between mb-3 ${headerClassName}`}>
<div className="flex items-center gap-2">
{icon}
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
</div>
{right}
</div>
) : null}
<div className={bodyClassName}>{children}</div>
{footer ? (
<div className="mt-3 pt-3 border-t border-gray-100">{footer}</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { authenticatedApi } from "@/lib/api";
type Tone = "info" | "success" | "warning" | "error";
interface UsePaymentRefreshOptions<T> {
// Refetch function from usePaymentMethods
refetch: () => Promise<{ data: T | undefined }>;
// Given refetch result, determine if user has payment methods
hasMethods: (data: T | undefined) => boolean;
// When true, attaches focus/visibility listeners to refresh automatically
attachFocusListeners?: boolean;
}
export function usePaymentRefresh<T>({ refetch, hasMethods, attachFocusListeners = false }: UsePaymentRefreshOptions<T>) {
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
visible: false,
text: "",
tone: "info",
});
const triggerRefresh = async () => {
setToast({ visible: true, text: "Refreshing payment methods...", tone: "info" });
try {
try {
await authenticatedApi.post("/invoices/payment-methods/refresh");
} catch (err) {
// Soft-fail cache refresh, still attempt refetch
// eslint-disable-next-line no-console
console.warn("Payment methods cache refresh failed:", err);
}
const result = await refetch();
const has = hasMethods(result.data);
setToast({
visible: true,
text: has ? "Payment methods updated" : "No payment method found yet",
tone: has ? "success" : "warning",
});
} catch (_e) {
setToast({ visible: true, text: "Could not refresh payment methods", tone: "warning" });
} finally {
setTimeout(() => setToast(t => ({ ...t, visible: false })), 2200);
}
};
useEffect(() => {
if (!attachFocusListeners) return;
const onFocus = () => {
void triggerRefresh();
};
const onVis = () => {
if (document.visibilityState === "visible") void triggerRefresh();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVis);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVis);
};
}, [attachFocusListeners]);
return { toast, triggerRefresh, setToast } as const;
}

View File

@ -45,6 +45,21 @@ export interface ResetPasswordData {
password: string;
}
export interface ChangePasswordData {
currentPassword: string;
newPassword: string;
}
export interface CheckPasswordNeededData {
email: string;
}
export interface CheckPasswordNeededResponse {
needsPasswordSet: boolean;
userExists: boolean;
email?: string;
}
export interface AuthResponse {
user: {
id: string;
@ -154,6 +169,26 @@ class AuthAPI {
},
});
}
async changePassword(
token: string,
data: ChangePasswordData
): Promise<AuthResponse> {
return this.request<AuthResponse>("/auth/change-password", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
}
async checkPasswordNeeded(data: CheckPasswordNeededData): Promise<CheckPasswordNeededResponse> {
return this.request<CheckPasswordNeededResponse>("/auth/check-password-needed", {
method: "POST",
body: JSON.stringify(data),
});
}
}
export const authAPI = new AuthAPI();

View File

@ -46,6 +46,8 @@ interface AuthState {
setPassword: (email: string, password: string) => Promise<void>;
requestPasswordReset: (email: string) => Promise<void>;
resetPassword: (token: string, password: string) => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean; userExists: boolean; email?: string }>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
}
@ -164,6 +166,32 @@ export const useAuthStore = create<AuthState>()(
}
},
changePassword: async (currentPassword: string, newPassword: string) => {
const { token } = get();
if (!token) throw new Error("Not authenticated");
set({ isLoading: true });
try {
const response = await authAPI.changePassword(token, { currentPassword, newPassword });
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
set({ isLoading: false });
throw error;
}
},
checkPasswordNeeded: async (email: string) => {
try {
return await authAPI.checkPasswordNeeded({ email });
} catch (error) {
throw error;
}
},
logout: async () => {
const { token } = get();

View File

@ -9,10 +9,12 @@ interface QueryProviderProps {
}
export function QueryProvider({ children }: QueryProviderProps) {
const enableDevtools =
process.env.NEXT_PUBLIC_ENABLE_DEVTOOLS === "true" && process.env.NODE_ENV !== "production";
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
{enableDevtools ? <ReactQueryDevtools initialIsOpen={false} /> : null}
</QueryClientProvider>
);
}

View File

@ -0,0 +1,12 @@
:root {
/* Card tokens */
--cp-card-radius: 1rem; /* ~rounded-2xl */
--cp-card-padding: 1.25rem; /* ~p-5 */
--cp-card-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); /* shadow-sm */
/* Pill tokens */
--cp-pill-radius: 9999px; /* fully rounded */
--cp-pill-px: 0.5rem; /* 8px */
--cp-pill-py: 0.125rem; /* 2px */
}