Refactor Salesforce request handling and improve logging
- Moved metrics tracking and logging from the queueing phase to the execution phase in SalesforceRequestQueueService for better accuracy. - Updated CSRF token generation in CsrfController to accept parameters in a more flexible manner. - Enhanced CacheService to handle immediate expiry requests without leaking stale values. - Improved error handling and re-authentication logic in SalesforceConnection for better resilience during session expiration. - Refactored logout functionality in AuthFacade to handle optional userId and improve logging during token revocation. - Updated AuthController to apply rate limit headers and improved type handling in various request contexts. - Streamlined imports and improved overall code organization across multiple modules for better maintainability.
This commit is contained in:
parent
5d011c87be
commit
d6f7c50e7b
@ -207,22 +207,22 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
|||||||
const isLongRunning = options.isLongRunning || false;
|
const isLongRunning = options.isLongRunning || false;
|
||||||
const queue = isLongRunning ? longRunningQueue : standardQueue;
|
const queue = isLongRunning ? longRunningQueue : standardQueue;
|
||||||
|
|
||||||
this.metrics.totalRequests++;
|
|
||||||
this.metrics.dailyApiUsage++;
|
|
||||||
this.updateQueueMetrics();
|
|
||||||
|
|
||||||
this.logger.debug("Queueing Salesforce request", {
|
|
||||||
requestId,
|
|
||||||
isLongRunning,
|
|
||||||
queueSize: queue.size,
|
|
||||||
pending: queue.pending,
|
|
||||||
priority: options.priority || 0,
|
|
||||||
dailyUsage: this.metrics.dailyApiUsage,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = (await queue.add(
|
const result = (await queue.add(
|
||||||
async () => {
|
async () => {
|
||||||
|
this.metrics.totalRequests++;
|
||||||
|
this.metrics.dailyApiUsage++;
|
||||||
|
this.updateQueueMetrics();
|
||||||
|
|
||||||
|
this.logger.debug("Executing Salesforce request", {
|
||||||
|
requestId,
|
||||||
|
isLongRunning,
|
||||||
|
queueSize: queue.size,
|
||||||
|
pending: queue.pending,
|
||||||
|
priority: options.priority || 0,
|
||||||
|
dailyUsage: this.metrics.dailyApiUsage,
|
||||||
|
});
|
||||||
|
|
||||||
const waitTime = Date.now() - startTime;
|
const waitTime = Date.now() - startTime;
|
||||||
this.recordWaitTime(waitTime);
|
this.recordWaitTime(waitTime);
|
||||||
|
|
||||||
@ -277,10 +277,6 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
|||||||
)) as T;
|
)) as T;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
|
||||||
this.metrics.failedRequests++;
|
|
||||||
this.metrics.lastErrorTime = new Date();
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
this.updateQueueMetrics();
|
this.updateQueueMetrics();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { CsrfController } from "./csrf.controller";
|
||||||
|
import type { CsrfService, CsrfTokenData } from "../services/csrf.service";
|
||||||
|
|
||||||
|
const createMockResponse = () => {
|
||||||
|
const cookie = jest.fn();
|
||||||
|
const json = jest.fn();
|
||||||
|
return {
|
||||||
|
cookie,
|
||||||
|
json,
|
||||||
|
} as unknown as Response;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CsrfController", () => {
|
||||||
|
const csrfToken: CsrfTokenData = {
|
||||||
|
token: "token-value",
|
||||||
|
secret: "secret-value",
|
||||||
|
expiresAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeController = (overrides: Partial<CsrfService> = {}) => {
|
||||||
|
const csrfService: Partial<CsrfService> = {
|
||||||
|
generateToken: jest.fn().mockReturnValue(csrfToken),
|
||||||
|
invalidateUserTokens: jest.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = { debug: jest.fn() } as unknown as Logger;
|
||||||
|
return {
|
||||||
|
controller: new CsrfController(csrfService as CsrfService, logger),
|
||||||
|
csrfService,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it("passes session and user identifiers to generateToken in the correct argument order", () => {
|
||||||
|
const { controller, csrfService } = makeController();
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
user: { id: "user-123", sessionId: "session-456" },
|
||||||
|
cookies: {},
|
||||||
|
get: jest.fn(),
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
controller.getCsrfToken(req, res);
|
||||||
|
|
||||||
|
expect(csrfService.generateToken).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
|
"session-456",
|
||||||
|
"user-123"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to anonymous user during refresh and still forwards identifiers correctly", () => {
|
||||||
|
const { controller, csrfService } = makeController();
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
cookies: { "connect.sid": "cookie-session" },
|
||||||
|
get: jest.fn(),
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
controller.refreshCsrfToken(req, res);
|
||||||
|
|
||||||
|
expect(csrfService.generateToken).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
|
"cookie-session",
|
||||||
|
"anonymous"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -23,7 +23,7 @@ export class CsrfController {
|
|||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
|
|
||||||
// Generate new CSRF token
|
// Generate new CSRF token
|
||||||
const tokenData = this.csrfService.generateToken(sessionId, userId);
|
const tokenData = this.csrfService.generateToken(undefined, sessionId, userId);
|
||||||
|
|
||||||
// Set CSRF secret in secure cookie
|
// Set CSRF secret in secure cookie
|
||||||
res.cookie("csrf-secret", tokenData.secret, {
|
res.cookie("csrf-secret", tokenData.secret, {
|
||||||
@ -58,7 +58,7 @@ export class CsrfController {
|
|||||||
this.csrfService.invalidateUserTokens(userId);
|
this.csrfService.invalidateUserTokens(userId);
|
||||||
|
|
||||||
// Generate new CSRF token
|
// Generate new CSRF token
|
||||||
const tokenData = this.csrfService.generateToken(sessionId, userId);
|
const tokenData = this.csrfService.generateToken(undefined, sessionId, userId);
|
||||||
|
|
||||||
// Set CSRF secret in secure cookie
|
// Set CSRF secret in secure cookie
|
||||||
res.cookie("csrf-secret", tokenData.secret, {
|
res.cookie("csrf-secret", tokenData.secret, {
|
||||||
|
|||||||
15
apps/bff/src/infra/cache/cache.service.ts
vendored
15
apps/bff/src/infra/cache/cache.service.ts
vendored
@ -26,11 +26,18 @@ export class CacheService {
|
|||||||
|
|
||||||
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
||||||
const serialized = JSON.stringify(value);
|
const serialized = JSON.stringify(value);
|
||||||
if (ttlSeconds) {
|
if (ttlSeconds !== undefined) {
|
||||||
await this.redis.setex(key, ttlSeconds, serialized);
|
const ttl = Math.max(0, Math.floor(ttlSeconds));
|
||||||
} else {
|
if (ttl > 0) {
|
||||||
await this.redis.set(key, serialized);
|
await this.redis.set(key, serialized, "EX", ttl);
|
||||||
|
} else {
|
||||||
|
// Allow callers to request immediate expiry without leaking stale values
|
||||||
|
await this.redis.set(key, serialized, "PX", 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.redis.set(key, serialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
async del(key: string): Promise<void> {
|
async del(key: string): Promise<void> {
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
import type { Logger } from "nestjs-pino";
|
||||||
|
import type { ConfigService } from "@nestjs/config";
|
||||||
|
import { SalesforceConnection } from "./salesforce-connection.service";
|
||||||
|
import type { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service";
|
||||||
|
|
||||||
|
describe("SalesforceConnection", () => {
|
||||||
|
const createService = () => {
|
||||||
|
const configService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
|
||||||
|
const requestQueue: Partial<SalesforceRequestQueueService> = {
|
||||||
|
execute: jest.fn().mockImplementation(async (fn) => fn()),
|
||||||
|
executeHighPriority: jest.fn().mockImplementation(async (fn) => fn()),
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
debug: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
log: jest.fn(),
|
||||||
|
} as unknown as Logger;
|
||||||
|
|
||||||
|
const service = new SalesforceConnection(configService, requestQueue as SalesforceRequestQueueService, logger);
|
||||||
|
|
||||||
|
// Override internal connection with simple stubs
|
||||||
|
const queryMock = jest.fn().mockResolvedValue("query-result");
|
||||||
|
service["connection"] = {
|
||||||
|
query: queryMock,
|
||||||
|
sobject: jest.fn().mockReturnValue({
|
||||||
|
create: jest.fn().mockResolvedValue({ id: "001" }),
|
||||||
|
update: jest.fn().mockResolvedValue({}),
|
||||||
|
}),
|
||||||
|
} as unknown as typeof service["connection"];
|
||||||
|
|
||||||
|
jest.spyOn(service as unknown as { ensureConnected(): Promise<void> }, "ensureConnected").mockResolvedValue();
|
||||||
|
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
requestQueue,
|
||||||
|
queryMock,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes standard queries through the request queue with derived priority metadata", async () => {
|
||||||
|
const { service, requestQueue, queryMock } = createService();
|
||||||
|
|
||||||
|
await service.query("SELECT Id FROM Account WHERE Id = '001'");
|
||||||
|
|
||||||
|
expect(requestQueue.execute).toHaveBeenCalledTimes(1);
|
||||||
|
const [, options] = (requestQueue.execute as jest.Mock).mock.calls[0];
|
||||||
|
expect(options).toMatchObject({ priority: 8, isLongRunning: false });
|
||||||
|
expect(queryMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes SObject create operations through the high-priority queue", async () => {
|
||||||
|
const { service, requestQueue } = createService();
|
||||||
|
|
||||||
|
const sobject = service.sobject("Order");
|
||||||
|
await sobject.create({ Name: "Test" });
|
||||||
|
|
||||||
|
expect(requestQueue.executeHighPriority).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -242,42 +242,50 @@ export class SalesforceConnection {
|
|||||||
|
|
||||||
// Expose connection methods with automatic re-authentication
|
// Expose connection methods with automatic re-authentication
|
||||||
async query(soql: string): Promise<unknown> {
|
async query(soql: string): Promise<unknown> {
|
||||||
|
const priority = this.getQueryPriority(soql);
|
||||||
|
const isLongRunning = this.isLongRunningQuery(soql);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure we have a base URL and token
|
return await this.requestQueue.execute(
|
||||||
await this.ensureConnected();
|
async () => {
|
||||||
return await this.connection.query(soql);
|
await this.ensureConnected();
|
||||||
|
try {
|
||||||
|
return await this.connection.query(soql);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (this.isSessionExpiredError(error)) {
|
||||||
|
const reAuthStartTime = Date.now();
|
||||||
|
this.logger.warn("Salesforce session expired, attempting to re-authenticate", {
|
||||||
|
originalError: getErrorMessage(error),
|
||||||
|
tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null,
|
||||||
|
tokenExpiresAt: this.tokenExpiresAt ? new Date(this.tokenExpiresAt).toISOString() : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connect(true);
|
||||||
|
|
||||||
|
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||||
|
this.logger.debug("Retrying query after re-authentication", {
|
||||||
|
reAuthDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.connection.query(soql);
|
||||||
|
} catch (retryError) {
|
||||||
|
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||||
|
this.logger.error("Failed to re-authenticate or retry query", {
|
||||||
|
originalError: getErrorMessage(error),
|
||||||
|
retryError: getErrorMessage(retryError),
|
||||||
|
reAuthDuration,
|
||||||
|
});
|
||||||
|
throw retryError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ priority, isLongRunning }
|
||||||
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Check if this is a session expiration error
|
|
||||||
if (this.isSessionExpiredError(error)) {
|
|
||||||
const reAuthStartTime = Date.now();
|
|
||||||
this.logger.warn("Salesforce session expired, attempting to re-authenticate", {
|
|
||||||
originalError: getErrorMessage(error),
|
|
||||||
tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null,
|
|
||||||
tokenExpiresAt: this.tokenExpiresAt ? new Date(this.tokenExpiresAt).toISOString() : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Re-authenticate
|
|
||||||
await this.connect(true);
|
|
||||||
|
|
||||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
|
||||||
this.logger.debug("Retrying query after re-authentication", {
|
|
||||||
reAuthDuration,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await this.connection.query(soql);
|
|
||||||
} catch (retryError) {
|
|
||||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
|
||||||
this.logger.error("Failed to re-authenticate or retry query", {
|
|
||||||
originalError: getErrorMessage(error),
|
|
||||||
retryError: getErrorMessage(retryError),
|
|
||||||
reAuthDuration,
|
|
||||||
});
|
|
||||||
throw retryError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-throw other errors as-is
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -305,86 +313,90 @@ export class SalesforceConnection {
|
|||||||
// Return a wrapper that handles session expiration for SObject operations
|
// Return a wrapper that handles session expiration for SObject operations
|
||||||
return {
|
return {
|
||||||
create: async (data: object) => {
|
create: async (data: object) => {
|
||||||
try {
|
return this.requestQueue.executeHighPriority(async () => {
|
||||||
await this.ensureConnected();
|
try {
|
||||||
return await originalSObject.create(data);
|
await this.ensureConnected();
|
||||||
} catch (error: unknown) {
|
return await originalSObject.create(data);
|
||||||
if (this.isSessionExpiredError(error)) {
|
} catch (error: unknown) {
|
||||||
const reAuthStartTime = Date.now();
|
if (this.isSessionExpiredError(error)) {
|
||||||
this.logger.warn(
|
const reAuthStartTime = Date.now();
|
||||||
"Salesforce session expired during SObject create, attempting to re-authenticate",
|
this.logger.warn(
|
||||||
{
|
"Salesforce session expired during SObject create, attempting to re-authenticate",
|
||||||
sobjectType: type,
|
{
|
||||||
originalError: getErrorMessage(error),
|
sobjectType: type,
|
||||||
|
originalError: getErrorMessage(error),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connect(true);
|
||||||
|
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||||
|
this.logger.debug("Retrying SObject create after re-authentication", {
|
||||||
|
sobjectType: type,
|
||||||
|
reAuthDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newSObject = this.connection.sobject(type);
|
||||||
|
return await newSObject.create(data);
|
||||||
|
} catch (retryError) {
|
||||||
|
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||||
|
this.logger.error("Failed to re-authenticate or retry SObject create", {
|
||||||
|
sobjectType: type,
|
||||||
|
originalError: getErrorMessage(error),
|
||||||
|
retryError: getErrorMessage(retryError),
|
||||||
|
reAuthDuration,
|
||||||
|
});
|
||||||
|
throw retryError;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connect(true);
|
|
||||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
|
||||||
this.logger.debug("Retrying SObject create after re-authentication", {
|
|
||||||
sobjectType: type,
|
|
||||||
reAuthDuration,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newSObject = this.connection.sobject(type);
|
|
||||||
return await newSObject.create(data);
|
|
||||||
} catch (retryError) {
|
|
||||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
|
||||||
this.logger.error("Failed to re-authenticate or retry SObject create", {
|
|
||||||
sobjectType: type,
|
|
||||||
originalError: getErrorMessage(error),
|
|
||||||
retryError: getErrorMessage(retryError),
|
|
||||||
reAuthDuration,
|
|
||||||
});
|
|
||||||
throw retryError;
|
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
throw error;
|
});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (data: object & { Id: string }) => {
|
update: async (data: object & { Id: string }) => {
|
||||||
try {
|
return this.requestQueue.executeHighPriority(async () => {
|
||||||
await this.ensureConnected();
|
try {
|
||||||
return await originalSObject.update(data);
|
await this.ensureConnected();
|
||||||
} catch (error: unknown) {
|
return await originalSObject.update(data);
|
||||||
if (this.isSessionExpiredError(error)) {
|
} catch (error: unknown) {
|
||||||
const reAuthStartTime = Date.now();
|
if (this.isSessionExpiredError(error)) {
|
||||||
this.logger.warn(
|
const reAuthStartTime = Date.now();
|
||||||
"Salesforce session expired during SObject update, attempting to re-authenticate",
|
this.logger.warn(
|
||||||
{
|
"Salesforce session expired during SObject update, attempting to re-authenticate",
|
||||||
sobjectType: type,
|
{
|
||||||
recordId: data.Id,
|
sobjectType: type,
|
||||||
originalError: getErrorMessage(error),
|
recordId: data.Id,
|
||||||
|
originalError: getErrorMessage(error),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connect(true);
|
||||||
|
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||||
|
this.logger.debug("Retrying SObject update after re-authentication", {
|
||||||
|
sobjectType: type,
|
||||||
|
recordId: data.Id,
|
||||||
|
reAuthDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newSObject = this.connection.sobject(type);
|
||||||
|
return await newSObject.update(data);
|
||||||
|
} catch (retryError) {
|
||||||
|
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||||
|
this.logger.error("Failed to re-authenticate or retry SObject update", {
|
||||||
|
sobjectType: type,
|
||||||
|
recordId: data.Id,
|
||||||
|
originalError: getErrorMessage(error),
|
||||||
|
retryError: getErrorMessage(retryError),
|
||||||
|
reAuthDuration,
|
||||||
|
});
|
||||||
|
throw retryError;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connect(true);
|
|
||||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
|
||||||
this.logger.debug("Retrying SObject update after re-authentication", {
|
|
||||||
sobjectType: type,
|
|
||||||
recordId: data.Id,
|
|
||||||
reAuthDuration,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newSObject = this.connection.sobject(type);
|
|
||||||
return await newSObject.update(data);
|
|
||||||
} catch (retryError) {
|
|
||||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
|
||||||
this.logger.error("Failed to re-authenticate or retry SObject update", {
|
|
||||||
sobjectType: type,
|
|
||||||
recordId: data.Id,
|
|
||||||
originalError: getErrorMessage(error),
|
|
||||||
retryError: getErrorMessage(retryError),
|
|
||||||
reAuthDuration,
|
|
||||||
});
|
|
||||||
throw retryError;
|
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
throw error;
|
});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -440,7 +452,28 @@ export class SalesforceConnection {
|
|||||||
async queryHighPriority(soql: string): Promise<unknown> {
|
async queryHighPriority(soql: string): Promise<unknown> {
|
||||||
return this.requestQueue.executeHighPriority(async () => {
|
return this.requestQueue.executeHighPriority(async () => {
|
||||||
await this.ensureConnected();
|
await this.ensureConnected();
|
||||||
return await this.connection.query(soql);
|
try {
|
||||||
|
return await this.connection.query(soql);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (this.isSessionExpiredError(error)) {
|
||||||
|
const reAuthStartTime = Date.now();
|
||||||
|
this.logger.warn(
|
||||||
|
"Salesforce session expired during high-priority query, attempting to re-authenticate",
|
||||||
|
{
|
||||||
|
originalError: getErrorMessage(error),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.connect(true);
|
||||||
|
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||||
|
this.logger.debug("Retrying high-priority query after re-authentication", {
|
||||||
|
reAuthDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.connection.query(soql);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -297,18 +297,22 @@ export class AuthFacade {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(userId: string, token?: string, request?: Request): Promise<void> {
|
async logout(userId?: string, token?: string, request?: Request): Promise<void> {
|
||||||
if (token) {
|
if (token) {
|
||||||
await this.tokenBlacklistService.blacklistToken(token);
|
await this.tokenBlacklistService.blacklistToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (userId) {
|
||||||
await this.tokenService.revokeAllUserTokens(userId);
|
try {
|
||||||
} catch (error) {
|
await this.tokenService.revokeAllUserTokens(userId);
|
||||||
this.logger.warn("Failed to revoke refresh tokens during logout", {
|
} catch (error) {
|
||||||
userId,
|
this.logger.warn("Failed to revoke refresh tokens during logout", {
|
||||||
error: getErrorMessage(error),
|
userId,
|
||||||
});
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.debug("Skipping refresh token revocation during logout – userId unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, request, true);
|
await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, request, true);
|
||||||
|
|||||||
@ -12,13 +12,18 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { AuthFacade } from "@bff/modules/auth/application/auth.facade";
|
import { AuthFacade } from "@bff/modules/auth/application/auth.facade";
|
||||||
import { LocalAuthGuard } from "./guards/local-auth.guard";
|
import { LocalAuthGuard } from "./guards/local-auth.guard";
|
||||||
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
||||||
import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
|
import {
|
||||||
|
FailedLoginThrottleGuard,
|
||||||
|
type RequestWithRateLimit,
|
||||||
|
} from "./guards/failed-login-throttle.guard";
|
||||||
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
|
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
|
||||||
import { Public } from "../../decorators/public.decorator";
|
import { Public } from "../../decorators/public.decorator";
|
||||||
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
||||||
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||||
|
|
||||||
// Import Zod schemas from domain
|
// Import Zod schemas from domain
|
||||||
import {
|
import {
|
||||||
@ -100,7 +105,7 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => {
|
|||||||
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private authFacade: AuthFacade) {}
|
constructor(private authFacade: AuthFacade, private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
private setAuthCookies(res: Response, tokens: AuthTokens): void {
|
private setAuthCookies(res: Response, tokens: AuthTokens): void {
|
||||||
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
|
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
|
||||||
@ -121,6 +126,10 @@ export class AuthController {
|
|||||||
res.setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" });
|
res.setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
|
||||||
|
FailedLoginThrottleGuard.applyRateLimitHeaders(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post("validate-signup")
|
@Post("validate-signup")
|
||||||
@UseGuards(AuthThrottleGuard)
|
@UseGuards(AuthThrottleGuard)
|
||||||
@ -173,21 +182,41 @@ export class AuthController {
|
|||||||
@UseInterceptors(LoginResultInterceptor)
|
@UseInterceptors(LoginResultInterceptor)
|
||||||
@Post("login")
|
@Post("login")
|
||||||
async login(
|
async login(
|
||||||
@Req() req: Request & { user: { id: string; email: string; role: string } },
|
@Req() req: RequestWithUser & RequestWithRateLimit,
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const result = await this.authFacade.login(req.user, req);
|
const result = await this.authFacade.login(req.user, req);
|
||||||
this.setAuthCookies(res, result.tokens);
|
this.setAuthCookies(res, result.tokens);
|
||||||
|
this.applyAuthRateLimitHeaders(req, res);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Post("logout")
|
@Post("logout")
|
||||||
async logout(
|
async logout(
|
||||||
@Req() req: RequestWithCookies & { user: { id: string } },
|
@Req() req: RequestWithCookies & { user?: { id: string } },
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const token = extractTokenFromRequest(req);
|
const token = extractTokenFromRequest(req);
|
||||||
await this.authFacade.logout(req.user.id, token, req as Request);
|
let userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!userId && token) {
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<{ sub?: string }>(token, {
|
||||||
|
ignoreExpiration: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload?.sub) {
|
||||||
|
userId = payload.sub;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore verification errors – we still want to clear client cookies.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.authFacade.logout(userId, token, req as Request);
|
||||||
|
|
||||||
|
// Always clear cookies, even if session expired
|
||||||
this.clearAuthCookies(res);
|
this.clearAuthCookies(res);
|
||||||
return { message: "Logout successful" };
|
return { message: "Logout successful" };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { Injectable, ExecutionContext } from "@nestjs/common";
|
import { Injectable, ExecutionContext } from "@nestjs/common";
|
||||||
import type { Request } from "express";
|
import type { Request, Response } from "express";
|
||||||
import {
|
import {
|
||||||
AuthRateLimitService,
|
AuthRateLimitService,
|
||||||
type RateLimitOutcome,
|
type RateLimitOutcome,
|
||||||
} from "../../../infra/rate-limiting/auth-rate-limit.service";
|
} from "../../../infra/rate-limiting/auth-rate-limit.service";
|
||||||
|
|
||||||
type RequestWithRateLimit = Request & { __authRateLimit?: RateLimitOutcome };
|
export type RequestWithRateLimit = Request & { __authRateLimit?: RateLimitOutcome };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FailedLoginThrottleGuard {
|
export class FailedLoginThrottleGuard {
|
||||||
@ -26,4 +26,16 @@ export class FailedLoginThrottleGuard {
|
|||||||
await this.authRateLimitService.clearLoginAttempts(request);
|
await this.authRateLimitService.clearLoginAttempts(request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static applyRateLimitHeaders(
|
||||||
|
request: RequestWithRateLimit,
|
||||||
|
response: Response
|
||||||
|
): void {
|
||||||
|
const outcome = request.__authRateLimit;
|
||||||
|
if (!outcome) return;
|
||||||
|
|
||||||
|
response.setHeader("X-RateLimit-Remaining", String(outcome.remainingPoints));
|
||||||
|
response.setHeader("X-RateLimit-Reset", String(Math.ceil(outcome.msBeforeNext / 1000)));
|
||||||
|
response.setHeader("X-Auth-Captcha", outcome.needsCaptcha ? "required" : "optional");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,6 +49,7 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
|||||||
override async canActivate(context: ExecutionContext): Promise<boolean> {
|
override async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = this.getRequest(context);
|
const request = this.getRequest(context);
|
||||||
const route = `${request.method} ${request.url}`;
|
const route = `${request.method} ${request.url}`;
|
||||||
|
const isLogoutRoute = this.isLogoutRoute(request);
|
||||||
|
|
||||||
// Check if the route is marked as public
|
// Check if the route is marked as public
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
@ -65,6 +66,10 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
|||||||
// First, run the standard JWT authentication
|
// First, run the standard JWT authentication
|
||||||
const canActivate = await super.canActivate(context);
|
const canActivate = await super.canActivate(context);
|
||||||
if (!canActivate) {
|
if (!canActivate) {
|
||||||
|
if (isLogoutRoute) {
|
||||||
|
this.logger.debug(`Allowing logout request without active session: ${route}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
this.logger.warn(`JWT authentication failed for route: ${route}`);
|
this.logger.warn(`JWT authentication failed for route: ${route}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -84,6 +89,10 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedException) {
|
if (error instanceof UnauthorizedException) {
|
||||||
|
if (isLogoutRoute) {
|
||||||
|
this.logger.debug(`Allowing logout request with expired or invalid token: ${route}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const token = extractTokenFromRequest(request);
|
const token = extractTokenFromRequest(request);
|
||||||
const log =
|
const log =
|
||||||
typeof token === "string"
|
typeof token === "string"
|
||||||
@ -116,4 +125,31 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
|||||||
(!candidate.route || typeof candidate.route === "object")
|
(!candidate.route || typeof candidate.route === "object")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isLogoutRoute(request: RequestWithRoute): boolean {
|
||||||
|
if (!request || typeof request.method !== "string" || typeof request.url !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method.toUpperCase() !== "POST") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routePath = request.route?.path;
|
||||||
|
if (routePath && this.isLogoutPath(routePath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUrl = request.url.split("?")[0] ?? "";
|
||||||
|
return this.isLogoutPath(normalizedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLogoutPath(path: string): boolean {
|
||||||
|
if (typeof path !== "string" || path.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = path.endsWith("/") ? path.slice(0, -1) : path;
|
||||||
|
return normalized === "/auth/logout" || normalized === "/api/auth/logout";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,11 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Observable, defer } from "rxjs";
|
import { Observable, defer } from "rxjs";
|
||||||
import { tap, catchError } from "rxjs/operators";
|
import { tap, catchError } from "rxjs/operators";
|
||||||
import { FailedLoginThrottleGuard } from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard";
|
import {
|
||||||
import type { Request } from "express";
|
FailedLoginThrottleGuard,
|
||||||
|
type RequestWithRateLimit,
|
||||||
|
} from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LoginResultInterceptor implements NestInterceptor {
|
export class LoginResultInterceptor implements NestInterceptor {
|
||||||
@ -16,14 +19,16 @@ export class LoginResultInterceptor implements NestInterceptor {
|
|||||||
|
|
||||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
const rawRequest = context.switchToHttp().getRequest<unknown>();
|
const rawRequest = context.switchToHttp().getRequest<unknown>();
|
||||||
|
const response = context.switchToHttp().getResponse<Response>();
|
||||||
if (!this.isExpressRequest(rawRequest)) {
|
if (!this.isExpressRequest(rawRequest)) {
|
||||||
throw new UnauthorizedException("Invalid request context");
|
throw new UnauthorizedException("Invalid request context");
|
||||||
}
|
}
|
||||||
const request: Request = rawRequest;
|
const request: RequestWithRateLimit = rawRequest as RequestWithRateLimit;
|
||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
tap(() => {
|
tap(() => {
|
||||||
void this.failedLoginGuard.handleLoginResult(request, true);
|
void this.failedLoginGuard.handleLoginResult(request, true);
|
||||||
|
FailedLoginThrottleGuard.applyRateLimitHeaders(request, response);
|
||||||
}),
|
}),
|
||||||
catchError(error =>
|
catchError(error =>
|
||||||
defer(async () => {
|
defer(async () => {
|
||||||
@ -37,6 +42,7 @@ export class LoginResultInterceptor implements NestInterceptor {
|
|||||||
|
|
||||||
if (isAuthError) {
|
if (isAuthError) {
|
||||||
await this.failedLoginGuard.handleLoginResult(request, false);
|
await this.failedLoginGuard.handleLoginResult(request, false);
|
||||||
|
FailedLoginThrottleGuard.applyRateLimitHeaders(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get } from "@nestjs/common";
|
import { Controller, Get } from "@nestjs/common";
|
||||||
import { Public } from "@bff/modules/auth/decorators/public.decorator";
|
import { Public } from "@bff/modules/auth/decorators/public.decorator";
|
||||||
import { WhmcsCurrencyService } from "../../integrations/whmcs/services/whmcs-currency.service";
|
import { WhmcsCurrencyService } from "../../integrations/whmcs/services/whmcs-currency.service";
|
||||||
|
import type { WhmcsCurrency } from "@customer-portal/domain/billing";
|
||||||
|
|
||||||
@Controller("currency")
|
@Controller("currency")
|
||||||
export class CurrencyController {
|
export class CurrencyController {
|
||||||
@ -8,20 +9,13 @@ export class CurrencyController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get("default")
|
@Get("default")
|
||||||
getDefaultCurrency() {
|
getDefaultCurrency(): WhmcsCurrency {
|
||||||
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
return this.currencyService.getDefaultCurrency();
|
||||||
return {
|
|
||||||
code: defaultCurrency.code,
|
|
||||||
prefix: defaultCurrency.prefix,
|
|
||||||
suffix: defaultCurrency.suffix,
|
|
||||||
format: defaultCurrency.format,
|
|
||||||
rate: defaultCurrency.rate,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get("all")
|
@Get("all")
|
||||||
getAllCurrencies() {
|
getAllCurrencies(): WhmcsCurrency[] {
|
||||||
return this.currencyService.getAllCurrencies();
|
return this.currencyService.getAllCurrencies();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,11 @@ import { Module } from "@nestjs/common";
|
|||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
import { PrismaModule } from "@bff/infra/database/prisma.module";
|
import { PrismaModule } from "@bff/infra/database/prisma.module";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { QueueModule } from "@bff/core/queue/queue.module";
|
||||||
|
import { QueueHealthController } from "@bff/core/health/queue-health.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, ConfigModule],
|
imports: [PrismaModule, ConfigModule, QueueModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController, QueueHealthController],
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
|||||||
@ -95,7 +95,7 @@ export class OrderEventsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildEvent(event: string, data: Record<string, unknown>): MessageEvent {
|
private buildEvent<T extends object>(event: string, data: T): MessageEvent {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
event,
|
event,
|
||||||
@ -104,4 +104,3 @@ export class OrderEventsService {
|
|||||||
} satisfies MessageEvent;
|
} satisfies MessageEvent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,6 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
|||||||
|
|
||||||
# Copy package.json files for dependency resolution
|
# Copy package.json files for dependency resolution
|
||||||
COPY packages/domain/package.json ./packages/domain/
|
COPY packages/domain/package.json ./packages/domain/
|
||||||
COPY packages/logging/package.json ./packages/logging/
|
|
||||||
COPY packages/validation/package.json ./packages/validation/
|
COPY packages/validation/package.json ./packages/validation/
|
||||||
COPY apps/portal/package.json ./apps/portal/
|
COPY apps/portal/package.json ./apps/portal/
|
||||||
|
|
||||||
@ -52,7 +51,6 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
|
|
||||||
# Build shared workspace packages first
|
# Build shared workspace packages first
|
||||||
RUN pnpm --filter @customer-portal/domain build && \
|
RUN pnpm --filter @customer-portal/domain build && \
|
||||||
pnpm --filter @customer-portal/logging build && \
|
|
||||||
pnpm --filter @customer-portal/validation build
|
pnpm --filter @customer-portal/validation build
|
||||||
|
|
||||||
# Build portal with standalone output
|
# Build portal with standalone output
|
||||||
|
|||||||
@ -18,7 +18,6 @@ const nextConfig = {
|
|||||||
// Ensure workspace packages are transpiled correctly
|
// Ensure workspace packages are transpiled correctly
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@customer-portal/domain",
|
"@customer-portal/domain",
|
||||||
"@customer-portal/logging",
|
|
||||||
"@customer-portal/validation",
|
"@customer-portal/validation",
|
||||||
],
|
],
|
||||||
|
|
||||||
@ -119,7 +118,6 @@ const nextConfig = {
|
|||||||
config.resolve.alias = {
|
config.resolve.alias = {
|
||||||
...config.resolve.alias,
|
...config.resolve.alias,
|
||||||
"@customer-portal/domain": path.join(workspaceRoot, "packages/domain"),
|
"@customer-portal/domain": path.join(workspaceRoot, "packages/domain"),
|
||||||
"@customer-portal/logging": path.join(workspaceRoot, "packages/logging/src"),
|
|
||||||
"@customer-portal/validation": path.join(workspaceRoot, "packages/validation/src"),
|
"@customer-portal/validation": path.join(workspaceRoot, "packages/validation/src"),
|
||||||
};
|
};
|
||||||
const preferredExtensions = [".ts", ".tsx", ".mts", ".cts"];
|
const preferredExtensions = [".ts", ".tsx", ".mts", ".cts"];
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@customer-portal/domain": "workspace:*",
|
"@customer-portal/domain": "workspace:*",
|
||||||
"@customer-portal/logging": "workspace:*",
|
|
||||||
"@customer-portal/validation": "workspace:*",
|
"@customer-portal/validation": "workspace:*",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Component, ReactNode, ErrorInfo } from "react";
|
import { Component, ReactNode, ErrorInfo } from "react";
|
||||||
import { log } from "@customer-portal/logging";
|
import { log } from "@/lib/logger";
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
interface ErrorBoundaryState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
import { accountService } from "@/features/account/services/account.service";
|
import { accountService } from "@/features/account/services/account.service";
|
||||||
import { logger } from "@customer-portal/logging";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
// Use centralized profile types
|
// Use centralized profile types
|
||||||
import type { ProfileEditFormData, Address } from "@customer-portal/domain/customer";
|
import type { ProfileEditFormData, Address } from "@customer-portal/domain/customer";
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { logger } from "@customer-portal/logging";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { create } from "zustand";
|
|||||||
import { apiClient } from "@/lib/api";
|
import { apiClient } from "@/lib/api";
|
||||||
import { getNullableData } from "@/lib/api/response-helpers";
|
import { getNullableData } from "@/lib/api/response-helpers";
|
||||||
import { getErrorInfo } from "@/lib/utils/error-handling";
|
import { getErrorInfo } from "@/lib/utils/error-handling";
|
||||||
import logger from "@customer-portal/logging";
|
import { logger } from "@/lib/logger";
|
||||||
import {
|
import {
|
||||||
authResponseSchema,
|
authResponseSchema,
|
||||||
checkPasswordNeededResponseSchema,
|
checkPasswordNeededResponseSchema,
|
||||||
|
|||||||
@ -20,7 +20,7 @@ const { formatCurrency } = Formatting;
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
||||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||||
import { logger } from "@customer-portal/logging";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
interface InvoiceTableProps {
|
interface InvoiceTableProps {
|
||||||
invoices: Invoice[];
|
invoices: Invoice[];
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
|||||||
import { ErrorState } from "@/components/atoms/error-state";
|
import { ErrorState } from "@/components/atoms/error-state";
|
||||||
import { CheckCircleIcon, DocumentTextIcon } from "@heroicons/react/24/outline";
|
import { CheckCircleIcon, DocumentTextIcon } from "@heroicons/react/24/outline";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { logger } from "@customer-portal/logging";
|
import { logger } from "@/lib/logger";
|
||||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||||
import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks";
|
import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import { InlineToast } from "@/components/atoms/inline-toast";
|
|||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
import { logger } from "@customer-portal/logging";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export function PaymentMethodsContainer() {
|
export function PaymentMethodsContainer() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { accountService } from "@/features/account/services/account.service";
|
import { accountService } from "@/features/account/services/account.service";
|
||||||
import { log } from "@customer-portal/logging";
|
import { log } from "@/lib/logger";
|
||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
import { StatusPill } from "@/components/atoms/status-pill";
|
||||||
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
import { COUNTRY_OPTIONS, getCountryName } from "@/lib/constants/countries";
|
import { COUNTRY_OPTIONS, getCountryName } from "@/lib/constants/countries";
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboa
|
|||||||
import { LoadingStats, LoadingTable } from "@/components/atoms";
|
import { LoadingStats, LoadingTable } from "@/components/atoms";
|
||||||
import { ErrorState } from "@/components/atoms/error-state";
|
import { ErrorState } from "@/components/atoms/error-state";
|
||||||
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
||||||
import { log } from "@customer-portal/logging";
|
import { log } from "@/lib/logger";
|
||||||
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
||||||
|
|
||||||
export function DashboardView() {
|
export function DashboardView() {
|
||||||
|
|||||||
@ -44,13 +44,6 @@ const SERVICE_ICON_STYLES = {
|
|||||||
default: "bg-slate-50 text-slate-600",
|
default: "bg-slate-50 text-slate-600",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const STATUS_ACCENT_TONE_CLASSES = {
|
|
||||||
success: "bg-green-400/70",
|
|
||||||
info: "bg-blue-400/70",
|
|
||||||
warning: "bg-amber-400/70",
|
|
||||||
neutral: "bg-slate-200",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const renderServiceIcon = (orderType?: string): ReactNode => {
|
const renderServiceIcon = (orderType?: string): ReactNode => {
|
||||||
const category = getServiceCategory(orderType);
|
const category = getServiceCategory(orderType);
|
||||||
switch (category) {
|
switch (category) {
|
||||||
@ -115,7 +108,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
|||||||
<article
|
<article
|
||||||
key={String(order.id)}
|
key={String(order.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-7 shadow-sm transition-all focus-visible:outline-none",
|
"group overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-all focus-visible:outline-none",
|
||||||
isInteractive &&
|
isInteractive &&
|
||||||
"cursor-pointer hover:border-blue-200 hover:shadow-md focus-within:border-blue-300 focus-within:ring-2 focus-within:ring-blue-100",
|
"cursor-pointer hover:border-blue-200 hover:shadow-md focus-within:border-blue-300 focus-within:ring-2 focus-within:ring-blue-100",
|
||||||
className
|
className
|
||||||
@ -125,92 +118,86 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
|||||||
role={isInteractive ? "button" : undefined}
|
role={isInteractive ? "button" : undefined}
|
||||||
tabIndex={isInteractive ? 0 : undefined}
|
tabIndex={isInteractive ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<span
|
{/* Header */}
|
||||||
className={cn(
|
<div className="border-b border-slate-100 bg-gradient-to-br from-white to-slate-50 px-6 py-4">
|
||||||
"pointer-events-none absolute inset-x-4 top-4 h-1 rounded-full",
|
<div className="flex items-center justify-between gap-6">
|
||||||
STATUS_ACCENT_TONE_CLASSES[statusDescriptor.tone]
|
<div className="flex items-center gap-3">
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="flex min-w-0 flex-1 items-start gap-4">
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-12 w-12 items-center justify-center rounded-xl border border-transparent",
|
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
|
||||||
iconStyles
|
iconStyles
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{serviceIcon}
|
{serviceIcon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 space-y-2">
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 line-clamp-2">{serviceSummary}</h3>
|
<h3 className="font-semibold text-gray-900">{serviceSummary}</h3>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-gray-500">
|
||||||
<span className="font-medium uppercase tracking-[0.14em] text-gray-400">
|
<span className="font-medium">#{order.orderNumber || String(order.id).slice(-8)}</span>
|
||||||
Order #{order.orderNumber || String(order.id).slice(-8)}
|
<span>•</span>
|
||||||
</span>
|
<span>{formattedCreatedDate || "—"}</span>
|
||||||
<span className="hidden text-gray-300 sm:inline">•</span>
|
|
||||||
<span>{formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 line-clamp-2">{statusDescriptor.description}</p>
|
</div>
|
||||||
{displayItems.length > 1 && (
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 pt-1 text-xs text-gray-500">
|
|
||||||
{displayItems.slice(1, 4).map(item => (
|
{/* Pricing */}
|
||||||
<span
|
{showPricing && (
|
||||||
key={item.id}
|
<div className="flex items-center gap-4">
|
||||||
className="rounded-full bg-slate-100 px-2 py-0.5 font-medium text-slate-600"
|
{totals.monthlyTotal > 0 && (
|
||||||
>
|
<div className="text-right">
|
||||||
{item.name}
|
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">Monthly</p>
|
||||||
</span>
|
<p className="text-xl font-bold text-gray-900">
|
||||||
))}
|
¥{totals.monthlyTotal.toLocaleString()}
|
||||||
{displayItems.length > 4 && (
|
</p>
|
||||||
<span className="text-gray-400">+{displayItems.length - 4} more</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{totals.oneTimeTotal > 0 && (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">One-Time</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">
|
||||||
|
¥{totals.oneTimeTotal.toLocaleString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col items-end gap-3 text-right">
|
|
||||||
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{showPricing ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{totals.monthlyTotal > 0 ? (
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
¥{totals.monthlyTotal.toLocaleString()}
|
|
||||||
<span className="ml-1 text-xs font-medium text-gray-500">/ month</span>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm font-semibold text-gray-500">No monthly charges</p>
|
|
||||||
)}
|
|
||||||
{totals.oneTimeTotal > 0 && (
|
|
||||||
<p className="text-xs font-medium text-gray-500">
|
|
||||||
¥{totals.oneTimeTotal.toLocaleString()} one-time
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm font-semibold text-gray-500">Included in plan</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(isInteractive || footer) && (
|
{/* Body */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-blue-600">
|
<div className="px-6 py-4">
|
||||||
{isInteractive ? (
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="flex items-center gap-2 font-medium">
|
<p className="text-sm text-gray-700">{statusDescriptor.description}</p>
|
||||||
View details
|
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
|
||||||
<ChevronRightIcon className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
|
</div>
|
||||||
|
{displayItems.length > 1 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{displayItems.slice(1, 4).map(item => (
|
||||||
|
<span
|
||||||
|
key={item.id}
|
||||||
|
className="inline-flex items-center rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{displayItems.length > 4 && (
|
||||||
|
<span className="inline-flex items-center text-xs text-gray-400">
|
||||||
|
+{displayItems.length - 4} more
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<span className="text-gray-500">{statusDescriptor.label}</span>
|
|
||||||
)}
|
)}
|
||||||
{footer && <div className="text-gray-500">{footer}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{isInteractive && (
|
||||||
|
<div className="border-t border-slate-100 bg-slate-50/30 px-6 py-3">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium text-blue-600">
|
||||||
|
View details
|
||||||
|
<ChevronRightIcon className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,10 +118,14 @@ const aggregateCharges = (group: OrderItemGroup): OrderDisplayItemCharge[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildGroupName = (group: OrderItemGroup): string => {
|
const buildGroupName = (group: OrderItemGroup): string => {
|
||||||
const monthlyItem = group.items.find(
|
// For bundles, combine the names
|
||||||
item => normalizeBillingCycle(item.billingCycle ?? undefined) === "monthly"
|
if (group.items.length > 1) {
|
||||||
);
|
return group.items
|
||||||
const fallbackItem = monthlyItem ?? group.items[0];
|
.map(item => item.productName || item.name || "Item")
|
||||||
|
.join(" + ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackItem = group.items[0];
|
||||||
return fallbackItem?.productName || fallbackItem?.name || "Service item";
|
return fallbackItem?.productName || fallbackItem?.name || "Service item";
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -195,22 +199,22 @@ export function buildOrderDisplayItems(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = buildOrderItemGroup(items);
|
// Don't group items - show each one separately
|
||||||
|
return items.map((item, index) => {
|
||||||
return groups.map((group, groupIndex) => {
|
const charges = aggregateCharges({ indices: [index], items: [item] });
|
||||||
const charges = aggregateCharges(group);
|
const isBundled = Boolean(item.isBundledAddon);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: buildOrderItemId(group, groupIndex),
|
id: item.productId || item.sku || `order-item-${index}`,
|
||||||
name: buildGroupName(group),
|
name: item.productName || item.name || "Service item",
|
||||||
quantity:
|
quantity: item.quantity ?? undefined,
|
||||||
group.items.length === 1 ? group.items[0]?.quantity ?? undefined : undefined,
|
status: item.status ?? undefined,
|
||||||
status: group.items.length === 1 ? group.items[0]?.status ?? undefined : undefined,
|
primaryCategory: resolveCategory(item),
|
||||||
primaryCategory: determinePrimaryCategory(group),
|
categories: [resolveCategory(item)],
|
||||||
categories: collectCategories(group),
|
|
||||||
charges,
|
charges,
|
||||||
included: isIncludedGroup(charges),
|
included: isIncludedGroup(charges),
|
||||||
sourceItems: group.items,
|
sourceItems: [item],
|
||||||
isBundle: group.items.length > 1,
|
isBundle: isBundled,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
PuzzlePieceIcon,
|
PuzzlePieceIcon,
|
||||||
BoltIcon,
|
BoltIcon,
|
||||||
|
ClockIcon,
|
||||||
Squares2X2Icon,
|
Squares2X2Icon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
@ -63,43 +64,71 @@ const CATEGORY_CONFIG: Record<
|
|||||||
OrderDisplayItemCategory,
|
OrderDisplayItemCategory,
|
||||||
{
|
{
|
||||||
icon: typeof SparklesIcon;
|
icon: typeof SparklesIcon;
|
||||||
badgeClass: string;
|
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
service: {
|
service: {
|
||||||
icon: SparklesIcon,
|
icon: SparklesIcon,
|
||||||
badgeClass: "bg-blue-50 text-blue-700",
|
|
||||||
label: "Service",
|
label: "Service",
|
||||||
},
|
},
|
||||||
installation: {
|
installation: {
|
||||||
icon: WrenchScrewdriverIcon,
|
icon: WrenchScrewdriverIcon,
|
||||||
badgeClass: "bg-emerald-50 text-emerald-700",
|
|
||||||
label: "Installation",
|
label: "Installation",
|
||||||
},
|
},
|
||||||
addon: {
|
addon: {
|
||||||
icon: PuzzlePieceIcon,
|
icon: PuzzlePieceIcon,
|
||||||
badgeClass: "bg-violet-50 text-violet-700",
|
|
||||||
label: "Add-on",
|
label: "Add-on",
|
||||||
},
|
},
|
||||||
activation: {
|
activation: {
|
||||||
icon: BoltIcon,
|
icon: BoltIcon,
|
||||||
badgeClass: "bg-amber-50 text-amber-700",
|
|
||||||
label: "Activation",
|
label: "Activation",
|
||||||
},
|
},
|
||||||
other: {
|
other: {
|
||||||
icon: Squares2X2Icon,
|
icon: Squares2X2Icon,
|
||||||
badgeClass: "bg-slate-100 text-slate-600",
|
|
||||||
label: "Item",
|
label: "Item",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_ACCENT_TONE_CLASSES = {
|
const ITEM_VISUAL_STYLES: Record<
|
||||||
success: "bg-green-400/80",
|
OrderDisplayItemCategory,
|
||||||
info: "bg-blue-400/80",
|
{
|
||||||
warning: "bg-amber-400/80",
|
container: string;
|
||||||
neutral: "bg-slate-200",
|
icon: string;
|
||||||
} as const;
|
}
|
||||||
|
> = {
|
||||||
|
service: {
|
||||||
|
container: "border-blue-200 bg-white",
|
||||||
|
icon: "bg-blue-50 text-blue-600",
|
||||||
|
},
|
||||||
|
installation: {
|
||||||
|
container: "border-green-200 bg-white",
|
||||||
|
icon: "bg-green-50 text-green-600",
|
||||||
|
},
|
||||||
|
addon: {
|
||||||
|
container: "border-slate-200 bg-white",
|
||||||
|
icon: "bg-slate-50 text-slate-600",
|
||||||
|
},
|
||||||
|
activation: {
|
||||||
|
container: "border-slate-200 bg-white",
|
||||||
|
icon: "bg-slate-50 text-slate-600",
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
container: "border-slate-200 bg-white",
|
||||||
|
icon: "bg-slate-50 text-slate-600",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUNDLE_VISUAL_STYLE = {
|
||||||
|
container: "border-purple-200 bg-white",
|
||||||
|
icon: "bg-purple-50 text-purple-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemVisualStyle = (item: OrderDisplayItem) => {
|
||||||
|
if (item.isBundle) {
|
||||||
|
return BUNDLE_VISUAL_STYLE;
|
||||||
|
}
|
||||||
|
return ITEM_VISUAL_STYLES[item.primaryCategory] ?? ITEM_VISUAL_STYLES.other;
|
||||||
|
};
|
||||||
|
|
||||||
const describeCharge = (charge: OrderDisplayItemCharge): string => {
|
const describeCharge = (charge: OrderDisplayItemCharge): string => {
|
||||||
if (typeof charge.suffix === "string" && charge.suffix.trim().length > 0) {
|
if (typeof charge.suffix === "string" && charge.suffix.trim().length > 0) {
|
||||||
@ -146,9 +175,6 @@ export function OrderDetailContainer() {
|
|||||||
|
|
||||||
const serviceCategory = getServiceCategory(data?.orderType);
|
const serviceCategory = getServiceCategory(data?.orderType);
|
||||||
const serviceIcon = renderServiceIcon(serviceCategory, "h-6 w-6");
|
const serviceIcon = renderServiceIcon(serviceCategory, "h-6 w-6");
|
||||||
const accentTone = statusDescriptor
|
|
||||||
? STATUS_ACCENT_TONE_CLASSES[statusDescriptor.tone]
|
|
||||||
: STATUS_ACCENT_TONE_CLASSES.neutral;
|
|
||||||
|
|
||||||
const displayItems = useMemo<OrderDisplayItem[]>(() => {
|
const displayItems = useMemo<OrderDisplayItem[]>(() => {
|
||||||
return buildOrderDisplayItems(data?.itemsSummary);
|
return buildOrderDisplayItems(data?.itemsSummary);
|
||||||
@ -300,186 +326,164 @@ export function OrderDetailContainer() {
|
|||||||
|
|
||||||
{data ? (
|
{data ? (
|
||||||
<>
|
<>
|
||||||
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-8 shadow-sm sm:px-8">
|
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm">
|
||||||
<span
|
{/* Header Section */}
|
||||||
className={cn(
|
<div className="border-b border-slate-200 bg-gradient-to-br from-white to-slate-50 px-6 py-6 sm:px-8">
|
||||||
"pointer-events-none absolute inset-x-6 top-6 h-1 rounded-full",
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||||
accentTone
|
<div className="space-y-4">
|
||||||
)}
|
<div className="flex items-center gap-4">
|
||||||
aria-hidden
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-50 text-blue-600">
|
||||||
/>
|
{serviceIcon}
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div className="flex flex-1 items-start gap-4">
|
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-50 text-blue-600">
|
|
||||||
{serviceIcon}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900">{serviceLabel}</h2>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Order #{data.orderNumber || String(data.id).slice(-8)}
|
|
||||||
{placedDate ? ` • Placed ${placedDate}` : null}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right text-sm text-gray-500">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{totals.monthlyTotal > 0 ? (
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{yenFormatter.format(totals.monthlyTotal)}
|
|
||||||
<span className="ml-1 text-xs font-medium text-gray-500">/ month</span>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm font-semibold text-gray-500">No monthly charges</p>
|
|
||||||
)}
|
|
||||||
{totals.oneTimeTotal > 0 && (
|
|
||||||
<p className="text-xs font-medium text-gray-500">
|
|
||||||
{yenFormatter.format(totals.oneTimeTotal)} one-time
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
||||||
Your Services & Products
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{displayItems.length === 0 ? (
|
|
||||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-gray-500">
|
|
||||||
No items found on this order.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div>
|
||||||
displayItems.map(item => {
|
<h2 className="text-xl font-bold text-gray-900">{serviceLabel}</h2>
|
||||||
const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
|
<p className="text-sm text-gray-500">
|
||||||
const Icon = categoryConfig.icon;
|
Order #{data.orderNumber || String(data.id).slice(-8)}
|
||||||
const categories = Array.from(new Set(item.categories));
|
{placedDate ? ` • ${placedDate}` : null}
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 px-4 py-4 shadow-sm sm:flex-row sm:items-center sm:justify-between sm:gap-6"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 items-start gap-3">
|
|
||||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 space-y-2">
|
|
||||||
<div className="flex flex-wrap items-baseline gap-2">
|
|
||||||
<p className="text-base font-semibold text-gray-900">{item.name}</p>
|
|
||||||
{typeof item.quantity === "number" && item.quantity > 1 && (
|
|
||||||
<span className="text-xs font-medium text-gray-500">×{item.quantity}</span>
|
|
||||||
)}
|
|
||||||
{item.status && (
|
|
||||||
<span className="text-[11px] uppercase tracking-wide text-gray-400">
|
|
||||||
{item.status}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
|
||||||
{categories.map(category => {
|
|
||||||
const badge = CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.other;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={`${item.id}-${category}`}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
|
||||||
badge.badgeClass
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{badge.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{item.isBundle && (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700">
|
|
||||||
Bundle
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.included && (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
|
||||||
Included
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-start gap-1 text-sm text-gray-600 sm:items-end">
|
|
||||||
{item.charges.map((charge, index) => {
|
|
||||||
const descriptor = describeCharge(charge);
|
|
||||||
if (charge.amount > 0) {
|
|
||||||
return (
|
|
||||||
<div key={`${item.id}-charge-${index}`} className="flex items-baseline gap-1">
|
|
||||||
<span className="text-base font-semibold text-gray-900">
|
|
||||||
{yenFormatter.format(charge.amount)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium text-gray-500">{descriptor}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${item.id}-charge-${index}`}
|
|
||||||
className="flex items-baseline gap-1 text-sm font-medium text-gray-500"
|
|
||||||
>
|
|
||||||
Included
|
|
||||||
<span className="text-xs text-gray-400">{descriptor}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showFeeNotice && (
|
|
||||||
<div className="rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-amber-900">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-500" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-semibold">Additional fees may apply</p>
|
|
||||||
<p className="text-xs leading-relaxed">
|
|
||||||
Weekend installation, express setup, or specialised configuration work can
|
|
||||||
add extra costs. We'll always confirm with you before applying any
|
|
||||||
additional charges.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{statusDescriptor && (
|
{statusDescriptor && (
|
||||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-blue-100 bg-blue-50/60 px-3 py-3 text-sm text-blue-900">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
|
||||||
<div className="space-y-2">
|
<span className="font-semibold text-blue-900">{statusDescriptor.description}</span>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
Status
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-semibold text-gray-900">
|
|
||||||
{statusDescriptor.description}
|
|
||||||
</p>
|
|
||||||
{statusDescriptor.timeline && (
|
{statusDescriptor.timeline && (
|
||||||
<p className="text-sm text-gray-600">
|
<span className="flex items-center gap-1 text-xs text-blue-800">
|
||||||
<span className="font-medium text-gray-700">Timeline: </span>
|
<ClockIcon className="h-4 w-4" aria-hidden />
|
||||||
{statusDescriptor.timeline}
|
{statusDescriptor.timeline}
|
||||||
</p>
|
</span>
|
||||||
|
)}
|
||||||
|
{statusDescriptor.nextAction && (
|
||||||
|
<span className="text-xs text-blue-700">
|
||||||
|
Next: {statusDescriptor.nextAction}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
|
)}
|
||||||
</div>
|
</div>
|
||||||
{statusDescriptor.nextAction && (
|
|
||||||
<div className="mt-4 rounded-2xl border border-blue-100 bg-white p-4">
|
{/* Pricing Section */}
|
||||||
<p className="text-sm font-semibold text-blue-900">Next steps</p>
|
<div className="flex items-center gap-6">
|
||||||
<p className="mt-1 text-sm text-blue-800">{statusDescriptor.nextAction}</p>
|
{totals.monthlyTotal > 0 && (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-blue-600">Monthly</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900">
|
||||||
|
{yenFormatter.format(totals.monthlyTotal)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totals.oneTimeTotal > 0 && (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-blue-600">One-Time</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{yenFormatter.format(totals.oneTimeTotal)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-6 sm:px-8">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{/* Status Section */}
|
||||||
|
{/* Order Items Section */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-700">Order Details</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{displayItems.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-gray-500">
|
||||||
|
No items found on this order.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
displayItems.map((item, itemIndex) => {
|
||||||
|
const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
|
||||||
|
const Icon = categoryConfig.icon;
|
||||||
|
const prevItem = itemIndex > 0 ? displayItems[itemIndex - 1] : null;
|
||||||
|
const showBundleStart = item.isBundle && (!prevItem || !prevItem.isBundle);
|
||||||
|
const style = getItemVisualStyle(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id}>
|
||||||
|
{showBundleStart && (
|
||||||
|
<div className="mb-2 flex items-center gap-2 px-1">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider text-purple-600">
|
||||||
|
Bundled
|
||||||
|
</span>
|
||||||
|
<div className="h-px flex-1 bg-purple-200"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between",
|
||||||
|
style.container
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 items-start gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
|
||||||
|
style.icon
|
||||||
|
)}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h4 className="font-semibold text-gray-900">
|
||||||
|
{item.name}
|
||||||
|
</h4>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500">
|
||||||
|
{categoryConfig.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start gap-1 text-sm text-gray-600 sm:items-end sm:text-right">
|
||||||
|
{item.charges.map((charge, index) => {
|
||||||
|
const descriptor = describeCharge(charge);
|
||||||
|
if (charge.amount > 0) {
|
||||||
|
return (
|
||||||
|
<div key={`${item.id}-charge-${index}`} className="flex items-baseline gap-2">
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{yenFormatter.format(charge.amount)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">{descriptor}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.id}-charge-${index}`}
|
||||||
|
className="text-xs font-medium text-gray-500"
|
||||||
|
>
|
||||||
|
Included {descriptor}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFeeNotice && (
|
||||||
|
<div className="rounded-xl bg-amber-50 px-4 py-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-amber-900">Additional fees may apply</p>
|
||||||
|
<p className="mt-0.5 text-xs leading-relaxed text-amber-800">
|
||||||
|
Weekend installation, express setup, or specialised configuration work can
|
||||||
|
add extra costs. We'll always confirm with you before applying any
|
||||||
|
additional charges.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
import { logger } from "@customer-portal/logging";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export function NewSupportCaseView() {
|
export function NewSupportCaseView() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* API Helper Functions
|
|
||||||
* Generic utilities for working with API responses
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract data from API response or throw error
|
|
||||||
*/
|
|
||||||
export function getDataOrThrow<T>(
|
|
||||||
response: { data?: T; error?: unknown },
|
|
||||||
errorMessage: string
|
|
||||||
): T {
|
|
||||||
if (response.error || !response.data) {
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract data from API response or return default value
|
|
||||||
*/
|
|
||||||
export function getDataOrDefault<T>(response: { data?: T; error?: unknown }, defaultValue: T): T {
|
|
||||||
return response.data ?? defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if value is an API error
|
|
||||||
*/
|
|
||||||
export function isApiError(error: unknown): error is Error {
|
|
||||||
return error instanceof Error;
|
|
||||||
}
|
|
||||||
@ -12,7 +12,7 @@ export { ApiError, isApiError } from "./runtime/client";
|
|||||||
export * from "./response-helpers";
|
export * from "./response-helpers";
|
||||||
|
|
||||||
// Import createClient for internal use
|
// Import createClient for internal use
|
||||||
import { createClient } from "./runtime/client";
|
import { createClient, ApiError } from "./runtime/client";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,7 +54,7 @@ async function handleApiError(response: Response): Promise<void> {
|
|||||||
// Ignore body parse errors
|
// Ignore body parse errors
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new (await import("./runtime/client")).ApiError(message, response, body);
|
throw new ApiError(message, response, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = createClient({
|
export const apiClient = createClient({
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { currencyService, type CurrencyInfo } from "@/lib/services/currency.service";
|
import { currencyService, FALLBACK_CURRENCY } from "@/lib/services/currency.service";
|
||||||
|
import type { WhmcsCurrency } from "@customer-portal/domain/billing";
|
||||||
|
|
||||||
export function useCurrency() {
|
export function useCurrency() {
|
||||||
const [defaultCurrency, setDefaultCurrency] = useState<CurrencyInfo | null>(null);
|
const [defaultCurrency, setDefaultCurrency] = useState<WhmcsCurrency | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -18,13 +19,7 @@ export function useCurrency() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load currency");
|
setError(err instanceof Error ? err.message : "Failed to load currency");
|
||||||
// Fallback to JPY if API fails
|
// Fallback to JPY if API fails
|
||||||
setDefaultCurrency({
|
setDefaultCurrency(FALLBACK_CURRENCY);
|
||||||
code: "JPY",
|
|
||||||
prefix: "¥",
|
|
||||||
suffix: "",
|
|
||||||
format: "1",
|
|
||||||
rate: "1.00000",
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import logger from "@customer-portal/logging";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing localStorage with SSR safety
|
* Hook for managing localStorage with SSR safety
|
||||||
|
|||||||
@ -74,3 +74,11 @@ class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const logger = new Logger();
|
export const logger = new Logger();
|
||||||
|
|
||||||
|
export const log = {
|
||||||
|
info: (message: string, meta?: LogMeta) => logger.info(message, meta),
|
||||||
|
warn: (message: string, meta?: LogMeta) => logger.warn(message, meta),
|
||||||
|
error: (message: string, error?: unknown, meta?: LogMeta) =>
|
||||||
|
logger.error(message, error, meta),
|
||||||
|
debug: (message: string, meta?: LogMeta) => logger.debug(message, meta),
|
||||||
|
};
|
||||||
|
|||||||
@ -1,34 +1,23 @@
|
|||||||
import { apiClient } from "@/lib/api";
|
import { apiClient, getDataOrThrow } from "@/lib/api";
|
||||||
|
import type { WhmcsCurrency } from "@customer-portal/domain/billing";
|
||||||
|
|
||||||
export interface CurrencyInfo {
|
export const FALLBACK_CURRENCY: WhmcsCurrency = {
|
||||||
code: string;
|
id: 1,
|
||||||
prefix: string;
|
code: "JPY",
|
||||||
suffix: string;
|
prefix: "¥",
|
||||||
format: string;
|
suffix: "",
|
||||||
rate: string;
|
format: "1",
|
||||||
}
|
rate: "1.00000",
|
||||||
|
};
|
||||||
|
|
||||||
export interface CurrencyService {
|
export const currencyService = {
|
||||||
getDefaultCurrency(): Promise<CurrencyInfo>;
|
async getDefaultCurrency(): Promise<WhmcsCurrency> {
|
||||||
getAllCurrencies(): Promise<CurrencyInfo[]>;
|
const response = await apiClient.GET<WhmcsCurrency>("/api/currency/default");
|
||||||
}
|
return getDataOrThrow(response, "Failed to get default currency");
|
||||||
|
},
|
||||||
|
|
||||||
class CurrencyServiceImpl implements CurrencyService {
|
async getAllCurrencies(): Promise<WhmcsCurrency[]> {
|
||||||
async getDefaultCurrency(): Promise<CurrencyInfo> {
|
const response = await apiClient.GET<WhmcsCurrency[]>("/api/currency/all");
|
||||||
const response = await apiClient.GET("/api/currency/default");
|
return getDataOrThrow(response, "Failed to get currencies");
|
||||||
if (!response.data) {
|
},
|
||||||
throw new Error("Failed to get default currency");
|
};
|
||||||
}
|
|
||||||
return response.data as CurrencyInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllCurrencies(): Promise<CurrencyInfo[]> {
|
|
||||||
const response = await apiClient.GET("/api/currency/all");
|
|
||||||
if (!response.data) {
|
|
||||||
throw new Error("Failed to get currencies");
|
|
||||||
}
|
|
||||||
return response.data as CurrencyInfo[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const currencyService = new CurrencyServiceImpl();
|
|
||||||
|
|||||||
@ -18,8 +18,6 @@
|
|||||||
"@/lib/*": ["./src/lib/*"],
|
"@/lib/*": ["./src/lib/*"],
|
||||||
"@customer-portal/domain": ["../../packages/domain/index.ts"],
|
"@customer-portal/domain": ["../../packages/domain/index.ts"],
|
||||||
"@customer-portal/domain/*": ["../../packages/domain/*"],
|
"@customer-portal/domain/*": ["../../packages/domain/*"],
|
||||||
"@customer-portal/logging": ["../../packages/logging/src"],
|
|
||||||
"@customer-portal/logging/*": ["../../packages/logging/src/*"],
|
|
||||||
"@customer-portal/validation": ["../../packages/validation/src"],
|
"@customer-portal/validation": ["../../packages/validation/src"],
|
||||||
"@customer-portal/validation/*": ["../../packages/validation/src/*"]
|
"@customer-portal/validation/*": ["../../packages/validation/src/*"]
|
||||||
},
|
},
|
||||||
|
|||||||
21
env/portal-backend.env.sample
vendored
21
env/portal-backend.env.sample
vendored
@ -25,6 +25,12 @@ JWT_SECRET=CHANGE_ME
|
|||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
# CSRF Protection
|
||||||
|
CSRF_TOKEN_EXPIRY=3600000
|
||||||
|
CSRF_SECRET_KEY=CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG
|
||||||
|
CSRF_COOKIE_NAME=csrf-secret
|
||||||
|
CSRF_HEADER_NAME=X-CSRF-Token
|
||||||
|
|
||||||
# CORS / Proxy
|
# CORS / Proxy
|
||||||
CORS_ORIGIN=https://asolutions.jp
|
CORS_ORIGIN=https://asolutions.jp
|
||||||
TRUST_PROXY=true
|
TRUST_PROXY=true
|
||||||
@ -34,6 +40,21 @@ RATE_LIMIT_TTL=60
|
|||||||
RATE_LIMIT_LIMIT=100
|
RATE_LIMIT_LIMIT=100
|
||||||
AUTH_RATE_LIMIT_TTL=900
|
AUTH_RATE_LIMIT_TTL=900
|
||||||
AUTH_RATE_LIMIT_LIMIT=3
|
AUTH_RATE_LIMIT_LIMIT=3
|
||||||
|
AUTH_REFRESH_RATE_LIMIT_TTL=300
|
||||||
|
AUTH_REFRESH_RATE_LIMIT_LIMIT=10
|
||||||
|
LOGIN_RATE_LIMIT_TTL=900
|
||||||
|
LOGIN_RATE_LIMIT_LIMIT=5
|
||||||
|
LOGIN_CAPTCHA_AFTER_ATTEMPTS=3
|
||||||
|
SIGNUP_RATE_LIMIT_TTL=900
|
||||||
|
SIGNUP_RATE_LIMIT_LIMIT=5
|
||||||
|
PASSWORD_RESET_RATE_LIMIT_TTL=900
|
||||||
|
PASSWORD_RESET_RATE_LIMIT_LIMIT=5
|
||||||
|
|
||||||
|
# CAPTCHA Configuration
|
||||||
|
AUTH_CAPTCHA_PROVIDER=none
|
||||||
|
AUTH_CAPTCHA_SECRET=
|
||||||
|
AUTH_CAPTCHA_THRESHOLD=0
|
||||||
|
AUTH_CAPTCHA_ALWAYS_ON=false
|
||||||
|
|
||||||
# Validation error visibility (set true to show field-level errors to clients)
|
# Validation error visibility (set true to show field-level errors to clients)
|
||||||
EXPOSE_VALIDATION_ERRORS=false
|
EXPOSE_VALIDATION_ERRORS=false
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"dev": "tsc -w --preserveWatchOutput",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"type-check": "NODE_OPTIONS=\"--max-old-space-size=2048 --max-semi-space-size=128\" tsc --project tsconfig.json --noEmit",
|
"type-check": "NODE_OPTIONS=\"--max-old-space-size=2048 --max-semi-space-size=128\" tsc --project tsconfig.json --noEmit",
|
||||||
"typecheck": "pnpm run type-check"
|
"typecheck": "pnpm run type-check"
|
||||||
|
|||||||
@ -357,7 +357,11 @@ start_apps() {
|
|||||||
# Prisma Studio can be started manually with: pnpm db:studio
|
# Prisma Studio can be started manually with: pnpm db:studio
|
||||||
|
|
||||||
# Run portal + bff in parallel with hot reload
|
# Run portal + bff in parallel with hot reload
|
||||||
pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev
|
pnpm --parallel \
|
||||||
|
--filter @customer-portal/domain \
|
||||||
|
--filter @customer-portal/validation \
|
||||||
|
--filter @customer-portal/portal \
|
||||||
|
--filter @customer-portal/bff run dev
|
||||||
}
|
}
|
||||||
|
|
||||||
reset_env() {
|
reset_env() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user