Update security hardening and prune test/auth tooling
- Remove Passport-based auth; use jose-only guards - Remove Jest/Istanbul toolchain and switch to node --test - Stop runtime prisma dlx downloads; run migrations via bundled prisma - Remove glob override and tighten Next.js config
This commit is contained in:
parent
8b1b402814
commit
5981ed941e
@ -1,10 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm lint-staged
|
||||
pnpm type-check
|
||||
|
||||
echo "Running security audit..."
|
||||
if ! pnpm audit --audit-level=high > /dev/null 2>&1; then
|
||||
echo "High or critical security vulnerabilities detected!"
|
||||
echo "Run 'pnpm audit' to see details."
|
||||
fi
|
||||
# Security audit is enforced in CI (`.github/workflows/security.yml`).
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
|
||||
ARG NODE_VERSION=22
|
||||
ARG PNPM_VERSION=10.25.0
|
||||
ARG PRISMA_VERSION=7.1.0
|
||||
|
||||
# =============================================================================
|
||||
# Stage 1: Dependencies (cached layer)
|
||||
@ -40,8 +39,6 @@ RUN --mount=type=cache,id=pnpm-bff,target=/root/.local/share/pnpm/store \
|
||||
# =============================================================================
|
||||
FROM deps AS builder
|
||||
|
||||
ARG PRISMA_VERSION
|
||||
|
||||
# Copy source files
|
||||
COPY tsconfig.json tsconfig.base.json ./
|
||||
COPY packages/domain/ ./packages/domain/
|
||||
@ -62,7 +59,7 @@ RUN pnpm deploy --filter @customer-portal/bff --prod /app/deploy \
|
||||
|
||||
# Regenerate Prisma client in the flattened deploy layout so embedded schema path matches production
|
||||
WORKDIR /app/deploy
|
||||
RUN pnpm dlx prisma@${PRISMA_VERSION} generate --schema=prisma/schema.prisma
|
||||
RUN pnpm exec prisma generate --schema=prisma/schema.prisma
|
||||
WORKDIR /app
|
||||
|
||||
# =============================================================================
|
||||
@ -71,7 +68,6 @@ WORKDIR /app
|
||||
FROM node:${NODE_VERSION}-alpine AS production
|
||||
|
||||
ARG PNPM_VERSION
|
||||
ARG PRISMA_VERSION
|
||||
|
||||
LABEL org.opencontainers.image.title="Customer Portal BFF" \
|
||||
org.opencontainers.image.description="NestJS Backend-for-Frontend API" \
|
||||
@ -109,7 +105,6 @@ EXPOSE 4000
|
||||
# Environment configuration
|
||||
ENV NODE_ENV=production \
|
||||
PORT=4000 \
|
||||
PRISMA_VERSION=${PRISMA_VERSION} \
|
||||
# Node.js production optimizations
|
||||
NODE_OPTIONS="--max-old-space-size=512"
|
||||
|
||||
|
||||
@ -16,11 +16,7 @@
|
||||
"start:prod": "node dist/main.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
||||
"test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch",
|
||||
"test:cov": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage",
|
||||
"test:debug": "NODE_OPTIONS='--experimental-vm-modules --inspect-brk' node node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json",
|
||||
"test": "node --test",
|
||||
"type-check": "tsc --project tsconfig.json --noEmit",
|
||||
"type-check:watch": "tsc --project tsconfig.json --noEmit --watch",
|
||||
"clean": "rm -rf dist tsconfig.build.tsbuildinfo",
|
||||
@ -36,7 +32,6 @@
|
||||
"@nestjs/common": "^11.1.9",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.9",
|
||||
"@prisma/adapter-pg": "^7.1.0",
|
||||
"@prisma/client": "^7.1.0",
|
||||
@ -51,10 +46,8 @@
|
||||
"nestjs-pino": "^4.5.0",
|
||||
"nestjs-zod": "^5.0.1",
|
||||
"p-queue": "^9.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.16.3",
|
||||
"prisma": "^7.1.0",
|
||||
"rate-limiter-flexible": "^9.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
@ -68,37 +61,10 @@
|
||||
"@nestjs/testing": "^11.1.9",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/ssh2-sftp-client": "^9.0.6",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"jest": "^30.2.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^7.1.0",
|
||||
"supertest": "^7.1.4",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"maxWorkers": "50%",
|
||||
"extensionsToTreatAsEsm": [".ts"],
|
||||
"transform": {
|
||||
"^.+\\.ts$": ["ts-jest", { "useESM": true }]
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.ts", "!**/*.spec.ts", "!**/node_modules/**"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||
"^@/(.*)$": "<rootDir>/$1",
|
||||
"^@bff/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,6 @@ echo "🚀 Starting Customer Portal Backend..."
|
||||
echo " Version: ${APP_VERSION:-unknown}"
|
||||
echo " Node: $(node --version)"
|
||||
|
||||
PRISMA_VERSION="${PRISMA_VERSION:-7.1.0}"
|
||||
export PRISMA_SCHEMA_PATH="/app/prisma/schema.prisma"
|
||||
|
||||
# =============================================================================
|
||||
@ -95,7 +94,7 @@ if [ "$RUN_MIGRATIONS" = "true" ] && [ -n "$DATABASE_URL" ]; then
|
||||
# Change to app directory where schema is located
|
||||
cd /app
|
||||
|
||||
if pnpm dlx prisma@"${PRISMA_VERSION}" migrate deploy; then
|
||||
if pnpm exec prisma migrate deploy --schema=prisma/schema.prisma; then
|
||||
echo "✅ Migrations complete"
|
||||
else
|
||||
echo "⚠️ Migration failed - check database connectivity"
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
import type { Response } from "express";
|
||||
import type { Logger } from "nestjs-pino";
|
||||
import { CsrfController } from "./csrf.controller.js";
|
||||
import type { AuthenticatedRequest } from "./csrf.controller.js";
|
||||
import type { CsrfService, CsrfTokenData } from "../services/csrf.service.js";
|
||||
|
||||
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 AuthenticatedRequest;
|
||||
|
||||
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 AuthenticatedRequest;
|
||||
|
||||
controller.refreshCsrfToken(req, res);
|
||||
|
||||
expect(csrfService.generateToken).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
"cookie-session",
|
||||
"anonymous"
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,92 +0,0 @@
|
||||
import type { Logger } from "nestjs-pino";
|
||||
import type { ConfigService } from "@nestjs/config";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service.js";
|
||||
import type { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service.js";
|
||||
|
||||
describe("SalesforceConnection", () => {
|
||||
const createService = () => {
|
||||
const configService = {
|
||||
get: jest.fn(),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
const execute = jest.fn<
|
||||
Promise<unknown>,
|
||||
[fn: () => Promise<unknown>, options?: Record<string, unknown>]
|
||||
>(async (fn): Promise<unknown> => {
|
||||
return await fn();
|
||||
});
|
||||
|
||||
const executeHighPriority = jest.fn<
|
||||
Promise<unknown>,
|
||||
[fn: () => Promise<unknown>, options?: Record<string, unknown>]
|
||||
>(async (fn): Promise<unknown> => {
|
||||
return await fn();
|
||||
});
|
||||
|
||||
const requestQueue = {
|
||||
execute,
|
||||
executeHighPriority,
|
||||
} as unknown as SalesforceRequestQueueService;
|
||||
|
||||
const logger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
log: jest.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
const service = new SalesforceConnection(configService, requestQueue, 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,
|
||||
execute,
|
||||
executeHighPriority,
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("routes standard queries through the request queue with derived priority metadata", async () => {
|
||||
const { service, execute, queryMock } = createService();
|
||||
|
||||
await service.query("SELECT Id FROM Account WHERE Id = '001'");
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
const [, options] = execute.mock.calls[0];
|
||||
expect(options).toMatchObject({
|
||||
priority: 8,
|
||||
isLongRunning: false,
|
||||
label: "salesforce:query:account",
|
||||
});
|
||||
expect(queryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes SObject create operations through the high-priority queue", async () => {
|
||||
const { service, executeHighPriority } = createService();
|
||||
|
||||
const sobject = service.sobject("Order");
|
||||
await sobject.create({ Name: "Test" });
|
||||
|
||||
expect(executeHighPriority).toHaveBeenCalledTimes(1);
|
||||
const [, options] = executeHighPriority.mock.calls[0];
|
||||
expect(options).toMatchObject({ label: "salesforce:sobject:Order:create" });
|
||||
});
|
||||
});
|
||||
@ -9,6 +9,14 @@ export interface SftpConfig {
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
/**
|
||||
* Optional SHA256 host key fingerprint for MITM protection.
|
||||
* Set via env: SFTP_HOST_KEY_SHA256
|
||||
*
|
||||
* The value is compared against ssh2's `hostHash: "sha256"` output.
|
||||
* If you paste an OpenSSH-style fingerprint like "SHA256:xxxx", the "SHA256:" prefix is ignored.
|
||||
*/
|
||||
hostKeySha256?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@ -26,6 +34,7 @@ export class SftpClientService implements OnModuleDestroy {
|
||||
port: this.configService.get<number>("SFTP_PORT") || 22,
|
||||
username: this.configService.get<string>("SFTP_USERNAME") || "PASI",
|
||||
password: this.configService.get<string>("SFTP_PASSWORD") || "",
|
||||
hostKeySha256: this.configService.get<string>("SFTP_HOST_KEY_SHA256") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@ -43,11 +52,34 @@ export class SftpClientService implements OnModuleDestroy {
|
||||
|
||||
try {
|
||||
this.logger.log(`Connecting to SFTP: ${config.host}:${config.port}`);
|
||||
|
||||
const expectedHostKey = config.hostKeySha256?.trim().replace(/^SHA256:/, "");
|
||||
if (!expectedHostKey && this.configService.get("NODE_ENV") === "production") {
|
||||
this.logger.warn(
|
||||
"SFTP_HOST_KEY_SHA256 not configured; host key will not be verified (MITM risk)"
|
||||
);
|
||||
}
|
||||
|
||||
await this.client.connect({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
...(expectedHostKey
|
||||
? {
|
||||
hostHash: "sha256",
|
||||
hostVerifier: (hash: string) => {
|
||||
const ok = hash === expectedHostKey;
|
||||
if (!ok) {
|
||||
this.logger.error(
|
||||
{ host: config.host, expectedHostKey, actualHostKey: hash },
|
||||
"SFTP host key fingerprint mismatch"
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
this.logger.log(`Connected to SFTP: ${config.host}`);
|
||||
return this.client;
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { AuthFacade } from "./application/auth.facade.js";
|
||||
import { AuthController } from "./presentation/http/auth.controller.js";
|
||||
import { UsersModule } from "@bff/modules/users/users.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
|
||||
import { JwtStrategy } from "./presentation/strategies/jwt.strategy.js";
|
||||
import { LocalStrategy } from "./presentation/strategies/local.strategy.js";
|
||||
import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard.js";
|
||||
import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js";
|
||||
import { EmailModule } from "@bff/infra/email/email.module.js";
|
||||
@ -22,19 +19,10 @@ import { LoginResultInterceptor } from "./presentation/http/interceptors/login-r
|
||||
import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
UsersModule,
|
||||
MappingsModule,
|
||||
IntegrationsModule,
|
||||
EmailModule,
|
||||
CacheModule,
|
||||
],
|
||||
imports: [UsersModule, MappingsModule, IntegrationsModule, EmailModule, CacheModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthFacade,
|
||||
JwtStrategy,
|
||||
LocalStrategy,
|
||||
TokenBlacklistService,
|
||||
AuthTokenService,
|
||||
JoseJwtService,
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import { Injectable, UnauthorizedException, Logger } from "@nestjs/common";
|
||||
import type { CanActivate, ExecutionContext } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ExtractJwt } from "passport-jwt";
|
||||
|
||||
import type { Request } from "express";
|
||||
|
||||
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js";
|
||||
import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||
import type { UserAuth } from "@customer-portal/domain/customer";
|
||||
|
||||
type CookieValue = string | undefined;
|
||||
type RequestBase = Omit<Request, "cookies" | "route">;
|
||||
@ -21,28 +23,30 @@ type RequestWithRoute = RequestWithCookies & {
|
||||
route?: { path?: string };
|
||||
};
|
||||
|
||||
const headerExtractor = ExtractJwt.fromAuthHeaderAsBearerToken();
|
||||
const extractTokenFromRequest = (request: RequestWithCookies): string | undefined => {
|
||||
const headerToken = headerExtractor(request);
|
||||
if (headerToken) {
|
||||
return headerToken;
|
||||
}
|
||||
const rawHeader = (request as unknown as { headers?: Record<string, unknown> }).headers?.[
|
||||
"authorization"
|
||||
];
|
||||
const authHeader = typeof rawHeader === "string" ? rawHeader : undefined;
|
||||
const headerToken =
|
||||
authHeader && authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : undefined;
|
||||
if (headerToken && headerToken.length > 0) return headerToken;
|
||||
const cookieToken = request.cookies?.access_token;
|
||||
return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
||||
export class GlobalAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(GlobalAuthGuard.name);
|
||||
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private tokenBlacklistService: TokenBlacklistService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
private readonly tokenBlacklistService: TokenBlacklistService,
|
||||
private readonly jwtService: JoseJwtService,
|
||||
private readonly usersFacade: UsersFacade
|
||||
) {}
|
||||
|
||||
override async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = this.getRequest(context);
|
||||
const route = `${request.method} ${request.url}`;
|
||||
const isLogoutRoute = this.isLogoutRoute(request);
|
||||
@ -59,28 +63,50 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
||||
}
|
||||
|
||||
try {
|
||||
// First, run the standard JWT authentication
|
||||
const canActivate = await super.canActivate(context);
|
||||
if (!canActivate) {
|
||||
const token = extractTokenFromRequest(request);
|
||||
if (!token) {
|
||||
if (isLogoutRoute) {
|
||||
this.logger.debug(`Allowing logout request without active session: ${route}`);
|
||||
return true;
|
||||
}
|
||||
this.logger.warn(`JWT authentication failed for route: ${route}`);
|
||||
return false;
|
||||
throw new UnauthorizedException("Missing token");
|
||||
}
|
||||
|
||||
const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>(
|
||||
token
|
||||
);
|
||||
|
||||
if (!payload.sub || !payload.email) {
|
||||
throw new UnauthorizedException("Invalid token payload");
|
||||
}
|
||||
|
||||
// Explicit expiry buffer check to avoid tokens expiring mid-request
|
||||
if (typeof payload.exp !== "number") {
|
||||
throw new UnauthorizedException("Token missing expiration claim");
|
||||
}
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < nowSeconds + 60) {
|
||||
throw new UnauthorizedException("Token expired or expiring soon");
|
||||
}
|
||||
|
||||
// Then check token blacklist
|
||||
const token = extractTokenFromRequest(request);
|
||||
|
||||
if (token) {
|
||||
const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token);
|
||||
if (isBlacklisted) {
|
||||
this.logger.warn(`Blacklisted token attempted access to: ${route}`);
|
||||
throw new UnauthorizedException("Token has been revoked");
|
||||
}
|
||||
const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token);
|
||||
if (isBlacklisted) {
|
||||
this.logger.warn(`Blacklisted token attempted access to: ${route}`);
|
||||
throw new UnauthorizedException("Token has been revoked");
|
||||
}
|
||||
|
||||
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
|
||||
if (!prismaUser) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
}
|
||||
if (prismaUser.email !== payload.email) {
|
||||
throw new UnauthorizedException("Token subject does not match user record");
|
||||
}
|
||||
|
||||
const profile: UserAuth = mapPrismaUserToDomain(prismaUser);
|
||||
(request as RequestWithRoute & { user?: UserAuth }).user = profile;
|
||||
|
||||
this.logger.debug(`Authenticated access to: ${route}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@ -101,7 +127,7 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
override getRequest(context: ExecutionContext): RequestWithRoute {
|
||||
getRequest(context: ExecutionContext): RequestWithRoute {
|
||||
const rawRequest = context.switchToHttp().getRequest<unknown>();
|
||||
if (!this.isRequestWithRoute(rawRequest)) {
|
||||
this.logger.error("Unable to determine HTTP request in auth guard");
|
||||
|
||||
@ -1,5 +1,31 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import type { CanActivate, ExecutionContext } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { AuthFacade } from "@bff/modules/auth/application/auth.facade.js";
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard("local") {}
|
||||
export class LocalAuthGuard implements CanActivate {
|
||||
constructor(private readonly authFacade: AuthFacade) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const body = (request.body ?? {}) as Record<string, unknown>;
|
||||
|
||||
const email = typeof body.email === "string" ? body.email : "";
|
||||
const password = typeof body.password === "string" ? body.password : "";
|
||||
|
||||
if (!email || !password) {
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
}
|
||||
|
||||
const user = await this.authFacade.validateUser(email, password, request);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
}
|
||||
|
||||
// Attach user to request (replaces Passport's req.user behavior)
|
||||
(request as Request & { user: unknown }).user = user;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { UserAuth } from "@customer-portal/domain/customer";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||
import type { Request } from "express";
|
||||
|
||||
const cookieExtractor = (req: Request): string | null => {
|
||||
const cookieSource: unknown = Reflect.get(req, "cookies");
|
||||
if (!cookieSource || typeof cookieSource !== "object") {
|
||||
return null;
|
||||
}
|
||||
const token = Reflect.get(cookieSource, "access_token") as unknown;
|
||||
return typeof token === "string" && token.length > 0 ? token : null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private readonly usersFacade: UsersFacade
|
||||
) {
|
||||
const jwtSecret = configService.get<string>("JWT_SECRET");
|
||||
if (!jwtSecret) {
|
||||
throw new Error("JWT_SECRET is required in environment variables");
|
||||
}
|
||||
|
||||
const options = {
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
cookieExtractor,
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: jwtSecret,
|
||||
};
|
||||
|
||||
super(options);
|
||||
}
|
||||
|
||||
async validate(payload: {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}): Promise<UserAuth> {
|
||||
// Validate payload structure
|
||||
if (!payload.sub || !payload.email) {
|
||||
throw new UnauthorizedException("Invalid JWT payload");
|
||||
}
|
||||
|
||||
// Explicit expiry check with 60-second buffer to prevent edge cases
|
||||
// where tokens expire during request processing
|
||||
if (payload.exp) {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const bufferSeconds = 60; // 1 minute buffer
|
||||
|
||||
if (payload.exp < nowSeconds + bufferSeconds) {
|
||||
throw new UnauthorizedException("Token expired or expiring soon");
|
||||
}
|
||||
} else {
|
||||
// Tokens without expiry are not allowed
|
||||
throw new UnauthorizedException("Token missing expiration claim");
|
||||
}
|
||||
|
||||
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
|
||||
|
||||
if (!prismaUser) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
}
|
||||
|
||||
if (prismaUser.email !== payload.email) {
|
||||
throw new UnauthorizedException("Token subject does not match user record");
|
||||
}
|
||||
|
||||
const profile = mapPrismaUserToDomain(prismaUser);
|
||||
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Strategy } from "passport-local";
|
||||
import type { Request } from "express";
|
||||
import { AuthFacade } from "@bff/modules/auth/application/auth.facade.js";
|
||||
import { ErrorCode, ErrorMessages } from "@customer-portal/domain/common";
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private authFacade: AuthFacade) {
|
||||
super({ usernameField: "email", passReqToCallback: true });
|
||||
}
|
||||
|
||||
async validate(
|
||||
req: Request,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ id: string; email: string; role: string }> {
|
||||
const user = await this.authFacade.validateUser(email, password, req);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException(ErrorMessages[ErrorCode.INVALID_CREDENTIALS]);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@ -1,287 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import type { Logger } from "nestjs-pino";
|
||||
import { CheckoutService } from "./checkout.service.js";
|
||||
import { ORDER_TYPE } from "@customer-portal/domain/orders";
|
||||
import type { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service.js";
|
||||
import type { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
|
||||
import type { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalog.service.js";
|
||||
|
||||
const createLogger = (): Logger =>
|
||||
({
|
||||
log: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}) as unknown as Logger;
|
||||
|
||||
const internetPlan = {
|
||||
id: "prod-1",
|
||||
sku: "PLAN-1",
|
||||
name: "Plan 1",
|
||||
description: "Plan 1",
|
||||
monthlyPrice: 1000,
|
||||
oneTimePrice: 0,
|
||||
} as unknown;
|
||||
|
||||
const simPlan = {
|
||||
id: "sim-1",
|
||||
sku: "SIM-PLAN-1",
|
||||
name: "SIM Plan",
|
||||
description: "SIM Plan",
|
||||
monthlyPrice: 4500,
|
||||
oneTimePrice: 0,
|
||||
} as unknown;
|
||||
|
||||
const defaultActivationFee = {
|
||||
id: "act-1",
|
||||
sku: "SIM-ACTIVATION-FEE",
|
||||
name: "SIM Activation Fee",
|
||||
description: "One-time fee",
|
||||
monthlyPrice: 0,
|
||||
oneTimePrice: 3000,
|
||||
catalogMetadata: {
|
||||
autoAdd: true,
|
||||
isDefault: true,
|
||||
},
|
||||
} as unknown;
|
||||
|
||||
const alternateActivationFee = {
|
||||
id: "act-2",
|
||||
sku: "SIM-ACTIVATION-PREMIUM",
|
||||
name: "SIM Premium Activation",
|
||||
description: "Premium activation",
|
||||
monthlyPrice: 0,
|
||||
oneTimePrice: 5000,
|
||||
catalogMetadata: {
|
||||
autoAdd: false,
|
||||
isDefault: false,
|
||||
},
|
||||
} as unknown;
|
||||
|
||||
const createService = ({
|
||||
internet,
|
||||
sim,
|
||||
vpn,
|
||||
}: {
|
||||
internet: Partial<InternetCatalogService>;
|
||||
sim: Partial<SimCatalogService>;
|
||||
vpn: Partial<VpnCatalogService>;
|
||||
}) =>
|
||||
new CheckoutService(
|
||||
createLogger(),
|
||||
internet as InternetCatalogService,
|
||||
sim as SimCatalogService,
|
||||
vpn as VpnCatalogService
|
||||
);
|
||||
|
||||
describe("CheckoutService - personalized carts", () => {
|
||||
it("uses personalized internet plans when userId is provided", async () => {
|
||||
const internetCatalogService = {
|
||||
getPlansForUser: jest.fn().mockResolvedValue([internetPlan]),
|
||||
getPlans: jest.fn(),
|
||||
getInstallations: jest.fn().mockResolvedValue([]),
|
||||
getAddons: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const service = createService({
|
||||
internet: internetCatalogService,
|
||||
sim: {
|
||||
getPlans: jest.fn(),
|
||||
getPlansForUser: jest.fn(),
|
||||
getAddons: jest.fn(),
|
||||
getActivationFees: jest.fn(),
|
||||
},
|
||||
vpn: {
|
||||
getPlans: jest.fn(),
|
||||
getActivationFees: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await service.buildCart(ORDER_TYPE.INTERNET, { planSku: "PLAN-1" }, undefined, "user-123");
|
||||
|
||||
expect(internetCatalogService.getPlansForUser).toHaveBeenCalledWith("user-123");
|
||||
expect(internetCatalogService.getPlans).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects plans that are not available to the user", async () => {
|
||||
const internetCatalogService = {
|
||||
getPlansForUser: jest.fn().mockResolvedValue([]),
|
||||
getPlans: jest.fn(),
|
||||
getInstallations: jest.fn().mockResolvedValue([]),
|
||||
getAddons: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const service = createService({
|
||||
internet: internetCatalogService,
|
||||
sim: {
|
||||
getPlans: jest.fn(),
|
||||
getPlansForUser: jest.fn(),
|
||||
getAddons: jest.fn(),
|
||||
getActivationFees: jest.fn(),
|
||||
},
|
||||
vpn: {
|
||||
getPlans: jest.fn(),
|
||||
getActivationFees: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.buildCart(ORDER_TYPE.INTERNET, { planSku: "UNKNOWN" }, undefined, "user-123")
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("falls back to shared catalog when userId is not provided", async () => {
|
||||
const internetCatalogService = {
|
||||
getPlansForUser: jest.fn(),
|
||||
getPlans: jest.fn().mockResolvedValue([internetPlan]),
|
||||
getInstallations: jest.fn().mockResolvedValue([]),
|
||||
getAddons: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const service = createService({
|
||||
internet: internetCatalogService,
|
||||
sim: {
|
||||
getPlans: jest.fn(),
|
||||
getPlansForUser: jest.fn(),
|
||||
getAddons: jest.fn(),
|
||||
getActivationFees: jest.fn(),
|
||||
},
|
||||
vpn: {
|
||||
getPlans: jest.fn(),
|
||||
getActivationFees: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await service.buildCart(ORDER_TYPE.INTERNET, { planSku: "PLAN-1" });
|
||||
|
||||
expect(internetCatalogService.getPlans).toHaveBeenCalledTimes(1);
|
||||
expect(internetCatalogService.getPlansForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CheckoutService - SIM activation fees", () => {
|
||||
const internetCatalogService = {
|
||||
getPlansForUser: jest.fn(),
|
||||
getPlans: jest.fn(),
|
||||
getInstallations: jest.fn(),
|
||||
getAddons: jest.fn(),
|
||||
};
|
||||
|
||||
const vpnCatalogService = {
|
||||
getPlans: jest.fn(),
|
||||
getActivationFees: jest.fn(),
|
||||
};
|
||||
|
||||
it("auto-adds default activation fee when none specified", async () => {
|
||||
const simCatalogService = {
|
||||
getPlans: jest.fn().mockResolvedValue([simPlan]),
|
||||
getPlansForUser: jest.fn(),
|
||||
getAddons: jest.fn().mockResolvedValue([]),
|
||||
getActivationFees: jest.fn().mockResolvedValue([defaultActivationFee]),
|
||||
};
|
||||
|
||||
const service = createService({
|
||||
internet: internetCatalogService,
|
||||
sim: simCatalogService,
|
||||
vpn: vpnCatalogService,
|
||||
});
|
||||
|
||||
const cart = await service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" });
|
||||
|
||||
expect(cart.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ sku: "SIM-PLAN-1" }),
|
||||
expect.objectContaining({
|
||||
sku: "SIM-ACTIVATION-FEE",
|
||||
itemType: "activation",
|
||||
autoAdded: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(cart.totals.oneTimeTotal).toBe(3000);
|
||||
});
|
||||
|
||||
it("respects explicit activation fee selection", async () => {
|
||||
const simCatalogService = {
|
||||
getPlans: jest.fn().mockResolvedValue([simPlan]),
|
||||
getPlansForUser: jest.fn(),
|
||||
getAddons: jest.fn().mockResolvedValue([]),
|
||||
getActivationFees: jest
|
||||
.fn()
|
||||
.mockResolvedValue([defaultActivationFee, alternateActivationFee]),
|
||||
};
|
||||
|
||||
const service = createService({
|
||||
internet: internetCatalogService,
|
||||
sim: simCatalogService,
|
||||
vpn: vpnCatalogService,
|
||||
});
|
||||
|
||||
const cart = await service.buildCart(ORDER_TYPE.SIM, {
|
||||
planSku: "SIM-PLAN-1",
|
||||
activationFeeSku: "SIM-ACTIVATION-PREMIUM",
|
||||
});
|
||||
|
||||
expect(cart.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
sku: "SIM-ACTIVATION-PREMIUM",
|
||||
autoAdded: false,
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(cart.totals.oneTimeTotal).toBe(5000);
|
||||
});
|
||||
|
||||
it("throws when no activation fee is available", async () => {
|
||||
const simCatalogService = {
|
||||
getPlans: jest.fn().mockResolvedValue([simPlan]),
|
||||
getPlansForUser: jest.fn(),
|
||||
getAddons: jest.fn().mockResolvedValue([]),
|
||||
getActivationFees: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const service = createService({
|
||||
internet: internetCatalogService,
|
||||
sim: simCatalogService,
|
||||
vpn: vpnCatalogService,
|
||||
});
|
||||
|
||||
await expect(service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" })).rejects.toThrow(
|
||||
"SIM activation fee is not available"
|
||||
);
|
||||
});
|
||||
|
||||
it("skips activation fees without SKUs and falls back to the next valid option", async () => {
|
||||
const simCatalogService = {
|
||||
getPlans: jest.fn().mockResolvedValue([simPlan]),
|
||||
getPlansForUser: jest.fn(),
|
||||
getAddons: jest.fn().mockResolvedValue([]),
|
||||
getActivationFees: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ ...(defaultActivationFee as Record<string, unknown>), sku: "" },
|
||||
alternateActivationFee,
|
||||
]),
|
||||
};
|
||||
|
||||
const service = createService({
|
||||
internet: internetCatalogService,
|
||||
sim: simCatalogService,
|
||||
vpn: vpnCatalogService,
|
||||
});
|
||||
|
||||
const cart = await service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" });
|
||||
|
||||
expect(cart.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
sku: "SIM-ACTIVATION-PREMIUM",
|
||||
itemType: "activation",
|
||||
autoAdded: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(cart.totals.oneTimeTotal).toBe(5000);
|
||||
});
|
||||
});
|
||||
@ -1,105 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import type { Logger } from "nestjs-pino";
|
||||
import { OrderOrchestrator } from "./order-orchestrator.service.js";
|
||||
import type { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
|
||||
import type { OrderValidator } from "./order-validator.service.js";
|
||||
import type { OrderBuilder } from "./order-builder.service.js";
|
||||
import type { OrderItemBuilder } from "./order-item-builder.service.js";
|
||||
import type { OrdersCacheService } from "./orders-cache.service.js";
|
||||
import type { OrderDetails } from "@customer-portal/domain/orders";
|
||||
|
||||
const buildLogger = (): Logger =>
|
||||
({
|
||||
log: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}) as unknown as Logger;
|
||||
|
||||
const createOrderDetails = (overrides: Partial<OrderDetails> = {}): OrderDetails => ({
|
||||
id: "006000000000000AAA",
|
||||
orderNumber: "O-123",
|
||||
status: "Open",
|
||||
effectiveDate: new Date().toISOString(),
|
||||
totalAmount: 100,
|
||||
createdDate: new Date().toISOString(),
|
||||
lastModifiedDate: new Date().toISOString(),
|
||||
activationStatus: "Pending",
|
||||
itemsSummary: [],
|
||||
items: [],
|
||||
accountId: "001000000000000AAA",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("OrderOrchestrator.getOrderForUser", () => {
|
||||
const logger = buildLogger();
|
||||
const salesforce = {} as SalesforceOrderService;
|
||||
const orderValidator = {
|
||||
validateUserMapping: jest.fn(),
|
||||
} as unknown as OrderValidator;
|
||||
const orderBuilder = {} as OrderBuilder;
|
||||
const orderItemBuilder = {} as OrderItemBuilder;
|
||||
const ordersCache = {} as OrdersCacheService;
|
||||
|
||||
const buildOrchestrator = () =>
|
||||
new OrderOrchestrator(
|
||||
logger,
|
||||
salesforce,
|
||||
orderValidator,
|
||||
orderBuilder,
|
||||
orderItemBuilder,
|
||||
ordersCache
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("returns the order when the Salesforce account matches the user mapping", async () => {
|
||||
const orchestrator = buildOrchestrator();
|
||||
const expectedOrder = createOrderDetails();
|
||||
|
||||
orderValidator.validateUserMapping = jest.fn().mockResolvedValue({
|
||||
userId: "user-1",
|
||||
sfAccountId: expectedOrder.accountId,
|
||||
whmcsClientId: 42,
|
||||
});
|
||||
const getOrderSpy = jest.spyOn(orchestrator, "getOrder").mockResolvedValue(expectedOrder);
|
||||
|
||||
const result = await orchestrator.getOrderForUser(expectedOrder.id, "user-1");
|
||||
|
||||
expect(result).toBe(expectedOrder);
|
||||
expect(getOrderSpy).toHaveBeenCalledWith(expectedOrder.id);
|
||||
});
|
||||
|
||||
it("throws NotFound when the user mapping lacks a Salesforce account", async () => {
|
||||
const orchestrator = buildOrchestrator();
|
||||
orderValidator.validateUserMapping = jest.fn().mockResolvedValue({
|
||||
userId: "user-1",
|
||||
whmcsClientId: 42,
|
||||
});
|
||||
const getOrderSpy = jest.spyOn(orchestrator, "getOrder");
|
||||
|
||||
await expect(orchestrator.getOrderForUser("006000000000000AAA", "user-1")).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
|
||||
expect(getOrderSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws NotFound when the order belongs to a different account", async () => {
|
||||
const orchestrator = buildOrchestrator();
|
||||
orderValidator.validateUserMapping = jest.fn().mockResolvedValue({
|
||||
userId: "user-1",
|
||||
sfAccountId: "001000000000000AAA",
|
||||
whmcsClientId: 42,
|
||||
});
|
||||
jest
|
||||
.spyOn(orchestrator, "getOrder")
|
||||
.mockResolvedValue(createOrderDetails({ accountId: "001000000000999ZZZ" }));
|
||||
|
||||
await expect(orchestrator.getOrderForUser("006000000000000AAA", "user-1")).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -13,8 +13,8 @@
|
||||
"@bff/*": ["src/*"]
|
||||
},
|
||||
"noEmit": true,
|
||||
"types": ["node", "jest"]
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"exclude": ["node_modules", "dist", "prisma"]
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "prisma", "test", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
|
||||
2
apps/portal/next-env.d.ts
vendored
2
apps/portal/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -29,10 +29,6 @@ const nextConfig = {
|
||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||
},
|
||||
|
||||
images: {
|
||||
remotePatterns: [{ protocol: "https", hostname: "**" }],
|
||||
},
|
||||
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
import { useWhmcsLink } from "@/features/auth/hooks";
|
||||
@ -33,7 +32,11 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
|
||||
|
||||
return (
|
||||
<form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
|
||||
<FormField label="Email Address" error={form.touched.email ? form.errors.email : undefined} required>
|
||||
<FormField
|
||||
label="Email Address"
|
||||
error={form.touched.email ? form.errors.email : undefined}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.values.email}
|
||||
@ -46,7 +49,11 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" error={form.touched.password ? form.errors.password : undefined} required>
|
||||
<FormField
|
||||
label="Password"
|
||||
error={form.touched.password ? form.errors.password : undefined}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.values.password}
|
||||
|
||||
@ -13,7 +13,7 @@ function formatFieldName(field: string): string {
|
||||
return field
|
||||
.replace("address.", "")
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (s) => s.toUpperCase())
|
||||
.replace(/^./, s => s.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
@ -89,7 +89,7 @@ interface ReviewStepProps {
|
||||
}
|
||||
|
||||
export function ReviewStep({ form }: ReviewStepProps) {
|
||||
const { values, errors, touched, setValue, setTouchedField } = form;
|
||||
const { values, errors, setValue, setTouchedField } = form;
|
||||
const address = values.address;
|
||||
|
||||
// Format address for display
|
||||
@ -130,7 +130,9 @@ export function ReviewStep({ form }: ReviewStepProps) {
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
<dt className="text-gray-500">Phone</dt>
|
||||
<dd className="text-gray-900 font-medium">{values.phoneCountryCode} {values.phone}</dd>
|
||||
<dd className="text-gray-900 font-medium">
|
||||
{values.phoneCountryCode} {values.phone}
|
||||
</dd>
|
||||
</div>
|
||||
{values.company && (
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
@ -162,7 +164,7 @@ export function ReviewStep({ form }: ReviewStepProps) {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={values.acceptTerms}
|
||||
onChange={(e) => setValue("acceptTerms", e.target.checked)}
|
||||
onChange={e => setValue("acceptTerms", e.target.checked)}
|
||||
onBlur={() => setTouchedField("acceptTerms")}
|
||||
className="mt-0.5 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
@ -194,14 +196,12 @@ export function ReviewStep({ form }: ReviewStepProps) {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={values.marketingConsent ?? false}
|
||||
onChange={(e) => setValue("marketingConsent", e.target.checked)}
|
||||
onChange={e => setValue("marketingConsent", e.target.checked)}
|
||||
className="mt-0.5 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Send me updates about new products and promotions
|
||||
<span className="block text-xs text-gray-500 mt-0.5">
|
||||
You can unsubscribe anytime
|
||||
</span>
|
||||
<span className="block text-xs text-gray-500 mt-0.5">You can unsubscribe anytime</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@ -19,10 +19,7 @@ interface ServiceManagementSectionProps {
|
||||
type ServiceKey = "SIM" | "INTERNET" | "NETGEAR" | "VPN";
|
||||
|
||||
// Inner component that uses useSearchParams
|
||||
function ServiceManagementContent({
|
||||
subscriptionId,
|
||||
productName,
|
||||
}: ServiceManagementSectionProps) {
|
||||
function ServiceManagementContent({ subscriptionId, productName }: ServiceManagementSectionProps) {
|
||||
const isSimService = useMemo(() => productName?.toLowerCase().includes("sim"), [productName]);
|
||||
|
||||
const [selectedService, setSelectedService] = useState<ServiceKey>(
|
||||
@ -132,8 +129,6 @@ function ServiceManagementContent({
|
||||
|
||||
// Wrapper component with Suspense boundary
|
||||
export function ServiceManagementSection(props: ServiceManagementSectionProps) {
|
||||
const isSimService = useMemo(() => props.productName?.toLowerCase().includes("sim"), [props.productName]);
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
|
||||
@ -147,30 +147,6 @@ export function SimDetailsCard({
|
||||
const usedGB = totalGB - remainingGB;
|
||||
const usagePercentage = (usedGB / totalGB) * 100;
|
||||
|
||||
// Usage Sparkline Component
|
||||
const UsageSparkline = ({ data }: { data: Array<{ date: string; usedMB: number }> }) => {
|
||||
const maxValue = Math.max(...data.map(d => d.usedMB), 1);
|
||||
const width = 80;
|
||||
const height = 16;
|
||||
|
||||
const points = data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
const y = height - (d.usedMB / maxValue) * height;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="text-blue-500">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
points={points}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Usage Donut Component
|
||||
const UsageDonut = ({ size = 120 }: { size?: number }) => {
|
||||
const radius = (size - 16) / 2;
|
||||
@ -250,7 +226,6 @@ export function SimDetailsCard({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -366,9 +341,7 @@ export function SimDetailsCard({
|
||||
{simDetails.internationalRoamingEnabled && (
|
||||
<div className="flex items-center">
|
||||
<WifiIcon className="h-4 w-4 mr-1 text-green-500" />
|
||||
<span className="text-sm text-green-600">
|
||||
International Roaming Enabled
|
||||
</span>
|
||||
<span className="text-sm text-green-600">International Roaming Enabled</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -381,9 +354,7 @@ export function SimDetailsCard({
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<div className="flex items-center text-sm">
|
||||
<ClockIcon className="h-4 w-4 text-amber-500 mr-2" />
|
||||
<span className="text-amber-800">
|
||||
Expires on {formatDate(simDetails.expiresAt)}
|
||||
</span>
|
||||
<span className="text-amber-800">Expires on {formatDate(simDetails.expiresAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
DevicePhoneMobileIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
PhoneIcon,
|
||||
@ -57,10 +56,12 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
|
||||
// Navigation handlers
|
||||
const navigateToTopUp = () => router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
|
||||
const navigateToChangePlan = () => router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
||||
const navigateToChangePlan = () =>
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
||||
const navigateToReissue = () => router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
|
||||
const navigateToCancel = () => router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
||||
const navigateToCallHistory = () => router.push(`/subscriptions/${subscriptionId}/sim/call-history`);
|
||||
const navigateToCallHistory = () =>
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/call-history`);
|
||||
|
||||
// Fetch subscription data
|
||||
const { data: subscription } = useSubscription(subscriptionId);
|
||||
@ -71,9 +72,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
|
||||
// SSO link mutation for payment
|
||||
const createSsoLink = useCreateInvoiceSsoLink({
|
||||
onSuccess: (data) => {
|
||||
onSuccess: data => {
|
||||
if (data.url) {
|
||||
window.open(data.url, '_blank');
|
||||
window.open(data.url, "_blank");
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -86,7 +87,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
const payload = response.data as { details: SimDetails; usage: any } | undefined;
|
||||
const payload = response.data as
|
||||
| { details: SimDetails; usage?: SimInfo["usage"] }
|
||||
| undefined;
|
||||
|
||||
if (!payload) {
|
||||
throw new Error("Failed to load SIM information");
|
||||
@ -94,7 +97,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
|
||||
setSimInfo({
|
||||
details: payload.details,
|
||||
usage: payload.usage
|
||||
usage: payload.usage,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const hasStatus = (v: unknown): v is { status: number } =>
|
||||
@ -121,13 +124,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
void fetchSimInfo();
|
||||
};
|
||||
|
||||
const handleActionSuccess = () => {
|
||||
void fetchSimInfo();
|
||||
};
|
||||
|
||||
const handlePayInvoice = () => {
|
||||
if (latestInvoice?.id) {
|
||||
createSsoLink.mutate({ invoiceId: latestInvoice.id, target: 'pay' });
|
||||
createSsoLink.mutate({ invoiceId: latestInvoice.id, target: "pay" });
|
||||
}
|
||||
};
|
||||
|
||||
@ -184,8 +183,8 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
const usedGB = simInfo.usage?.monthlyUsageMb
|
||||
? (simInfo.usage.monthlyUsageMb / 1000).toFixed(2)
|
||||
: simInfo.usage?.todayUsageMb
|
||||
? (simInfo.usage.todayUsageMb / 1000).toFixed(2)
|
||||
: "0.00";
|
||||
? (simInfo.usage.todayUsageMb / 1000).toFixed(2)
|
||||
: "0.00";
|
||||
|
||||
// Calculate percentage for circle
|
||||
const totalGB = parseFloat(remainingGB) + parseFloat(usedGB);
|
||||
@ -203,22 +202,27 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Monthly Cost: <span className="font-semibold text-gray-900">
|
||||
Monthly Cost:{" "}
|
||||
<span className="font-semibold text-gray-900">
|
||||
{subscription ? formatCurrency(subscription.amount) : "Loading..."}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Next Billing: <span className="font-semibold text-gray-900">
|
||||
Next Billing:{" "}
|
||||
<span className="font-semibold text-gray-900">
|
||||
{subscription?.nextDue ? formatDate(subscription.nextDue) : "N/A"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Registered: <span className="font-semibold text-gray-900">
|
||||
{subscription?.registrationDate ? formatDate(subscription.registrationDate) : "N/A"}
|
||||
Registered:{" "}
|
||||
<span className="font-semibold text-gray-900">
|
||||
{subscription?.registrationDate
|
||||
? formatDate(subscription.registrationDate)
|
||||
: "N/A"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@ -232,10 +236,14 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
</p>
|
||||
<button
|
||||
onClick={handlePayInvoice}
|
||||
disabled={createSsoLink.isPending || latestInvoice.status === 'Paid'}
|
||||
disabled={createSsoLink.isPending || latestInvoice.status === "Paid"}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{latestInvoice.status === 'Paid' ? 'PAID' : createSsoLink.isPending ? 'Loading...' : 'PAY'}
|
||||
{latestInvoice.status === "Paid"
|
||||
? "PAID"
|
||||
: createSsoLink.isPending
|
||||
? "Loading..."
|
||||
: "PAY"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@ -255,14 +263,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-48 h-48">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle
|
||||
cx="96"
|
||||
cy="96"
|
||||
r="88"
|
||||
fill="none"
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="12"
|
||||
/>
|
||||
<circle cx="96" cy="96" r="88" fill="none" stroke="#e5e7eb" strokeWidth="12" />
|
||||
<circle
|
||||
cx="96"
|
||||
cy="96"
|
||||
@ -356,7 +357,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Network Type</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
{simInfo.details.networkType ? simInfo.details.networkType : 'Disabled'}
|
||||
{simInfo.details.networkType ? simInfo.details.networkType : "Disabled"}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
@ -421,11 +422,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>Voice, network, and plan changes must be requested at least 30 minutes apart.</span>
|
||||
<span>
|
||||
Voice, network, and plan changes must be requested at least 30 minutes apart.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>Changes to Voice Mail / Call Waiting must be requested before the 25th of the month</span>
|
||||
<span>
|
||||
Changes to Voice Mail / Call Waiting must be requested before the 25th of the month
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { simInfoSchema, type SimInfo, type SimCancelFullRequest, type SimChangePlanFullRequest } from "@customer-portal/domain/sim";
|
||||
import {
|
||||
simInfoSchema,
|
||||
type SimInfo,
|
||||
type SimCancelFullRequest,
|
||||
type SimChangePlanFullRequest,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import type {
|
||||
SimTopUpRequest,
|
||||
SimPlanChangeRequest,
|
||||
SimCancelRequest,
|
||||
SimReissueRequest,
|
||||
} from "@customer-portal/domain/sim";
|
||||
|
||||
// Types imported from domain - no duplication
|
||||
@ -63,14 +67,18 @@ export const simActionsService = {
|
||||
});
|
||||
},
|
||||
|
||||
async changePlanFull(subscriptionId: string, request: SimChangePlanFullRequest): Promise<{ scheduledAt?: string }> {
|
||||
const response = await apiClient.POST<{ success: boolean; message: string; scheduledAt?: string }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/change-plan-full",
|
||||
{
|
||||
params: { path: { subscriptionId } },
|
||||
body: request,
|
||||
}
|
||||
);
|
||||
async changePlanFull(
|
||||
subscriptionId: string,
|
||||
request: SimChangePlanFullRequest
|
||||
): Promise<{ scheduledAt?: string }> {
|
||||
const response = await apiClient.POST<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
scheduledAt?: string;
|
||||
}>("/api/subscriptions/{subscriptionId}/sim/change-plan-full", {
|
||||
params: { path: { subscriptionId } },
|
||||
body: request,
|
||||
});
|
||||
return { scheduledAt: response.data?.scheduledAt };
|
||||
},
|
||||
|
||||
@ -160,12 +168,12 @@ export const simActionsService = {
|
||||
params.page = String(page);
|
||||
params.limit = String(limit);
|
||||
|
||||
const response = await apiClient.GET<{ success: boolean; data: InternationalCallHistoryResponse }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/call-history/international",
|
||||
{
|
||||
params: { path: { subscriptionId }, query: params },
|
||||
}
|
||||
);
|
||||
const response = await apiClient.GET<{
|
||||
success: boolean;
|
||||
data: InternationalCallHistoryResponse;
|
||||
}>("/api/subscriptions/{subscriptionId}/sim/call-history/international", {
|
||||
params: { path: { subscriptionId }, query: params },
|
||||
});
|
||||
return response.data?.data || null;
|
||||
},
|
||||
|
||||
|
||||
@ -6,12 +6,7 @@ import {
|
||||
ChatBubbleLeftRightIcon,
|
||||
SparklesIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
SUPPORT_CASE_STATUS,
|
||||
SUPPORT_CASE_PRIORITY,
|
||||
type SupportCaseStatus,
|
||||
type SupportCasePriority,
|
||||
} from "@customer-portal/domain/support";
|
||||
import { SUPPORT_CASE_STATUS, SUPPORT_CASE_PRIORITY } from "@customer-portal/domain/support";
|
||||
|
||||
/**
|
||||
* Status variant types for styling
|
||||
@ -33,13 +28,15 @@ const ICON_SIZE_CLASSES: Record<IconSize, string> = {
|
||||
* Status to icon mapping
|
||||
*/
|
||||
const STATUS_ICON_MAP: Record<string, (className: string) => ReactNode> = {
|
||||
[SUPPORT_CASE_STATUS.RESOLVED]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.CLOSED]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.VPN_PENDING]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.PENDING]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.IN_PROGRESS]: (cls) => <ClockIcon className={`${cls} text-blue-500`} />,
|
||||
[SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: (cls) => <ExclamationTriangleIcon className={`${cls} text-amber-500`} />,
|
||||
[SUPPORT_CASE_STATUS.NEW]: (cls) => <SparklesIcon className={`${cls} text-purple-500`} />,
|
||||
[SUPPORT_CASE_STATUS.RESOLVED]: cls => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.CLOSED]: cls => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.VPN_PENDING]: cls => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.PENDING]: cls => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.IN_PROGRESS]: cls => <ClockIcon className={`${cls} text-blue-500`} />,
|
||||
[SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: cls => (
|
||||
<ExclamationTriangleIcon className={`${cls} text-amber-500`} />
|
||||
),
|
||||
[SUPPORT_CASE_STATUS.NEW]: cls => <SparklesIcon className={`${cls} text-purple-500`} />,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -149,4 +146,3 @@ export function getCasePriorityClasses(priority: string, withBorder = false): st
|
||||
const variant = getCasePriorityVariant(priority);
|
||||
return withBorder ? PRIORITY_CLASSES_WITH_BORDER[variant] : PRIORITY_CLASSES[variant];
|
||||
}
|
||||
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
TagIcon,
|
||||
ArrowLeftIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { CalendarIcon, ClockIcon, TagIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { useSupportCase } from "@/features/support/hooks/useSupportCase";
|
||||
@ -77,16 +71,12 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
|
||||
<div className="p-5 border-b border-gray-100">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{getCaseStatusIcon(supportCase.status, "md")}
|
||||
</div>
|
||||
<div className="mt-0.5">{getCaseStatusIcon(supportCase.status, "md")}</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 leading-snug">
|
||||
{supportCase.subject}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Case #{supportCase.caseNumber}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Case #{supportCase.caseNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@ -112,7 +102,10 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<ClockIcon className="h-4 w-4 text-gray-400" />
|
||||
<span>Updated {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}</span>
|
||||
<span>
|
||||
Updated{" "}
|
||||
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
{supportCase.category && (
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
@ -145,12 +138,18 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium">Need to update this case?</p>
|
||||
<p className="mt-0.5 text-blue-600">Reply via email and your response will be added to this case automatically.</p>
|
||||
<p className="mt-0.5 text-blue-600">
|
||||
Reply via email and your response will be added to this case automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ChatBubbleLeftRightIcon,
|
||||
SparklesIcon,
|
||||
PlusIcon,
|
||||
ChevronRightIcon,
|
||||
@ -48,10 +47,7 @@ export function SupportHomeView() {
|
||||
<p className="text-sm text-blue-100 mb-4 leading-relaxed">
|
||||
Get instant answers to common questions about your account.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-white text-blue-600 hover:bg-blue-50 shadow-sm"
|
||||
>
|
||||
<Button size="sm" className="bg-white text-blue-600 hover:bg-blue-50 shadow-sm">
|
||||
Start Chat
|
||||
</Button>
|
||||
</div>
|
||||
@ -69,12 +65,7 @@ export function SupportHomeView() {
|
||||
<p className="text-sm text-gray-500 mb-4 leading-relaxed">
|
||||
Our team typically responds within 24 hours.
|
||||
</p>
|
||||
<Button
|
||||
as="a"
|
||||
href="/support/new"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Button as="a" href="/support/new" size="sm" variant="outline">
|
||||
New Case
|
||||
</Button>
|
||||
</div>
|
||||
@ -119,9 +110,7 @@ export function SupportHomeView() {
|
||||
onClick={() => router.push(`/support/cases/${supportCase.id}`)}
|
||||
className="flex items-center gap-4 p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{getCaseStatusIcon(supportCase.status)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{getCaseStatusIcon(supportCase.status)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
|
||||
@ -248,4 +248,3 @@ export function useZodForm<TValues extends Record<string, unknown>>({
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ interface QueryProviderProps {
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export function QueryProvider({ children, nonce }: QueryProviderProps) {
|
||||
export function QueryProvider({ children }: QueryProviderProps) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
|
||||
9
env/portal-backend.env.sample
vendored
9
env/portal-backend.env.sample
vendored
@ -108,6 +108,15 @@ FREEBIT_OEM_KEY=CHANGE_ME
|
||||
# FREEBIT_TIMEOUT=30000 # default
|
||||
# FREEBIT_RETRY_ATTEMPTS=3 # default
|
||||
|
||||
# --- SFTP (SIM Call/SMS history imports) ---
|
||||
# SFTP_HOST=fs.mvno.net # default
|
||||
# SFTP_PORT=22 # default
|
||||
# SFTP_USERNAME=PASI # default
|
||||
SFTP_PASSWORD=CHANGE_ME
|
||||
# Optional but recommended (MITM protection): server host key fingerprint
|
||||
# You can paste OpenSSH-style values (prefix "SHA256:" is ignored).
|
||||
# SFTP_HOST_KEY_SHA256=CHANGE_ME
|
||||
|
||||
# --- SendGrid (Email) ---
|
||||
SENDGRID_API_KEY=CHANGE_ME
|
||||
# EMAIL_ENABLED=true # default
|
||||
|
||||
@ -31,12 +31,28 @@ export default [
|
||||
rules: { "prettier/prettier": "warn" },
|
||||
},
|
||||
|
||||
// TypeScript type-checked rules for all TS files
|
||||
...tseslint.configs.recommendedTypeChecked.map((config) => ({
|
||||
// TypeScript recommended rules (fast, no type info)
|
||||
...tseslint.configs.recommended.map((config) => ({
|
||||
...config,
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
languageOptions: {
|
||||
...(config.languageOptions || {}),
|
||||
// Keep config simple: allow both environments; app-specific blocks can tighten later
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
})),
|
||||
|
||||
// TypeScript type-aware rules only where we really want them (backend + shared packages)
|
||||
...tseslint.configs.recommendedTypeChecked.map((config) => ({
|
||||
...config,
|
||||
files: ["apps/bff/**/*.ts", "packages/**/*.ts"],
|
||||
languageOptions: {
|
||||
...(config.languageOptions || {}),
|
||||
parserOptions: {
|
||||
...((config.languageOptions && config.languageOptions.parserOptions) || {}),
|
||||
projectService: true,
|
||||
tsconfigRootDir: process.cwd(),
|
||||
},
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
})),
|
||||
@ -75,21 +91,26 @@ export default [
|
||||
files: ["apps/portal/**/*.{js,jsx,ts,tsx}"],
|
||||
},
|
||||
{
|
||||
...reactHooks.configs.flat.recommended,
|
||||
// Keep this minimal (defaults-first): only the two classic hook rules.
|
||||
files: ["apps/portal/**/*.{jsx,tsx}"],
|
||||
plugins: { "react-hooks": reactHooks },
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
},
|
||||
},
|
||||
|
||||
// Portal type-aware rules
|
||||
// Portal overrides
|
||||
{
|
||||
files: ["apps/portal/**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: process.cwd(),
|
||||
},
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@next/next/no-html-link-for-pages": "off",
|
||||
},
|
||||
},
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
"test": "pnpm --recursive run test",
|
||||
"lint": "pnpm --recursive run lint",
|
||||
"lint:fix": "pnpm --recursive run lint:fix",
|
||||
"lint-staged": "lint-staged",
|
||||
"format": "prettier -w .",
|
||||
"format:check": "prettier -c .",
|
||||
"prepare": "husky",
|
||||
@ -54,6 +55,7 @@
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.3",
|
||||
"eslint": "^9.39.1",
|
||||
"lint-staged": "^16.2.7",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^16.5.0",
|
||||
@ -66,7 +68,6 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"js-yaml": ">=4.1.1",
|
||||
"glob": "^8.1.0",
|
||||
"typescript": "5.9.3",
|
||||
"@types/node": "24.10.3",
|
||||
"zod": "4.1.13"
|
||||
|
||||
11265
pnpm-lock.yaml
generated
11265
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user