diff --git a/.env.example b/.env.example
index 702afd19..1b6486f0 100644
--- a/.env.example
+++ b/.env.example
@@ -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
-
diff --git a/apps/bff/src/auth/auth.controller.ts b/apps/bff/src/auth/auth.controller.ts
index e19891c5..d9b6d880 100644
--- a/apps/bff/src/auth/auth.controller.ts
+++ b/apps/bff/src/auth/auth.controller.ts
@@ -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 {
+ 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 } }) {
diff --git a/apps/bff/src/auth/auth.service.ts b/apps/bff/src/auth/auth.service.ts
index 01a9619e..acf35c41 100644
--- a/apps/bff/src/auth/auth.service.ts
+++ b/apps/bff/src/auth/auth.service.ts
@@ -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(
"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("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."
);
}
}
diff --git a/apps/bff/src/auth/dto/account-status.dto.ts b/apps/bff/src/auth/dto/account-status.dto.ts
new file mode 100644
index 00000000..2b714975
--- /dev/null
+++ b/apps/bff/src/auth/dto/account-status.dto.ts
@@ -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;
+}
+
diff --git a/apps/bff/src/auth/dto/change-password.dto.ts b/apps/bff/src/auth/dto/change-password.dto.ts
new file mode 100644
index 00000000..5559b0b8
--- /dev/null
+++ b/apps/bff/src/auth/dto/change-password.dto.ts
@@ -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;
+}
+
diff --git a/apps/bff/src/auth/dto/reset-password.dto.ts b/apps/bff/src/auth/dto/reset-password.dto.ts
index 745462a9..97a7b74b 100644
--- a/apps/bff/src/auth/dto/reset-password.dto.ts
+++ b/apps/bff/src/auth/dto/reset-password.dto.ts
@@ -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;
}
diff --git a/apps/bff/src/auth/dto/set-password.dto.ts b/apps/bff/src/auth/dto/set-password.dto.ts
index 1e21123b..1347622e 100644
--- a/apps/bff/src/auth/dto/set-password.dto.ts
+++ b/apps/bff/src/auth/dto/set-password.dto.ts
@@ -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",
})
diff --git a/apps/bff/src/auth/dto/signup.dto.ts b/apps/bff/src/auth/dto/signup.dto.ts
index 6f960add..87769bac 100644
--- a/apps/bff/src/auth/dto/signup.dto.ts
+++ b/apps/bff/src/auth/dto/signup.dto.ts
@@ -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",
})
diff --git a/apps/bff/src/common/config/env.validation.ts b/apps/bff/src/common/config/env.validation.ts
index c3209865..a2c08bc2 100644
--- a/apps/bff/src/common/config/env.validation.ts
+++ b/apps/bff/src/common/config/env.validation.ts
@@ -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
diff --git a/apps/bff/src/orders/dto/order.dto.ts b/apps/bff/src/orders/dto/order.dto.ts
index cb48327b..d5d53b33 100644
--- a/apps/bff/src/orders/dto/order.dto.ts
+++ b/apps/bff/src/orders/dto/order.dto.ts
@@ -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 {
diff --git a/apps/bff/src/users/dto/update-address.dto.ts b/apps/bff/src/users/dto/update-address.dto.ts
new file mode 100644
index 00000000..c064a6de
--- /dev/null
+++ b/apps/bff/src/users/dto/update-address.dto.ts
@@ -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;
+}
+
diff --git a/apps/bff/src/users/users.controller.ts b/apps/bff/src/users/users.controller.ts
index 4d335192..d66e17d9 100644
--- a/apps/bff/src/users/users.controller.ts
+++ b/apps/bff/src/users/users.controller.ts
@@ -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);
}
}
diff --git a/apps/bff/src/users/users.service.ts b/apps/bff/src/users/users.service.ts
index 84254368..0bdcd698 100644
--- a/apps/bff/src/users/users.service.ts
+++ b/apps/bff/src/users/users.service.ts
@@ -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 {
+ 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 {
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 {
+ // Reuse the billing updater since WHMCS stores address on the client record
+ return this.updateBillingInfo(userId, address as unknown as UpdateBillingDto);
+ }
}
diff --git a/apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts b/apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts
index 661cf645..fc46caf3 100644
--- a/apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts
+++ b/apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts
@@ -186,6 +186,22 @@ export class WhmcsCacheService {
}
}
+ /**
+ * Invalidate client data cache for a specific client
+ */
+ async invalidateClientCache(clientId: number): Promise {
+ 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
*/
diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-client.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-client.service.ts
index ef81465b..dc4d34d5 100644
--- a/apps/bff/src/vendors/whmcs/services/whmcs-client.service.ts
+++ b/apps/bff/src/vendors/whmcs/services/whmcs-client.service.ts
@@ -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) {
diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts
index 85ca9e8a..76f60549 100644
--- a/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts
+++ b/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts
@@ -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("WHMCS_API_RETRY_ATTEMPTS", 3),
retryDelay: this.configService.get("WHMCS_API_RETRY_DELAY", 1000),
};
+ // Optional API Access Key (used by some WHMCS deployments alongside API Credentials)
+ this.accessKey = this.configService.get("WHMCS_API_ACCESS_KEY");
this.validateConfig();
}
@@ -79,11 +82,19 @@ export class WhmcsConnectionService {
): Promise {
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 = {
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 = {
+ ...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;
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]";
diff --git a/apps/portal/next.config.mjs b/apps/portal/next.config.mjs
index 0498f39f..751a8e00 100644
--- a/apps/portal/next.config.mjs
+++ b/apps/portal/next.config.mjs
@@ -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,
diff --git a/apps/portal/src/app/account/billing/page.tsx b/apps/portal/src/app/account/billing/page.tsx
index 41890246..92529251 100644
--- a/apps/portal/src/app/account/billing/page.tsx
+++ b/apps/portal/src/app/account/billing/page.tsx
@@ -122,7 +122,7 @@ export default function BillingPage() {
setError(null);
// Update address via API
- await authenticatedApi.patch("/me/billing", {
+ const updated = await authenticatedApi.patch("/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);
diff --git a/apps/portal/src/app/account/profile/page.tsx b/apps/portal/src/app/account/profile/page.tsx
index 14054b9b..efece5f9 100644
--- a/apps/portal/src/app/account/profile/page.tsx
+++ b/apps/portal/src/app/account/profile/page.tsx
@@ -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(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const [pwdError, setPwdError] = useState(null);
+ const [pwdSuccess, setPwdSuccess] = useState(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("/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() {
)}
+
+ {/* Change Password */}
+
+
+
Change Password
+
+
+ {pwdSuccess && (
+
+ {pwdSuccess}
+
+ )}
+ {pwdError && (
+
+ {pwdError}
+
+ )}
+
+
+ {
+ 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"}
+
+
+
+ Password must be at least 8 characters and include uppercase, lowercase, number, and special character.
+
+
+
diff --git a/apps/portal/src/app/auth/login/page.tsx b/apps/portal/src/app/auth/login/page.tsx
index 8cc0840a..0ced935b 100644
--- a/apps/portal/src/app/auth/login/page.tsx
+++ b/apps/portal/src/app/auth/login/page.tsx
@@ -95,6 +95,12 @@ export default function LoginPage() {
Create one here
+
+ Forgot your password?{" "}
+
+ Reset it
+
+
Had an account with us before?{" "}
diff --git a/apps/portal/src/app/auth/reset-password/page.tsx b/apps/portal/src/app/auth/reset-password/page.tsx
index a054dfc8..c2f22005 100644
--- a/apps/portal/src/app/auth/reset-password/page.tsx
+++ b/apps/portal/src/app/auth/reset-password/page.tsx
@@ -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, {
diff --git a/apps/portal/src/app/auth/set-password/page.tsx b/apps/portal/src/app/auth/set-password/page.tsx
index 2d569215..404280f3 100644
--- a/apps/portal/src/app/auth/set-password/page.tsx
+++ b/apps/portal/src/app/auth/set-password/page.tsx
@@ -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"),
diff --git a/apps/portal/src/app/auth/signup/page.tsx b/apps/portal/src/app/auth/signup/page.tsx
index 20953ca2..73fd03d7 100644
--- a/apps/portal/src/app/auth/signup/page.tsx
+++ b/apps/portal/src/app/auth/signup/page.tsx
@@ -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(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(null);
// Step 1 Form
const step1Form = useForm({
@@ -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() {
Email address
{
+ 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 && (
{step2Form.formState.errors.email.message}
)}
+
+ {/* Email Check Status */}
+ {emailCheckStatus?.showActions && (
+
+
+
+
+
+ We found an existing account with this email
+
+
+ {emailCheckStatus.needsPasswordSet ? (
+
+
+ You need to set a password for your account.
+
+
+ Set Password
+
+
+ ) : (
+
+
+ Please sign in to your existing account.
+
+
+
+ Sign In
+
+
+ Forgot Password?
+
+
+
+ )}
+
+
+
+
+ )}
diff --git a/apps/portal/src/app/billing/invoices/[id]/page.tsx b/apps/portal/src/app/billing/invoices/[id]/page.tsx
index 16e70ab3..0c66d7ec 100644
--- a/apps/portal/src/app/billing/invoices/[id]/page.tsx
+++ b/apps/portal/src/app/billing/invoices/[id]/page.tsx
@@ -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() {
{/* Invoice Card */}
-
+
{/* Invoice Header */}
@@ -180,7 +182,19 @@ export default function InvoiceDetailPage() {
Invoice #{invoice.number}
-
+ {/* Harmonize with StatusPill while keeping existing badge for now */}
+
@@ -276,11 +290,10 @@ export default function InvoiceDetailPage() {
{/* Invoice Body */}
-
+
{/* Items */}
- {invoice.items && invoice.items.length > 0 && (
-
-
Items & Services
+
+ {invoice.items && invoice.items.length > 0 ? (
{invoice.items.map((item: import("@customer-portal/shared").InvoiceItem) => (
))}
-
- )}
+ ) : (
+
No items found on this invoice.
+ )}
+
- {/* Total Section */}
-
+ {/* Totals */}
+
@@ -321,7 +336,43 @@ export default function InvoiceDetailPage() {
-
+
+
+ {/* Actions */}
+ {(invoice.status === "Unpaid" || invoice.status === "Overdue") && (
+
+
+
+ {loadingPaymentMethods ? (
+
+ ) : (
+
+ )}
+ Payment Methods
+
+
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 ? (
+
+ ) : (
+
+ )}
+ {invoice.status === "Overdue" ? "Pay Overdue" : "Pay Now"}
+
+
+
+ )}
diff --git a/apps/portal/src/app/billing/invoices/page.tsx b/apps/portal/src/app/billing/invoices/page.tsx
index 110e2340..8df2b21b 100644
--- a/apps/portal/src/app/billing/invoices/page.tsx
+++ b/apps/portal/src/app/billing/invoices/page.tsx
@@ -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) => (
-
- {invoice.status}
-
- ),
+ render: (invoice: Invoice) =>
,
},
{
key: "amount",
@@ -208,22 +204,74 @@ export default function InvoicesPage() {
title="Invoices"
description="Manage and view your billing invoices"
>
- {/* Search and Filters */}
-
{
- setStatusFilter(value);
- setCurrentPage(1); // Reset to first page when filtering
- }}
- filterOptions={statusFilterOptions}
- filterLabel="Filter by status"
- />
-
- {/* Invoice Table */}
-
+ {/* Invoice Table with integrated header filters */}
+
{
+ 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 ? (
+
+
+ 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
+
+ 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
+
+
+
+
+
+ Showing {(currentPage - 1) * itemsPerPage + 1} {" "}
+ to{" "}
+ {Math.min(currentPage * itemsPerPage, pagination?.totalItems || 0)} {" "}
+ of {pagination?.totalItems || 0} results
+
+
+
+
+ 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
+
+ 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
+
+
+
+
+
+ ) : undefined
+ }
+ >
(window.location.href = `/billing/invoices/${invoice.id}`)}
/>
-
+
{/* Pagination */}
- {pagination && filteredInvoices.length > 0 && (
-
-
- 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
-
- 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
-
-
-
-
-
- Showing {(currentPage - 1) * itemsPerPage + 1} {" "}
- to{" "}
-
- {Math.min(currentPage * itemsPerPage, pagination?.totalItems || 0)}
- {" "}
- of {pagination?.totalItems || 0} results
-
-
-
-
- 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
-
-
- 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
-
-
-
-
-
- )}
+ {/* Pagination moved to SubCard footer above */}
);
}
diff --git a/apps/portal/src/app/billing/payments/page.tsx b/apps/portal/src/app/billing/payments/page.tsx
index c63c8da2..e7d2c63d 100644
--- a/apps/portal/src/app/billing/payments/page.tsx
+++ b/apps/portal/src/app/billing/payments/page.tsx
@@ -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(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"
>
+
{/* Main Content */}
{/* Payment Methods Card */}
diff --git a/apps/portal/src/app/checkout/page.tsx b/apps/portal/src/app/checkout/page.tsx
index 1f627ea9..86a8cba1 100644
--- a/apps/portal/src/app/checkout/page.tsx
+++ b/apps/portal/src/app/checkout/page.tsx
@@ -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
(null);
+ const [toast, setToast] = useState<{ visible: boolean; text: string; tone: "info" | "success" | "warning" }>(
+ { visible: false, text: "", tone: "info" }
+ );
const [checkoutState, setCheckoutState] = useState({
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() {
>
{checkoutState.error}
-
router.back()} className="text-blue-600 hover:text-blue-800">
+ router.back()} className="text-blue-600 hover:text-blue-800">
Go Back
@@ -286,42 +302,131 @@ function CheckoutContent() {
return (
}
>
-
- {/* Address Confirmation */}
-
+
+
+ {/* Confirm Details - single card with Address + Payment */}
+
+
+
+
Confirm Details
+
+
+ {/* Sub-card: Installation Address */}
+
+
+
- {/* Order Submission Message */}
-
-
+ {/* Sub-card: Billing & Payment */}
+
}
+ right={
+ paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
+
+ ) : undefined
+ }
+ >
+
+ {paymentMethodsLoading ? (
+
+
+ Checking payment methods...
+
+ ) : paymentMethodsError ? (
+
+
+
+
+
Unable to verify payment methods
+
If you just added a payment method, try refreshing.
+
+ {
+ void paymentRefresh.triggerRefresh();
+ }}
+ className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
+ >
+ Check Again
+
+ 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
+
+
+
+
+
+ ) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
+
Payment will be processed using your card on file after approval.
+ ) : (
+
+
+
+
+
No payment method on file
+
Add a payment method to submit your order.
+
+ {
+ void paymentRefresh.triggerRefresh();
+ }}
+ className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
+ >
+ Check Again
+
+ 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
+
+
+
+
+
+ )}
+
+
+
+
+ {/* Review & Submit - prominent card with guidance */}
+
+
-
Submit Your Order for Review
-
- You've configured your service and reviewed all details. Your order will be
- submitted for review and approval.
+
Review & Submit
+
+ You’re almost done. Confirm your details above, then submit your order. We’ll review and notify you when everything is ready.
-
-
What happens next?
-
-
• Your order will be reviewed by our team
-
• We'll set up your services in our system
-
• Payment will be processed using your card on file
-
• You'll receive confirmation once everything is ready
+
+
What to expect
+
+
• Our team reviews your order and schedules setup if needed
+
• We may contact you to confirm details or availability
+
• We only charge your card after the order is approved
+
• You’ll receive confirmation and next steps by email
- {/* Quick Totals Summary */}
-
+ {/* Totals Summary */}
+
-
Total:
+
Estimated Total
¥{checkoutState.totals.monthlyTotal.toLocaleString()}/mo
@@ -336,130 +441,10 @@ function CheckoutContent() {
-
-
Billing Information
- {paymentMethodsLoading ? (
-
-
-
-
Checking payment methods...
-
-
- ) : paymentMethodsError ? (
-
-
-
-
-
- Unable to verify payment methods
-
-
- We couldn't check your payment methods. If you just added a payment method,
- try refreshing.
-
-
- {
- 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
-
- 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
-
-
-
-
-
- ) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
-
-
-
-
-
Payment method verified
-
- After order approval, payment will be automatically processed using your
- existing payment method on file. No additional payment steps required.
-
-
-
-
- ) : (
-
-
-
-
-
No payment method on file
-
- You need to add a payment method before submitting your order. Please add a
- credit card or other payment method to proceed.
-
-
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
-
-
-
-
- )}
-
-
- {/* Debug Info - Remove in production */}
-
- Debug Info: 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()}
-
{
// 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() {
void handleSubmitOrder()}
disabled={
submitting ||
@@ -515,13 +501,13 @@ function CheckoutContent() {
Submitting Order...
) : !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"
)}
diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css
index dc98be74..76df72bd 100644
--- a/apps/portal/src/app/globals.css
+++ b/apps/portal/src/app/globals.css
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
+@import "../styles/tokens.css";
@custom-variant dark (&:is(.dark *));
diff --git a/apps/portal/src/app/orders/[id]/page.tsx b/apps/portal/src/app/orders/[id]/page.tsx
index 48726d1a..926344e2 100644
--- a/apps/portal/src/app/orders/[id]/page.tsx
+++ b/apps/portal/src/app/orders/[id]/page.tsx
@@ -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 (
{/* Service Header */}
@@ -281,70 +292,31 @@ export default function OrderStatusPage() {
})()}
- {/* Status Card */}
-
-
-
- {data.status === "Activated" ? (
-
-
-
- ) : (
-
-
-
- )}
+ {/* Status Card (standardized) */}
+
}
+ >
+
{statusInfo.description}
+ {statusInfo.nextAction && (
+
+
Next Steps
+
{statusInfo.nextAction}
-
-
-
- {statusInfo.label}
-
-
{statusInfo.description}
-
- {statusInfo.nextAction && (
-
-
Next Steps:
-
{statusInfo.nextAction}
-
- )}
-
- {statusInfo.timeline && (
-
- Timeline: {statusInfo.timeline}
-
- )}
-
-
-
+ )}
+ {statusInfo.timeline && (
+
+ Timeline: {statusInfo.timeline}
+
+ )}
+
);
})()}
{/* Service Details */}
{data?.items && data.items.length > 0 && (
-
-
Your Services & Products
+
{data.items.map(item => {
// Use the actual Item_Class__c values from Salesforce documentation
@@ -447,7 +419,7 @@ export default function OrderStatusPage() {
);
})}
-
+
)}
{/* Pricing Summary */}
@@ -457,9 +429,7 @@ export default function OrderStatusPage() {
const totals = calculateDetailedTotals(data.items);
return (
-
-
Pricing Summary
-
+
{totals.monthlyTotal > 0 && (
@@ -494,15 +464,14 @@ export default function OrderStatusPage() {
-
+
);
})()}
{/* Support Contact */}
-
+
-
Need Help?
-
+
Questions about your order? Contact our support team.
@@ -521,7 +490,7 @@ export default function OrderStatusPage() {
-
+
);
}
diff --git a/apps/portal/src/app/orders/page.tsx b/apps/portal/src/app/orders/page.tsx
index 1f408f09..89bb8d5e 100644
--- a/apps/portal/src/app/orders/page.tsx
+++ b/apps/portal/src/app/orders/page.tsx
@@ -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() {
-
- {statusInfo.label}
-
+
diff --git a/apps/portal/src/app/subscriptions/page.tsx b/apps/portal/src/app/subscriptions/page.tsx
index ae373dd7..9c10b95c 100644
--- a/apps/portal/src/app/subscriptions/page.tsx
+++ b/apps/portal/src/app/subscriptions/page.tsx
@@ -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) => (
-
+
),
},
{
@@ -146,7 +165,7 @@ export default function SubscriptionsPage() {
key: "nextDue",
header: "Next Due",
render: (subscription: Subscription) => (
-
+
{subscription.nextDue ? formatDate(subscription.nextDue) : "N/A"}
@@ -218,97 +237,92 @@ export default function SubscriptionsPage() {
icon={ }
title="Subscriptions"
description="Manage your active services and subscriptions"
- >
-
+ actions={
Order Services
-
+ }
+ >
+ {/* Stats Cards */}
{/* Stats Cards */}
{stats && (
-
-
-
-
-
-
-
-
- Active
- {stats.active}
-
-
+
+
+
+
+
+
+
+ Active
+ {stats.active}
+
-
+
-
-
-
-
-
-
-
-
- Suspended
- {stats.suspended}
-
-
+
+
+
+
+
+
+
+ Suspended
+ {stats.suspended}
+
-
+
-
-
-
-
-
-
-
-
- Pending
- {stats.pending}
-
-
+
+
+
+
+
+
+
+ Pending
+ {stats.pending}
+
-
+
-
-
-
-
-
-
-
-
- Cancelled
- {stats.cancelled}
-
-
+
+
+
+
+
+
+
+ Cancelled
+ {stats.cancelled}
+
-
+
)}
- {/* Search and Filters */}
-
-
- {/* Subscriptions Table */}
-
+ {/* Subscriptions Table with integrated header + CTA */}
+
+ }
+ headerClassName="bg-gray-50 rounded md:p-2 p-1 mb-1"
+ >
(window.location.href = `/subscriptions/${subscription.id}`)}
/>
-
+
);
}
diff --git a/apps/portal/src/components/checkout/address-confirmation.tsx b/apps/portal/src/components/checkout/address-confirmation.tsx
index 6b692198..feff09e8 100644
--- a/apps/portal/src/components/checkout/address-confirmation.tsx
+++ b/apps/portal/src/components/checkout/address-confirmation.tsx
@@ -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
(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) => {
+ e?.preventDefault();
+ e?.stopPropagation();
+
setEditing(true);
setEditedAddress(
billingInfo?.address || {
@@ -90,7 +98,10 @@ export function AddressConfirmation({
);
};
- const handleSave = () => {
+ const handleSave = (e: React.MouseEvent) => {
+ 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 = {};
+ 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("/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) => {
+ // 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) => {
+ 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}>
+ ) : (
+ {node}
+ );
+
if (loading) {
- return (
-
-
-
-
Loading address information...
-
+ return wrap(
+
+
+
Loading address information...
);
}
if (error) {
- return (
-
+ return wrap(
+
Address Error
{error}
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 (
-
+ return wrap(
+ <>
-
- {isInternetOrder
- ? "Verify Installation Address"
- : billingInfo.isComplete
- ? "Confirm Service Address"
- : "Complete Your Address"}
-
-
- {billingInfo.isComplete && !editing && (
-
-
- Edit
-
- )}
-
-
- {/* Address should always be complete since it's required at signup */}
-
- {isInternetOrder && !addressConfirmed && (
-
-
-
-
-
- Internet Installation Address Verification Required
-
-
- Please verify this is the correct address for your internet installation. A
- technician will visit this location for setup.
-
-
+
+
+ {isInternetOrder
+ ? "Installation Address"
+ : billingInfo.isComplete
+ ? "Service Address"
+ : "Complete Your Address"}
+
- )}
+
+ {/* Consistent status pill placement (right side) */}
+
+
+
+
+ {/* Consolidated single card without separate banner */}
{editing ? (
@@ -316,6 +342,7 @@ export function AddressConfirmation({
@@ -323,6 +350,7 @@ export function AddressConfirmation({
Save Address
@@ -334,56 +362,78 @@ export function AddressConfirmation({
) : (
{billingInfo.address.street ? (
-
-
-
{billingInfo.address.street}
- {billingInfo.address.streetLine2 &&
{billingInfo.address.streetLine2}
}
-
- {billingInfo.address.city}, {billingInfo.address.state}{" "}
- {billingInfo.address.postalCode}
+
+
+
{billingInfo.address.street}
+ {billingInfo.address.streetLine2 && (
+
{billingInfo.address.streetLine2}
+ )}
+
+ {billingInfo.address.city}, {billingInfo.address.state} {billingInfo.address.postalCode}
-
{billingInfo.address.country}
+
{billingInfo.address.country}
- {/* Address Confirmation for Internet Orders */}
+ {/* Status message for Internet orders when pending */}
{isInternetOrder && !addressConfirmed && (
-
-
-
-
-
- Verification Required
-
+
+
+
+
+
Verification Required
+
+ Please confirm this is the correct installation address for your internet service.
+
-
- ✓ Confirm Installation Address
-
)}
- {/* Address Confirmed Status */}
- {addressConfirmed && (
-
-
-
-
- {isInternetOrder ? "Installation Address Confirmed" : "Address Confirmed"}
-
-
+ {/* Action buttons */}
+
+
+ {/* Primary action when pending for Internet orders */}
+ {isInternetOrder && !addressConfirmed && !editing && (
+ {
+ 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
+
+ )}
- )}
+
+ {/* Edit button - always on the right */}
+ {billingInfo.isComplete && !editing && (
+
+
+ Edit Address
+
+ )}
+
) : (
-
-
No address on file
+
+
+
+
No Address on File
+
Please add your installation address to continue.
Add Address
@@ -391,6 +441,6 @@ export function AddressConfirmation({
)}
)}
-
+ >
);
}
diff --git a/apps/portal/src/components/layout/dashboard-layout.tsx b/apps/portal/src/components/layout/dashboard-layout.tsx
index 08cc5ee6..abf03482 100644
--- a/apps/portal/src/components/layout/dashboard-layout.tsx
+++ b/apps/portal/src/components/layout/dashboard-layout.tsx
@@ -110,7 +110,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
}
return (
-
+
{/* Mobile sidebar overlay */}
{sidebarOpen && (
diff --git a/apps/portal/src/components/layout/page-layout.tsx b/apps/portal/src/components/layout/page-layout.tsx
index 11748834..8e3e6397 100644
--- a/apps/portal/src/components/layout/page-layout.tsx
+++ b/apps/portal/src/components/layout/page-layout.tsx
@@ -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 (
{/* Header */}
-
-
{icon}
-
-
{title}
-
{description}
+
+
+
{icon}
+
+
{title}
+
{description}
+
+ {actions ?
{actions}
: null}
diff --git a/apps/portal/src/components/ui/inline-toast.tsx b/apps/portal/src/components/ui/inline-toast.tsx
new file mode 100644
index 00000000..b89d31ee
--- /dev/null
+++ b/apps/portal/src/components/ui/inline-toast.tsx
@@ -0,0 +1,34 @@
+import type { HTMLAttributes } from "react";
+
+type Tone = "info" | "success" | "warning" | "error";
+
+interface InlineToastProps extends HTMLAttributes
{
+ 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 (
+
+ );
+}
+
diff --git a/apps/portal/src/components/ui/status-pill.tsx b/apps/portal/src/components/ui/status-pill.tsx
new file mode 100644
index 00000000..9464bb33
--- /dev/null
+++ b/apps/portal/src/components/ui/status-pill.tsx
@@ -0,0 +1,30 @@
+import type { HTMLAttributes } from "react";
+
+type Variant = "success" | "warning" | "info" | "neutral" | "error";
+
+interface StatusPillProps extends HTMLAttributes {
+ 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 (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal/src/components/ui/sub-card.tsx b/apps/portal/src/components/ui/sub-card.tsx
new file mode 100644
index 00000000..4a549907
--- /dev/null
+++ b/apps/portal/src/components/ui/sub-card.tsx
@@ -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 (
+
+ {header ? (
+
{header}
+ ) : title ? (
+
+
+ {icon}
+
{title}
+
+ {right}
+
+ ) : null}
+
{children}
+ {footer ? (
+
{footer}
+ ) : null}
+
+ );
+}
diff --git a/apps/portal/src/hooks/usePaymentRefresh.ts b/apps/portal/src/hooks/usePaymentRefresh.ts
new file mode 100644
index 00000000..b893f6b8
--- /dev/null
+++ b/apps/portal/src/hooks/usePaymentRefresh.ts
@@ -0,0 +1,67 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { authenticatedApi } from "@/lib/api";
+
+type Tone = "info" | "success" | "warning" | "error";
+
+interface UsePaymentRefreshOptions {
+ // 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({ refetch, hasMethods, attachFocusListeners = false }: UsePaymentRefreshOptions) {
+ 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;
+}
+
diff --git a/apps/portal/src/lib/auth/api.ts b/apps/portal/src/lib/auth/api.ts
index 649d5a4d..2ff1823a 100644
--- a/apps/portal/src/lib/auth/api.ts
+++ b/apps/portal/src/lib/auth/api.ts
@@ -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 {
+ return this.request("/auth/change-password", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify(data),
+ });
+ }
+
+ async checkPasswordNeeded(data: CheckPasswordNeededData): Promise {
+ return this.request("/auth/check-password-needed", {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ }
}
export const authAPI = new AuthAPI();
diff --git a/apps/portal/src/lib/auth/store.ts b/apps/portal/src/lib/auth/store.ts
index a42598e2..038dba0e 100644
--- a/apps/portal/src/lib/auth/store.ts
+++ b/apps/portal/src/lib/auth/store.ts
@@ -46,6 +46,8 @@ interface AuthState {
setPassword: (email: string, password: string) => Promise;
requestPasswordReset: (email: string) => Promise;
resetPassword: (token: string, password: string) => Promise;
+ changePassword: (currentPassword: string, newPassword: string) => Promise;
+ checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean; userExists: boolean; email?: string }>;
logout: () => Promise;
checkAuth: () => Promise;
}
@@ -164,6 +166,32 @@ export const useAuthStore = create()(
}
},
+ 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();
diff --git a/apps/portal/src/providers/query-provider.tsx b/apps/portal/src/providers/query-provider.tsx
index af9edbbe..3af75db0 100644
--- a/apps/portal/src/providers/query-provider.tsx
+++ b/apps/portal/src/providers/query-provider.tsx
@@ -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 (
{children}
-
+ {enableDevtools ? : null}
);
}
diff --git a/apps/portal/src/styles/tokens.css b/apps/portal/src/styles/tokens.css
new file mode 100644
index 00000000..261cf3f4
--- /dev/null
+++ b/apps/portal/src/styles/tokens.css
@@ -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 */
+}
+