diff --git a/.env.example b/.env.example index 1b6486f0..8ef3b909 100644 --- a/.env.example +++ b/.env.example @@ -44,12 +44,21 @@ CORS_ORIGIN="http://localhost:3000" # 🏢 EXTERNAL API CONFIGURATION (Development) # ============================================================================= # WHMCS Integration (use your actual credentials) +# Note: Do not include a trailing slash in BASE_URL. 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" +# WHMCS Dev Overrides (used when NODE_ENV !== "production") +# These override the above values in development/test. +# Note: Do not include a trailing slash in DEV_BASE_URL. +# WHMCS_DEV_BASE_URL="https://dev-wh.asolutions.co.jp" +# WHMCS_DEV_API_IDENTIFIER="your_dev_whmcs_api_identifier" +# WHMCS_DEV_API_SECRET="your_dev_whmcs_api_secret" +# WHMCS_DEV_API_ACCESS_KEY="your_dev_whmcs_api_access_key" + # Salesforce Integration (use your actual credentials) SF_LOGIN_URL="https://asolutions.my.salesforce.com" SF_CLIENT_ID="your_salesforce_client_id" @@ -91,4 +100,3 @@ NODE_OPTIONS="--no-deprecation" # 1. Run: pnpm dev:start # 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 20c8009b..15c5bc22 100644 --- a/apps/bff/src/auth/auth.controller.ts +++ b/apps/bff/src/auth/auth.controller.ts @@ -5,7 +5,6 @@ import { AuthService } from "./auth.service"; import { LocalAuthGuard } from "./guards/local-auth.guard"; import { AuthThrottleGuard } from "./guards/auth-throttle.guard"; 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"; @@ -14,6 +13,7 @@ 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"; +import { SignupDto } from "./dto/signup.dto"; @ApiTags("auth") @Controller("auth") @@ -41,6 +41,17 @@ export class AuthController { return this.authService.healthCheck(); } + @Public() + @Post("signup-preflight") + @UseGuards(AuthThrottleGuard) + @Throttle({ default: { limit: 10, ttl: 900000 } }) + @HttpCode(200) + @ApiOperation({ summary: "Validate full signup data without creating anything" }) + @ApiResponse({ status: 200, description: "Preflight results with next action guidance" }) + async signupPreflight(@Body() body: SignupDto) { + return this.authService.signupPreflight(body); + } + @Public() @Post("account-status") @ApiOperation({ summary: "Get account status by email" }) diff --git a/apps/bff/src/auth/auth.service.ts b/apps/bff/src/auth/auth.service.ts index afa33745..429cde5b 100644 --- a/apps/bff/src/auth/auth.service.ts +++ b/apps/bff/src/auth/auth.service.ts @@ -26,6 +26,7 @@ import { User as SharedUser } from "@customer-portal/shared"; import type { User as PrismaUser } from "@prisma/client"; import type { Request } from "express"; import { WhmcsClientResponse } from "../vendors/whmcs/types/whmcs-api.types"; +import { PrismaService } from "../common/prisma/prisma.service"; @Injectable() export class AuthService { @@ -42,6 +43,7 @@ export class AuthService { private auditService: AuditService, private tokenBlacklistService: TokenBlacklistService, private emailService: EmailService, + private prisma: PrismaService, @Inject(Logger) private readonly logger: Logger ) {} @@ -200,16 +202,13 @@ export class AuthService { // Enhanced input validation this.validateSignupData(signupData); - // Check if a portal user already exists + // Check if a portal user already exists (do not create anything yet) 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, existingUser.id, @@ -221,7 +220,7 @@ export class AuthService { throw new ConflictException(message); } - // Hash password with environment-based configuration + // Hash password with environment-based configuration (computed ahead, used after WHMCS success) const saltRoundsConfig = this.configService.get("BCRYPT_ROUNDS", 12); const saltRounds = typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig; @@ -237,24 +236,10 @@ export class AuthService { ); } - // 1. Create user in portal - const user: SharedUser = await this.usersService.create({ - email, - passwordHash, - firstName, - lastName, - company, - phone, - emailVerified: false, - failedLoginAttempts: 0, - lockedUntil: null, - lastLoginAt: null, - }); - - // 2. Create client in WHMCS + // 1. Create client in WHMCS first (no portal user is created yet) let whmcsClient: { clientId: number }; try { - // 2.0 Pre-check for existing WHMCS client by email to avoid duplicates and guide UX + // 1.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) { @@ -348,51 +333,58 @@ export class AuthService { firstName, lastName, }); - - // Rollback: Delete the portal user since WHMCS creation failed - try { - // Note: We should add a delete method to UsersService, but for now use direct approach - this.logger.warn("WHMCS creation failed, user account created but not fully integrated", { - userId: user.id, - email, - whmcsError: getErrorMessage(whmcsError), - }); - } catch (rollbackError) { - this.logger.error("Failed to log rollback information", { - userId: user.id, - email, - rollbackError: getErrorMessage(rollbackError), - }); - } - throw new BadRequestException( `Failed to create billing account: ${getErrorMessage(whmcsError)}` ); } - // 3. Store ID mappings - await this.mappingsService.createMapping({ - userId: user.id, - whmcsClientId: whmcsClient.clientId, - sfAccountId: sfAccount.id, + // 2. Only now create the portal user and mapping atomically + const { createdUserId } = await this.prisma.$transaction(async tx => { + const created = await tx.user.create({ + data: { + email, + passwordHash, + firstName, + lastName, + company: company || null, + phone: phone || null, + emailVerified: false, + failedLoginAttempts: 0, + lockedUntil: null, + lastLoginAt: null, + }, + select: { id: true, email: true }, + }); + + await tx.idMapping.create({ + data: { + userId: created.id, + whmcsClientId: whmcsClient.clientId, + sfAccountId: sfAccount.id, + }, + }); + + return { createdUserId: created.id }; }); - // 4. Do not update Salesforce Account fields from the portal. Salesforce stays authoritative. + // Fetch sanitized user response + const freshUser = await this.usersService.findByIdInternal(createdUserId); // Log successful signup await this.auditService.logAuthEvent( AuditAction.SIGNUP, - user.id, + createdUserId, { email, whmcsClientId: whmcsClient.clientId }, request, true ); - // Generate JWT token - const tokens = this.generateTokens(user); + const tokens = this.generateTokens({ id: createdUserId, email }); return { - user: this.sanitizeUser(user), + user: this.sanitizeUser( + freshUser ?? ({ id: createdUserId, email } as unknown as PrismaUser) + ), ...tokens, }; } catch (error) { @@ -777,7 +769,7 @@ export class AuthService { // Create SSO token using custom redirect for better compatibility const ssoDestination = "sso:custom_redirect"; - const ssoRedirectPath = destination || "clientarea.php"; + const ssoRedirectPath = this.sanitizeWhmcsRedirectPath(destination); const result = await this.whmcsService.createSsoToken( mapping.whmcsClientId, @@ -798,6 +790,30 @@ export class AuthService { } } + /** + * Ensure only safe, relative WHMCS paths are allowed for SSO redirects. + * Falls back to 'clientarea.php' when input is missing or unsafe. + */ + private sanitizeWhmcsRedirectPath(path?: string): string { + // Default + const fallback = "clientarea.php"; + if (!path || typeof path !== "string") return fallback; + const p = path.trim(); + // Disallow absolute URLs and protocol-like prefixes + if (/^https?:\/\//i.test(p) || /^\/\//.test(p) || /^sso:/i.test(p)) { + return fallback; + } + // Allow only known entry points; expand as needed + const allowedStarts = [ + "clientarea.php", + "index.php?rp=/account/paymentmethods", + "viewinvoice.php", + "dl.php", + ]; + const isAllowed = allowedStarts.some(prefix => p.toLowerCase().startsWith(prefix.toLowerCase())); + return isAllowed ? p : fallback; + } + async requestPasswordReset(email: string): Promise { const user = await this.usersService.findByEmailInternal(email); // Always act as if successful to avoid account enumeration @@ -985,6 +1001,121 @@ export class AuthService { }; } + /** + * Preflight validation for signup. No side effects. + * Returns a clear nextAction for the UI and detailed flags. + */ + async signupPreflight(signupData: SignupDto) { + const { email, sfNumber } = signupData; + + const normalizedEmail = email.toLowerCase().trim(); + + const result: { + ok: boolean; + canProceed: boolean; + nextAction: + | "proceed_signup" + | "link_whmcs" + | "login" + | "fix_input" + | "blocked"; + messages: string[]; + normalized: { email: string }; + portal: { userExists: boolean; needsPasswordSet?: boolean }; + salesforce: { accountId?: string; alreadyMapped: boolean }; + whmcs: { clientExists: boolean; clientId?: number }; + } = { + ok: true, + canProceed: false, + nextAction: "blocked", + messages: [], + normalized: { email: normalizedEmail }, + portal: { userExists: false }, + salesforce: { alreadyMapped: false }, + whmcs: { clientExists: false }, + }; + + // 0) Portal user existence + const portalUser = await this.usersService.findByEmailInternal(normalizedEmail); + if (portalUser) { + result.portal.userExists = true; + const mapped = await this.mappingsService.hasMapping(portalUser.id); + if (mapped) { + result.nextAction = "login"; + result.messages.push("An account already exists. Please sign in."); + return result; + } + + // Legacy unmapped user + result.portal.needsPasswordSet = !portalUser.passwordHash; + result.nextAction = portalUser.passwordHash ? "login" : "fix_input"; + result.messages.push( + portalUser.passwordHash + ? "An account exists without billing link. Please sign in to continue setup." + : "An account exists and needs password setup. Please set a password to continue." + ); + return result; + } + + // 1) Salesforce checks + const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber); + if (!sfAccount) { + result.nextAction = "fix_input"; + result.messages.push("Customer number not found in Salesforce"); + return result; + } + result.salesforce.accountId = sfAccount.id; + + const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id); + if (existingMapping) { + result.salesforce.alreadyMapped = true; + result.nextAction = "login"; + result.messages.push("This customer number is already registered. Please sign in."); + return result; + } + + // 2) WHMCS checks by email + try { + const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail); + if (client) { + result.whmcs.clientExists = true; + result.whmcs.clientId = client.id; + // If this WHMCS client is already linked to a portal user, direct to login + try { + const mapped = await this.mappingsService.findByWhmcsClientId(client.id); + if (mapped) { + result.nextAction = "login"; + result.messages.push("This billing account is already linked. Please sign in."); + return result; + } + } catch { + // ignore; treat as unmapped + } + + // Client exists but not mapped → suggest linking instead of creating new + result.nextAction = "link_whmcs"; + result.messages.push( + "We found an existing billing account for this email. Please link your account." + ); + return result; + } + } catch (err) { + // NotFoundException is expected when client doesn't exist. Other errors are reported. + if (!(err instanceof NotFoundException)) { + this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) }); + result.messages.push("Unable to verify billing system. Please try again later."); + result.nextAction = "blocked"; + return result; + } + } + + // If we reach here: no portal user, SF valid and not mapped, no WHMCS client → OK to proceed + result.canProceed = true; + result.nextAction = "proceed_signup"; + result.messages.push("All checks passed. Ready to create your account."); + return result; + } + private validateSignupData(signupData: SignupDto) { const { email, password, firstName, lastName } = signupData; diff --git a/apps/bff/src/auth/dto/signup.dto.ts b/apps/bff/src/auth/dto/signup.dto.ts index 87769bac..0aab0d9a 100644 --- a/apps/bff/src/auth/dto/signup.dto.ts +++ b/apps/bff/src/auth/dto/signup.dto.ts @@ -74,10 +74,12 @@ export class SignupDto { @IsString() company?: string; - @ApiProperty({ example: "+1-555-123-4567", required: false }) - @IsOptional() + @ApiProperty({ example: "+81-90-1234-5678", description: "Contact phone number" }) @IsString() - phone?: string; + @Matches(/^[+]?[0-9\s\-()]{7,20}$/, { + message: "Phone number must contain 7-20 digits and may include +, spaces, dashes, and parentheses", + }) + phone: string; @ApiProperty({ example: "CN-0012345", description: "Customer Number (SF Number)" }) @IsString() diff --git a/apps/bff/src/common/config/env.validation.ts b/apps/bff/src/common/config/env.validation.ts index 69801dd9..b851cf5a 100644 --- a/apps/bff/src/common/config/env.validation.ts +++ b/apps/bff/src/common/config/env.validation.ts @@ -35,6 +35,14 @@ export const envSchema = z.object({ WHMCS_API_SECRET: z.string().optional(), WHMCS_API_ACCESS_KEY: z.string().optional(), WHMCS_WEBHOOK_SECRET: z.string().optional(), + // WHMCS Dev-only overrides (used when NODE_ENV !== 'production') + WHMCS_DEV_BASE_URL: z.string().url().optional(), + WHMCS_DEV_API_IDENTIFIER: z.string().optional(), + WHMCS_DEV_API_SECRET: z.string().optional(), + WHMCS_DEV_API_ACCESS_KEY: z.string().optional(), + WHMCS_DEV_WEBHOOK_SECRET: z.string().optional(), + WHMCS_DEV_ADMIN_USERNAME: z.string().optional(), + WHMCS_DEV_ADMIN_PASSWORD_MD5: z.string().optional(), // Optional elevated admin credentials for privileged WHMCS actions (eg. AcceptOrder) WHMCS_ADMIN_USERNAME: z.string().optional(), // Expect MD5 hash of the admin password (preferred). Alias supported for compatibility. diff --git a/apps/bff/src/main.ts b/apps/bff/src/main.ts index 16472f03..44cdb40a 100644 --- a/apps/bff/src/main.ts +++ b/apps/bff/src/main.ts @@ -89,13 +89,14 @@ async function bootstrap() { }); // Global validation pipe with enhanced security + const exposeValidation = configService.get("EXPOSE_VALIDATION_ERRORS", "false") === "true"; app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true, forbidUnknownValues: true, - disableErrorMessages: configService.get("NODE_ENV") === "production", + disableErrorMessages: !exposeValidation && configService.get("NODE_ENV") === "production", validationError: { target: false, value: false, 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 4d33f8bb..bbb21efb 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts @@ -46,24 +46,83 @@ export class WhmcsConnectionService { @Inject(Logger) private readonly logger: Logger, private readonly configService: ConfigService ) { + // Helper: read the first defined value across a list of keys + const getFirst = (keys: Array): string | undefined => { + for (const key of keys) { + if (!key) continue; + const v = this.configService.get(key); + if (v && `${v}`.length > 0) return v; + const raw = process.env[key]; + if (raw && `${raw}`.length > 0) return raw; + } + return undefined; + }; + + const nodeEnv = this.configService.get("NODE_ENV", "development"); + const isDev = nodeEnv !== "production"; // treat anything non-prod as dev/test + + // Prefer explicit DEV variables when running in non-production + // Resolve and normalize base URL (trim trailing slashes) + const rawBaseUrl = getFirst([ + isDev ? "WHMCS_DEV_BASE_URL" : undefined, + "WHMCS_BASE_URL", + ]) || ""; + const baseUrl = rawBaseUrl.replace(/\/+$/, ""); + + const identifier = getFirst([ + isDev ? "WHMCS_DEV_API_IDENTIFIER" : undefined, + "WHMCS_API_IDENTIFIER", + ]) || ""; + + const secret = getFirst([ + isDev ? "WHMCS_DEV_API_SECRET" : undefined, + "WHMCS_API_SECRET", + ]) || ""; + + const adminUsername = getFirst([ + isDev ? "WHMCS_DEV_ADMIN_USERNAME" : undefined, + "WHMCS_ADMIN_USERNAME", + ]); + + const adminPasswordHash = getFirst([ + isDev ? "WHMCS_DEV_ADMIN_PASSWORD_MD5" : undefined, + "WHMCS_ADMIN_PASSWORD_MD5", + "WHMCS_ADMIN_PASSWORD_HASH", + ]); + + const accessKey = getFirst([ + isDev ? "WHMCS_DEV_API_ACCESS_KEY" : undefined, + "WHMCS_API_ACCESS_KEY", + ]); + this.config = { - baseUrl: this.configService.get("WHMCS_BASE_URL", ""), - identifier: this.configService.get("WHMCS_API_IDENTIFIER", ""), - secret: this.configService.get("WHMCS_API_SECRET", ""), + baseUrl, + identifier, + secret, timeout: this.configService.get("WHMCS_API_TIMEOUT", 30000), retryAttempts: this.configService.get("WHMCS_API_RETRY_ATTEMPTS", 1), retryDelay: this.configService.get("WHMCS_API_RETRY_DELAY", 1000), - adminUsername: this.configService.get("WHMCS_ADMIN_USERNAME"), - adminPasswordHash: - this.configService.get("WHMCS_ADMIN_PASSWORD_MD5") || - this.configService.get("WHMCS_ADMIN_PASSWORD_HASH"), + adminUsername, + adminPasswordHash, }; // Optional API Access Key (used by some WHMCS deployments alongside API Credentials) - this.accessKey = this.configService.get("WHMCS_API_ACCESS_KEY"); + this.accessKey = accessKey; + + if (isDev) { + const usingDev = [baseUrl, identifier, secret].every(v => !!v) && process.env.WHMCS_DEV_BASE_URL; + if (usingDev) { + this.logger.debug("Using WHMCS DEV environment variables (development mode)"); + } + } this.validateConfig(); } + /** Expose the resolved base URL for helper services (SSO URL resolution) */ + getBaseUrl(): string { + return this.config.baseUrl; + } + private validateConfig(): void { const requiredFields = ["baseUrl", "identifier", "secret"]; const missingFields = requiredFields.filter( diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts index d3d0ecbc..42597cb0 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts @@ -189,9 +189,11 @@ export class WhmcsPaymentService { const response = await this.connectionService.createSsoToken(params); + const url = this.resolveRedirectUrl(response.redirect_url); + this.debugLogRedirectHost(url); const result = { - url: response.redirect_url, - expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour + url, + expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds (per WHMCS spec) }; this.logger.log(`Created payment SSO token for client ${clientId}, invoice ${invoiceId}`, { @@ -247,4 +249,36 @@ export class WhmcsPaymentService { transformProduct(whmcsProduct: Record): unknown { return this.dataTransformer.transformProduct(whmcsProduct); } + + /** + * Normalize WHMCS SSO redirect URLs to absolute using configured base URL. + */ + private resolveRedirectUrl(redirectUrl: string): string { + if (!redirectUrl) return redirectUrl; + const base = this.connectionService.getBaseUrl().replace(/\/+$/, ""); + const isAbsolute = /^https?:\/\//i.test(redirectUrl); + if (!isAbsolute) { + const path = redirectUrl.replace(/^\/+/, ""); + return `${base}/${path}`; + } + return redirectUrl; + } + + /** + * Debug helper: log only the host of the SSO URL (never the token) in non-production. + */ + private debugLogRedirectHost(url: string): void { + if (process.env.NODE_ENV === "production") return; + try { + const target = new URL(url); + const base = new URL(this.connectionService.getBaseUrl()); + this.logger.debug("WHMCS Payment SSO redirect host", { + redirectHost: target.host, + redirectOrigin: target.origin, + baseOrigin: base.origin, + }); + } catch { + // Ignore parse errors silently + } + } } diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-sso.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-sso.service.ts index 69890871..2c4bd3c0 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-sso.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-sso.service.ts @@ -28,9 +28,11 @@ export class WhmcsSsoService { const response = await this.connectionService.createSsoToken(params); + const url = this.resolveRedirectUrl(response.redirect_url); + this.debugLogRedirectHost(url); const result = { - url: response.redirect_url, - expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour + url, + expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds (per WHMCS spec) }; this.logger.log(`Created SSO token for client ${clientId}`, { @@ -82,8 +84,10 @@ export class WhmcsSsoService { const response = await this.connectionService.createSsoToken(params); - // Return the 60s, one-time URL - return response.redirect_url; + // Return the 60s, one-time URL (resolved to absolute) + const url = this.resolveRedirectUrl(response.redirect_url); + this.debugLogRedirectHost(url); + return url; } /** @@ -101,9 +105,11 @@ export class WhmcsSsoService { const response = await this.connectionService.createSsoToken(params); + const url = this.resolveRedirectUrl(response.redirect_url); + this.debugLogRedirectHost(url); const result = { - url: response.redirect_url, - expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour + url, + expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds }; this.logger.log(`Created admin SSO token for client ${clientId}`, { @@ -154,9 +160,11 @@ export class WhmcsSsoService { const response = await this.connectionService.createSsoToken(ssoParams); + const url = this.resolveRedirectUrl(response.redirect_url); + this.debugLogRedirectHost(url); const result = { - url: response.redirect_url, - expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour + url, + expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds }; this.logger.log(`Created module SSO token for client ${clientId}`, { @@ -175,4 +183,38 @@ export class WhmcsSsoService { throw error; } } + + /** + * Ensure the returned redirect URL is absolute and points to the active WHMCS base URL. + * WHMCS typically returns an absolute URL, but we normalize in case it's relative. + */ + private resolveRedirectUrl(redirectUrl: string): string { + if (!redirectUrl) return redirectUrl; + const base = this.connectionService.getBaseUrl().replace(/\/+$/, ""); + const isAbsolute = /^https?:\/\//i.test(redirectUrl); + if (!isAbsolute) { + const path = redirectUrl.replace(/^\/+/, ""); + return `${base}/${path}`; + } + // Absolute URL returned by WHMCS — return as-is + return redirectUrl; + } + + /** + * Debug helper: log only the host of the SSO URL (never the token) in non-production. + */ + private debugLogRedirectHost(url: string): void { + if (process.env.NODE_ENV === "production") return; + try { + const target = new URL(url); + const base = new URL(this.connectionService.getBaseUrl()); + this.logger.debug("WHMCS SSO redirect host", { + redirectHost: target.host, + redirectOrigin: target.origin, + baseOrigin: base.origin, + }); + } catch { + // Ignore parse errors silently + } + } } diff --git a/env/portal-backend.env.sample b/env/portal-backend.env.sample index e1380faf..28b063e7 100644 --- a/env/portal-backend.env.sample +++ b/env/portal-backend.env.sample @@ -29,6 +29,9 @@ RATE_LIMIT_LIMIT=100 AUTH_RATE_LIMIT_TTL=900000 AUTH_RATE_LIMIT_LIMIT=3 +# Validation error visibility (set true to show field-level errors to clients) +EXPOSE_VALIDATION_ERRORS=false + # WHMCS Credentials WHMCS_BASE_URL=https://accounts.asolutions.co.jp WHMCS_API_IDENTIFIER= @@ -88,3 +91,5 @@ NODE_OPTIONS=--max-old-space-size=512 # NOTE: Frontend (Next.js) uses a separate env file (portal-frontend.env) # Do not include NEXT_PUBLIC_* variables here. + +