From cc2a6a3046ba3ff3312a249dd11961a636cbfcf5 Mon Sep 17 00:00:00 2001 From: "T. Narantuya" Date: Tue, 2 Sep 2025 13:52:13 +0900 Subject: [PATCH] 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. --- .env.example | 3 +- apps/bff/src/auth/auth.controller.ts | 26 ++ apps/bff/src/auth/auth.service.ts | 182 ++++++++++- apps/bff/src/auth/dto/account-status.dto.ts | 21 ++ apps/bff/src/auth/dto/change-password.dto.ts | 16 + apps/bff/src/auth/dto/reset-password.dto.ts | 2 +- apps/bff/src/auth/dto/set-password.dto.ts | 2 +- apps/bff/src/auth/dto/signup.dto.ts | 2 +- apps/bff/src/common/config/env.validation.ts | 1 + apps/bff/src/orders/dto/order.dto.ts | 33 ++ apps/bff/src/users/dto/update-address.dto.ts | 41 +++ apps/bff/src/users/users.controller.ts | 20 +- apps/bff/src/users/users.service.ts | 56 +++- .../whmcs/cache/whmcs-cache.service.ts | 16 + .../whmcs/services/whmcs-client.service.ts | 4 +- .../services/whmcs-connection.service.ts | 41 ++- apps/portal/next.config.mjs | 2 + apps/portal/src/app/account/billing/page.tsx | 12 +- apps/portal/src/app/account/profile/page.tsx | 119 +++++++- apps/portal/src/app/auth/login/page.tsx | 6 + .../src/app/auth/reset-password/page.tsx | 2 +- .../portal/src/app/auth/set-password/page.tsx | 2 +- apps/portal/src/app/auth/signup/page.tsx | 102 ++++++- .../src/app/billing/invoices/[id]/page.tsx | 73 ++++- apps/portal/src/app/billing/invoices/page.tsx | 164 +++++----- apps/portal/src/app/billing/payments/page.tsx | 21 +- apps/portal/src/app/checkout/page.tsx | 288 +++++++++--------- apps/portal/src/app/globals.css | 1 + apps/portal/src/app/orders/[id]/page.tsx | 101 +++--- apps/portal/src/app/orders/page.tsx | 16 +- apps/portal/src/app/subscriptions/page.tsx | 160 +++++----- .../checkout/address-confirmation.tsx | 252 +++++++++------ .../components/layout/dashboard-layout.tsx | 2 +- .../src/components/layout/page-layout.tsx | 16 +- .../portal/src/components/ui/inline-toast.tsx | 34 +++ apps/portal/src/components/ui/status-pill.tsx | 30 ++ apps/portal/src/components/ui/sub-card.tsx | 47 +++ apps/portal/src/hooks/usePaymentRefresh.ts | 67 ++++ apps/portal/src/lib/auth/api.ts | 35 +++ apps/portal/src/lib/auth/store.ts | 28 ++ apps/portal/src/providers/query-provider.tsx | 4 +- apps/portal/src/styles/tokens.css | 12 + 42 files changed, 1493 insertions(+), 569 deletions(-) create mode 100644 apps/bff/src/auth/dto/account-status.dto.ts create mode 100644 apps/bff/src/auth/dto/change-password.dto.ts create mode 100644 apps/bff/src/users/dto/update-address.dto.ts create mode 100644 apps/portal/src/components/ui/inline-toast.tsx create mode 100644 apps/portal/src/components/ui/status-pill.tsx create mode 100644 apps/portal/src/components/ui/sub-card.tsx create mode 100644 apps/portal/src/hooks/usePaymentRefresh.ts create mode 100644 apps/portal/src/styles/tokens.css 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} +
+ )} +
+
+ + 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="••••••••" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ +
+

+ 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() {

{ + 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") && ( + +
+ + +
+
+ )}
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 ? ( +
+
+ + +
+
+
+

+ Showing {(currentPage - 1) * itemsPerPage + 1}{" "} + to{" "} + {Math.min(currentPage * itemsPerPage, pagination?.totalItems || 0)}{" "} + of {pagination?.totalItems || 0} results +

+
+
+ +
+
+
+ ) : undefined + } + > (window.location.href = `/billing/invoices/${invoice.id}`)} /> -
+ {/* Pagination */} - {pagination && filteredInvoices.length > 0 && ( -
-
- - -
-
-
-

- Showing {(currentPage - 1) * itemsPerPage + 1}{" "} - to{" "} - - {Math.min(currentPage * itemsPerPage, pagination?.totalItems || 0)} - {" "} - of {pagination?.totalItems || 0} results -

-
-
- -
-
-
- )} + {/* 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}

-
@@ -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.

+
+ + +
+
+
+
+ ) : 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.

+
+ + +
+
+
+
+ )} +
+
+
+ + {/* 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. -

-
- - -
-
-
-
- ) : 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. -

- -
-
-
- )} -
- - {/* 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()} -
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}

- )} -
- - {/* 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({
)} - {/* Address Confirmed Status */} - {addressConfirmed && ( -
-
- - - {isInternetOrder ? "Installation Address Confirmed" : "Address Confirmed"} - -
+ {/* Action buttons */} +
+
+ {/* Primary action when pending for Internet orders */} + {isInternetOrder && !addressConfirmed && !editing && ( + + )}
- )} + + {/* Edit button - always on the right */} + {billingInfo.isComplete && !editing && ( + + )} +
) : (
- -

No address on file

+
+ +
+

No Address on File

+

Please add your installation address to continue.

@@ -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 ( +
+
+ {text} +
+
+ ); +} + 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 */ +} +