feat(auth): implement permission-based access control and centralized error handling
- Introduced PermissionsGuard to enforce permission checks on routes. - Added RequirePermissions decorator for specifying required permissions on handlers. - Created AUTH_ERRORS constants for consistent error messages across the auth module. - Updated CsrfService to reduce CSRF token expiry time for enhanced security. - Refactored auth cookie handling into utility functions for better maintainability. - Enhanced TokenBlacklistService to default to fail-closed in production environments. - Updated various DTOs and schemas for consistency and clarity. - Removed legacy code and types related to SIM requests. - Improved logging and error handling in GlobalAuthGuard. - Added middleware for public path checks and optimistic authentication.
This commit is contained in:
parent
b52b2874d6
commit
d3b94b1ed3
@ -16,7 +16,7 @@ export const envSchema = z.object({
|
|||||||
BCRYPT_ROUNDS: z.coerce.number().int().min(12).max(16).default(14),
|
BCRYPT_ROUNDS: z.coerce.number().int().min(12).max(16).default(14),
|
||||||
APP_BASE_URL: z.string().url().default("http://localhost:3000"),
|
APP_BASE_URL: z.string().url().default("http://localhost:3000"),
|
||||||
|
|
||||||
CSRF_TOKEN_EXPIRY: z.coerce.number().int().positive().default(3600000),
|
CSRF_TOKEN_EXPIRY: z.coerce.number().int().positive().default(900000), // 15 minutes
|
||||||
CSRF_SECRET_KEY: z.string().min(32, "CSRF secret key must be at least 32 characters").optional(),
|
CSRF_SECRET_KEY: z.string().min(32, "CSRF secret key must be at least 32 characters").optional(),
|
||||||
CSRF_COOKIE_NAME: z.string().default("csrf-secret"),
|
CSRF_COOKIE_NAME: z.string().default("csrf-secret"),
|
||||||
CSRF_HEADER_NAME: z.string().default("X-CSRF-Token"),
|
CSRF_HEADER_NAME: z.string().default("X-CSRF-Token"),
|
||||||
@ -46,7 +46,8 @@ export const envSchema = z.object({
|
|||||||
REDIS_URL: z.string().url().default("redis://localhost:6379"),
|
REDIS_URL: z.string().url().default("redis://localhost:6379"),
|
||||||
AUTH_ALLOW_REDIS_TOKEN_FAILOPEN: z.enum(["true", "false"]).default("false"),
|
AUTH_ALLOW_REDIS_TOKEN_FAILOPEN: z.enum(["true", "false"]).default("false"),
|
||||||
AUTH_REQUIRE_REDIS_FOR_TOKENS: z.enum(["true", "false"]).default("false"),
|
AUTH_REQUIRE_REDIS_FOR_TOKENS: z.enum(["true", "false"]).default("false"),
|
||||||
AUTH_BLACKLIST_FAIL_CLOSED: z.enum(["true", "false"]).default("false"),
|
// Default handled by service: fail-closed in production, fail-open in development
|
||||||
|
AUTH_BLACKLIST_FAIL_CLOSED: z.enum(["true", "false"]).optional(),
|
||||||
AUTH_MAINTENANCE_MODE: z.enum(["true", "false"]).default("false"),
|
AUTH_MAINTENANCE_MODE: z.enum(["true", "false"]).default("false"),
|
||||||
AUTH_MAINTENANCE_MESSAGE: z
|
AUTH_MAINTENANCE_MESSAGE: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@ -140,13 +140,6 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
// Store validated token in request for potential use by controllers
|
// Store validated token in request for potential use by controllers
|
||||||
req.csrfToken = token;
|
req.csrfToken = token;
|
||||||
|
|
||||||
this.logger.debug("CSRF validation successful", {
|
|
||||||
method: req.method,
|
|
||||||
path: req.path,
|
|
||||||
userId,
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,8 @@ export class CsrfService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
this.tokenExpiry = Number(this.configService.get("CSRF_TOKEN_EXPIRY", "3600000"));
|
// Default to 15 minutes (900000ms) - shorter TTL reduces CSRF attack window
|
||||||
|
this.tokenExpiry = Number(this.configService.get("CSRF_TOKEN_EXPIRY", "900000"));
|
||||||
this.secretKey = this.configService.get("CSRF_SECRET_KEY") || this.generateSecretKey();
|
this.secretKey = this.configService.get("CSRF_SECRET_KEY") || this.generateSecretKey();
|
||||||
this.cookieName = this.configService.get("CSRF_COOKIE_NAME", "csrf-secret");
|
this.cookieName = this.configService.get("CSRF_COOKIE_NAME", "csrf-secret");
|
||||||
this.headerName = this.configService.get("CSRF_HEADER_NAME", "X-CSRF-Token");
|
this.headerName = this.configService.get("CSRF_HEADER_NAME", "X-CSRF-Token");
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { UsersModule } from "@bff/modules/users/users.module.js";
|
|||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||||
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
|
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
|
||||||
import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard.js";
|
import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard.js";
|
||||||
|
import { PermissionsGuard } from "./presentation/http/guards/permissions.guard.js";
|
||||||
import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js";
|
import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js";
|
||||||
import { TokenStorageService } from "./infra/token/token-storage.service.js";
|
import { TokenStorageService } from "./infra/token/token-storage.service.js";
|
||||||
import { TokenRevocationService } from "./infra/token/token-revocation.service.js";
|
import { TokenRevocationService } from "./infra/token/token-revocation.service.js";
|
||||||
@ -68,7 +69,11 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: GlobalAuthGuard,
|
useClass: GlobalAuthGuard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: PermissionsGuard,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
exports: [AuthFacade, TokenBlacklistService, AuthTokenService],
|
exports: [AuthFacade, TokenBlacklistService, AuthTokenService, PermissionsGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
115
apps/bff/src/modules/auth/constants/auth-errors.ts
Normal file
115
apps/bff/src/modules/auth/constants/auth-errors.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Centralized Authentication Error Catalog
|
||||||
|
*
|
||||||
|
* Provides consistent error codes and messages across the auth module.
|
||||||
|
* Use these constants instead of hardcoding error messages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const AUTH_ERRORS = {
|
||||||
|
// Token errors
|
||||||
|
TOKEN_INVALID: {
|
||||||
|
code: "TOKEN_INVALID",
|
||||||
|
message: "Invalid token",
|
||||||
|
},
|
||||||
|
TOKEN_EXPIRED: {
|
||||||
|
code: "TOKEN_EXPIRED",
|
||||||
|
message: "Token has expired",
|
||||||
|
},
|
||||||
|
TOKEN_REVOKED: {
|
||||||
|
code: "TOKEN_REVOKED",
|
||||||
|
message: "Token has been revoked",
|
||||||
|
},
|
||||||
|
TOKEN_MISSING: {
|
||||||
|
code: "TOKEN_MISSING",
|
||||||
|
message: "Missing token",
|
||||||
|
},
|
||||||
|
TOKEN_INVALID_TYPE: {
|
||||||
|
code: "TOKEN_INVALID_TYPE",
|
||||||
|
message: "Invalid access token",
|
||||||
|
},
|
||||||
|
TOKEN_INVALID_PAYLOAD: {
|
||||||
|
code: "TOKEN_INVALID_PAYLOAD",
|
||||||
|
message: "Invalid token payload",
|
||||||
|
},
|
||||||
|
TOKEN_MISSING_EXPIRATION: {
|
||||||
|
code: "TOKEN_MISSING_EXPIRATION",
|
||||||
|
message: "Token missing expiration claim",
|
||||||
|
},
|
||||||
|
TOKEN_EXPIRING_SOON: {
|
||||||
|
code: "TOKEN_EXPIRING_SOON",
|
||||||
|
message: "Token expired or expiring soon",
|
||||||
|
},
|
||||||
|
TOKEN_SUBJECT_MISMATCH: {
|
||||||
|
code: "TOKEN_SUBJECT_MISMATCH",
|
||||||
|
message: "Token subject does not match user record",
|
||||||
|
},
|
||||||
|
|
||||||
|
// User errors
|
||||||
|
USER_NOT_FOUND: {
|
||||||
|
code: "USER_NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
},
|
||||||
|
ACCOUNT_EXISTS: {
|
||||||
|
code: "ACCOUNT_EXISTS",
|
||||||
|
message: "Account already exists",
|
||||||
|
},
|
||||||
|
INVALID_CREDENTIALS: {
|
||||||
|
code: "INVALID_CREDENTIALS",
|
||||||
|
message: "Invalid credentials",
|
||||||
|
},
|
||||||
|
EMAIL_NOT_VERIFIED: {
|
||||||
|
code: "EMAIL_NOT_VERIFIED",
|
||||||
|
message: "Email not verified",
|
||||||
|
},
|
||||||
|
ACCOUNT_DISABLED: {
|
||||||
|
code: "ACCOUNT_DISABLED",
|
||||||
|
message: "Account has been disabled",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Password errors
|
||||||
|
PASSWORD_TOO_WEAK: {
|
||||||
|
code: "PASSWORD_TOO_WEAK",
|
||||||
|
message: "Password does not meet requirements",
|
||||||
|
},
|
||||||
|
PASSWORD_MISMATCH: {
|
||||||
|
code: "PASSWORD_MISMATCH",
|
||||||
|
message: "Current password is incorrect",
|
||||||
|
},
|
||||||
|
PASSWORD_RESET_EXPIRED: {
|
||||||
|
code: "PASSWORD_RESET_EXPIRED",
|
||||||
|
message: "Password reset token has expired",
|
||||||
|
},
|
||||||
|
PASSWORD_RESET_INVALID: {
|
||||||
|
code: "PASSWORD_RESET_INVALID",
|
||||||
|
message: "Invalid password reset token",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Session errors
|
||||||
|
SESSION_EXPIRED: {
|
||||||
|
code: "SESSION_EXPIRED",
|
||||||
|
message: "Session has expired",
|
||||||
|
},
|
||||||
|
SESSION_INVALID: {
|
||||||
|
code: "SESSION_INVALID",
|
||||||
|
message: "Invalid session",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Request context errors
|
||||||
|
INVALID_REQUEST_CONTEXT: {
|
||||||
|
code: "INVALID_REQUEST_CONTEXT",
|
||||||
|
message: "Invalid request context",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Service errors
|
||||||
|
SERVICE_UNAVAILABLE: {
|
||||||
|
code: "SERVICE_UNAVAILABLE",
|
||||||
|
message: "Authentication temporarily unavailable",
|
||||||
|
},
|
||||||
|
RATE_LIMITED: {
|
||||||
|
code: "RATE_LIMITED",
|
||||||
|
message: "Too many requests. Please try again later.",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AuthErrorCode = keyof typeof AUTH_ERRORS;
|
||||||
|
export type AuthError = (typeof AUTH_ERRORS)[AuthErrorCode];
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
import type { Permission } from "@customer-portal/domain/auth";
|
||||||
|
|
||||||
|
export const REQUIRED_PERMISSIONS_KEY = "requiredPermissions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator to require specific permissions for a route.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @RequirePermissions(PERMISSIONS.ADMIN_USERS)
|
||||||
|
* @Get('admin/users')
|
||||||
|
* listUsers() { ... }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Require any of the specified permissions
|
||||||
|
* @RequirePermissions(PERMISSIONS.BILLING_READ, PERMISSIONS.ADMIN_AUDIT)
|
||||||
|
* @Get('billing/summary')
|
||||||
|
* getBillingSummary() { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const RequirePermissions = (...permissions: Permission[]) =>
|
||||||
|
SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions);
|
||||||
@ -16,7 +16,14 @@ export class TokenBlacklistService {
|
|||||||
private readonly jwtService: JoseJwtService,
|
private readonly jwtService: JoseJwtService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
this.failClosed = this.configService.get("AUTH_BLACKLIST_FAIL_CLOSED", "false") === "true";
|
// Default to fail-closed in production for security, fail-open in development for convenience
|
||||||
|
const nodeEnv = this.configService.get<string>("NODE_ENV", "development");
|
||||||
|
const explicitSetting = this.configService.get<string>("AUTH_BLACKLIST_FAIL_CLOSED");
|
||||||
|
if (explicitSetting === undefined) {
|
||||||
|
this.failClosed = nodeEnv === "production";
|
||||||
|
} else {
|
||||||
|
this.failClosed = explicitSetting === "true";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async blacklistToken(token: string): Promise<void> {
|
async blacklistToken(token: string): Promise<void> {
|
||||||
|
|||||||
@ -25,6 +25,14 @@ import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guard
|
|||||||
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
|
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
|
||||||
import type { UserAuth } from "@customer-portal/domain/customer";
|
import type { UserAuth } from "@customer-portal/domain/customer";
|
||||||
import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js";
|
import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js";
|
||||||
|
import {
|
||||||
|
setAuthCookies,
|
||||||
|
clearAuthCookies,
|
||||||
|
buildSessionInfo,
|
||||||
|
ACCESS_COOKIE_PATH,
|
||||||
|
REFRESH_COOKIE_PATH,
|
||||||
|
TOKEN_TYPE,
|
||||||
|
} from "./utils/auth-cookie.util.js";
|
||||||
|
|
||||||
// Import Zod schemas from domain
|
// Import Zod schemas from domain
|
||||||
import {
|
import {
|
||||||
@ -47,6 +55,9 @@ type RequestWithCookies = Omit<Request, "cookies"> & {
|
|||||||
cookies?: Record<string, CookieValue>;
|
cookies?: Record<string, CookieValue>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Re-export for backward compatibility with tests
|
||||||
|
export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };
|
||||||
|
|
||||||
class SignupRequestDto extends createZodDto(signupRequestSchema) {}
|
class SignupRequestDto extends createZodDto(signupRequestSchema) {}
|
||||||
class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {}
|
class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {}
|
||||||
class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {}
|
class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {}
|
||||||
@ -60,18 +71,6 @@ class SsoLinkRequestDto extends createZodDto(ssoLinkRequestSchema) {}
|
|||||||
class LinkWhmcsResponseDto extends createZodDto(linkWhmcsResponseSchema) {}
|
class LinkWhmcsResponseDto extends createZodDto(linkWhmcsResponseSchema) {}
|
||||||
class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededResponseSchema) {}
|
class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededResponseSchema) {}
|
||||||
|
|
||||||
const calculateCookieMaxAge = (isoTimestamp: string): number => {
|
|
||||||
const expiresAt = Date.parse(isoTimestamp);
|
|
||||||
if (Number.isNaN(expiresAt)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.max(0, expiresAt - Date.now());
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACCESS_COOKIE_PATH = "/api";
|
|
||||||
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
|
|
||||||
const TOKEN_TYPE = "Bearer" as const;
|
|
||||||
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
@ -79,46 +78,6 @@ export class AuthController {
|
|||||||
private readonly jwtService: JoseJwtService
|
private readonly jwtService: JoseJwtService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private setAuthCookies(
|
|
||||||
res: Response,
|
|
||||||
tokens: {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
expiresAt: string;
|
|
||||||
refreshExpiresAt: string;
|
|
||||||
}
|
|
||||||
): void {
|
|
||||||
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
|
|
||||||
const refreshMaxAge = calculateCookieMaxAge(tokens.refreshExpiresAt);
|
|
||||||
|
|
||||||
res.setSecureCookie("access_token", tokens.accessToken, {
|
|
||||||
maxAge: accessMaxAge,
|
|
||||||
path: ACCESS_COOKIE_PATH,
|
|
||||||
});
|
|
||||||
res.setSecureCookie("refresh_token", tokens.refreshToken, {
|
|
||||||
maxAge: refreshMaxAge,
|
|
||||||
path: REFRESH_COOKIE_PATH,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private toSession(tokens: { expiresAt: string; refreshExpiresAt: string }) {
|
|
||||||
return {
|
|
||||||
expiresAt: tokens.expiresAt,
|
|
||||||
refreshExpiresAt: tokens.refreshExpiresAt,
|
|
||||||
tokenType: TOKEN_TYPE,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearAuthCookies(res: Response): void {
|
|
||||||
// Clear current cookie paths
|
|
||||||
res.setSecureCookie("access_token", "", { maxAge: 0, path: ACCESS_COOKIE_PATH });
|
|
||||||
res.setSecureCookie("refresh_token", "", { maxAge: 0, path: REFRESH_COOKIE_PATH });
|
|
||||||
|
|
||||||
// Backward-compat: clear legacy cookies that were set on `/`
|
|
||||||
res.setSecureCookie("access_token", "", { maxAge: 0, path: "/" });
|
|
||||||
res.setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" });
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
|
private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
|
||||||
FailedLoginThrottleGuard.applyRateLimitHeaders(req, res);
|
FailedLoginThrottleGuard.applyRateLimitHeaders(req, res);
|
||||||
}
|
}
|
||||||
@ -145,8 +104,8 @@ export class AuthController {
|
|||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const result = await this.authFacade.signup(signupData, req);
|
const result = await this.authFacade.signup(signupData, req);
|
||||||
this.setAuthCookies(res, result.tokens);
|
setAuthCookies(res, result.tokens);
|
||||||
return { user: result.user, session: this.toSession(result.tokens) };
|
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -158,9 +117,9 @@ export class AuthController {
|
|||||||
@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);
|
setAuthCookies(res, result.tokens);
|
||||||
this.applyAuthRateLimitHeaders(req, res);
|
this.applyAuthRateLimitHeaders(req, res);
|
||||||
return { user: result.user, session: this.toSession(result.tokens) };
|
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -182,7 +141,7 @@ export class AuthController {
|
|||||||
await this.authFacade.logout(userId, token, req as Request);
|
await this.authFacade.logout(userId, token, req as Request);
|
||||||
|
|
||||||
// Always clear cookies, even if session expired
|
// Always clear cookies, even if session expired
|
||||||
this.clearAuthCookies(res);
|
clearAuthCookies(res);
|
||||||
return { message: "Logout successful" };
|
return { message: "Logout successful" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,8 +161,8 @@ export class AuthController {
|
|||||||
deviceId: body.deviceId ?? undefined,
|
deviceId: body.deviceId ?? undefined,
|
||||||
userAgent: userAgent ?? undefined,
|
userAgent: userAgent ?? undefined,
|
||||||
});
|
});
|
||||||
this.setAuthCookies(res, result.tokens);
|
setAuthCookies(res, result.tokens);
|
||||||
return { user: result.user, session: this.toSession(result.tokens) };
|
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -226,8 +185,8 @@ export class AuthController {
|
|||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const result = await this.authFacade.setPassword(setPasswordData);
|
const result = await this.authFacade.setPassword(setPasswordData);
|
||||||
this.setAuthCookies(res, result.tokens);
|
setAuthCookies(res, result.tokens);
|
||||||
return { user: result.user, session: this.toSession(result.tokens) };
|
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -264,7 +223,7 @@ export class AuthController {
|
|||||||
await this.authFacade.resetPassword(body.token, body.password);
|
await this.authFacade.resetPassword(body.token, body.password);
|
||||||
|
|
||||||
// Clear auth cookies after password reset to force re-login
|
// Clear auth cookies after password reset to force re-login
|
||||||
this.clearAuthCookies(res);
|
clearAuthCookies(res);
|
||||||
return { message: "Password reset successful" };
|
return { message: "Password reset successful" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,8 +236,8 @@ export class AuthController {
|
|||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const result = await this.authFacade.changePassword(req.user.id, body, req);
|
const result = await this.authFacade.changePassword(req.user.id, body, req);
|
||||||
this.setAuthCookies(res, result.tokens);
|
setAuthCookies(res, result.tokens);
|
||||||
return { user: result.user, session: this.toSession(result.tokens) };
|
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
import type { User } from "@customer-portal/domain/customer";
|
import type { User } from "@customer-portal/domain/customer";
|
||||||
|
|
||||||
import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js";
|
import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js";
|
||||||
|
import { setAuthCookies, buildSessionInfo, type SessionInfo } from "./utils/auth-cookie.util.js";
|
||||||
|
|
||||||
// DTO classes using Zod schemas
|
// DTO classes using Zod schemas
|
||||||
class SendVerificationCodeRequestDto extends createZodDto(sendVerificationCodeRequestSchema) {}
|
class SendVerificationCodeRequestDto extends createZodDto(sendVerificationCodeRequestSchema) {}
|
||||||
@ -32,72 +33,11 @@ class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchem
|
|||||||
class SignupWithEligibilityRequestDto extends createZodDto(signupWithEligibilityRequestSchema) {}
|
class SignupWithEligibilityRequestDto extends createZodDto(signupWithEligibilityRequestSchema) {}
|
||||||
class SignupWithEligibilityResponseDto extends createZodDto(signupWithEligibilityResponseSchema) {}
|
class SignupWithEligibilityResponseDto extends createZodDto(signupWithEligibilityResponseSchema) {}
|
||||||
|
|
||||||
// Cookie configuration
|
|
||||||
const ACCESS_COOKIE_PATH = "/api";
|
|
||||||
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
|
|
||||||
const TOKEN_TYPE = "Bearer" as const;
|
|
||||||
|
|
||||||
interface AuthTokens {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
expiresAt: string;
|
|
||||||
refreshExpiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionInfo {
|
|
||||||
expiresAt: string;
|
|
||||||
refreshExpiresAt: string;
|
|
||||||
tokenType: typeof TOKEN_TYPE;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthSuccessResponse {
|
interface AuthSuccessResponse {
|
||||||
user: User;
|
user: User;
|
||||||
session: SessionInfo;
|
session: SessionInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate cookie max age from ISO timestamp
|
|
||||||
*/
|
|
||||||
function calculateCookieMaxAge(isoTimestamp: string): number {
|
|
||||||
const expiresAt = Date.parse(isoTimestamp);
|
|
||||||
if (Number.isNaN(expiresAt)) return 0;
|
|
||||||
return Math.max(0, expiresAt - Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set authentication cookies (httpOnly, secure in production)
|
|
||||||
*/
|
|
||||||
function setAuthCookies(res: Response, tokens: AuthTokens): void {
|
|
||||||
const isProduction = process.env["NODE_ENV"] === "production";
|
|
||||||
|
|
||||||
res.cookie("access_token", tokens.accessToken, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isProduction,
|
|
||||||
sameSite: "lax",
|
|
||||||
path: ACCESS_COOKIE_PATH,
|
|
||||||
maxAge: calculateCookieMaxAge(tokens.expiresAt),
|
|
||||||
});
|
|
||||||
|
|
||||||
res.cookie("refresh_token", tokens.refreshToken, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isProduction,
|
|
||||||
sameSite: "lax",
|
|
||||||
path: REFRESH_COOKIE_PATH,
|
|
||||||
maxAge: calculateCookieMaxAge(tokens.refreshExpiresAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build session info from tokens
|
|
||||||
*/
|
|
||||||
function buildSessionInfo(tokens: AuthTokens): SessionInfo {
|
|
||||||
return {
|
|
||||||
expiresAt: tokens.expiresAt,
|
|
||||||
refreshExpiresAt: tokens.refreshExpiresAt,
|
|
||||||
tokenType: TOKEN_TYPE,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Started Controller
|
* Get Started Controller
|
||||||
*
|
*
|
||||||
|
|||||||
@ -55,22 +55,17 @@ export class GlobalAuthGuard implements CanActivate {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (isPublic) {
|
if (isPublic) {
|
||||||
if (isPublicNoSession) {
|
if (!isPublicNoSession) {
|
||||||
this.logger.debug(`Strict public route accessed (no session attach): ${route}`);
|
// Try to attach user from token if present (optional)
|
||||||
return true;
|
const token = extractAccessTokenFromRequest(request);
|
||||||
}
|
if (token) {
|
||||||
|
try {
|
||||||
const token = extractAccessTokenFromRequest(request);
|
await this.attachUserFromToken(request, token);
|
||||||
if (token) {
|
} catch {
|
||||||
try {
|
// Public endpoints should remain accessible even if the session is missing/expired/invalid
|
||||||
await this.attachUserFromToken(request, token);
|
}
|
||||||
this.logger.debug(`Authenticated session detected on public route: ${route}`);
|
|
||||||
} catch {
|
|
||||||
// Public endpoints should remain accessible even if the session is missing/expired/invalid.
|
|
||||||
this.logger.debug(`Ignoring invalid session on public route: ${route}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logger.debug(`Public route accessed: ${route}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,48 +80,35 @@ export class GlobalAuthGuard implements CanActivate {
|
|||||||
const token = extractAccessTokenFromRequest(request);
|
const token = extractAccessTokenFromRequest(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
// No token = not logged in, allow request with no user attached
|
// No token = not logged in, allow request with no user attached
|
||||||
this.logger.debug(`Optional auth route accessed without token: ${route}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token present - validate it strictly (invalid token = 401)
|
// Token present - validate it strictly (invalid token = 401)
|
||||||
try {
|
await this.attachUserFromToken(request, token, route);
|
||||||
await this.attachUserFromToken(request, token, route);
|
return true;
|
||||||
this.logger.debug(`Optional auth route accessed with valid token: ${route}`);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
// Token is invalid/expired - return 401 to signal "session expired"
|
|
||||||
this.logger.debug(`Optional auth route - invalid token, returning 401: ${route}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = extractAccessTokenFromRequest(request);
|
const token = extractAccessTokenFromRequest(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
if (isLogoutRoute) {
|
if (isLogoutRoute) {
|
||||||
this.logger.debug(`Allowing logout request without active session: ${route}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
throw new UnauthorizedException("Missing token");
|
throw new UnauthorizedException("Missing token");
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.attachUserFromToken(request, token, route);
|
await this.attachUserFromToken(request, token, route);
|
||||||
|
|
||||||
this.logger.debug(`Authenticated access to: ${route}`);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedException) {
|
if (error instanceof UnauthorizedException) {
|
||||||
if (isLogoutRoute) {
|
if (isLogoutRoute) {
|
||||||
this.logger.debug(`Allowing logout request with expired or invalid token: ${route}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// Only log warnings for requests with an invalid token (not missing tokens)
|
||||||
const token = extractAccessTokenFromRequest(request);
|
const token = extractAccessTokenFromRequest(request);
|
||||||
const log =
|
if (typeof token === "string") {
|
||||||
typeof token === "string"
|
this.logger.warn(`Unauthorized access attempt to ${route}`);
|
||||||
? () => this.logger.warn(`Unauthorized access attempt to ${route}`)
|
}
|
||||||
: () => this.logger.debug(`Unauthenticated request blocked for ${route}`);
|
|
||||||
log();
|
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(`Authentication error for route ${route}: ${extractErrorMessage(error)}`);
|
this.logger.error(`Authentication error for route ${route}: ${extractErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { Injectable, ForbiddenException, Logger } from "@nestjs/common";
|
||||||
|
import type { CanActivate, ExecutionContext } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import type { Request } from "express";
|
||||||
|
|
||||||
|
import { REQUIRED_PERMISSIONS_KEY } from "../../../decorators/permissions.decorator.js";
|
||||||
|
import { hasAnyPermission, type Permission } from "@customer-portal/domain/auth";
|
||||||
|
|
||||||
|
interface UserWithRole {
|
||||||
|
id: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestWithUser = Request & { user?: UserWithRole };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard to enforce permission-based access control.
|
||||||
|
*
|
||||||
|
* This guard checks if the authenticated user has any of the required
|
||||||
|
* permissions specified via the @RequirePermissions decorator.
|
||||||
|
*
|
||||||
|
* Note: This guard must be used after the GlobalAuthGuard, which
|
||||||
|
* attaches the user to the request.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionsGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(PermissionsGuard.name);
|
||||||
|
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredPermissions = this.reflector.getAllAndOverride<Permission[] | undefined>(
|
||||||
|
REQUIRED_PERMISSIONS_KEY,
|
||||||
|
[context.getHandler(), context.getClass()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// No permissions required - allow access
|
||||||
|
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
this.logger.warn("Permissions check failed - no user on request");
|
||||||
|
throw new ForbiddenException("Access denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.role) {
|
||||||
|
this.logger.warn("User has no role assigned", { userId: user.id });
|
||||||
|
throw new ForbiddenException("User role not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAccess = hasAnyPermission(user.role, requiredPermissions);
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
this.logger.warn("Permission denied", {
|
||||||
|
userId: user.id,
|
||||||
|
role: user.role,
|
||||||
|
requiredPermissions,
|
||||||
|
});
|
||||||
|
throw new ForbiddenException("Insufficient permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
import type { Response, CookieOptions } from "express";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth tokens structure for cookie setting
|
||||||
|
*/
|
||||||
|
export interface AuthTokens {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresAt: string;
|
||||||
|
refreshExpiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session info returned to client (excludes actual tokens)
|
||||||
|
*/
|
||||||
|
export interface SessionInfo {
|
||||||
|
expiresAt: string;
|
||||||
|
refreshExpiresAt: string;
|
||||||
|
tokenType: "Bearer";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom setSecureCookie function signature
|
||||||
|
* This is added by our security middleware
|
||||||
|
*/
|
||||||
|
type SetSecureCookieFn = (name: string, value: string, options?: CookieOptions) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get setSecureCookie function from response if available
|
||||||
|
* Returns null if the custom helper is not present
|
||||||
|
*/
|
||||||
|
function getSecureCookie(res: Response): SetSecureCookieFn | null {
|
||||||
|
const maybeSecure = res as Response & { setSecureCookie?: unknown };
|
||||||
|
if (typeof maybeSecure.setSecureCookie === "function") {
|
||||||
|
return maybeSecure.setSecureCookie.bind(res) as SetSecureCookieFn;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie paths - access token needs broader access, refresh token only for refresh endpoint
|
||||||
|
export const ACCESS_COOKIE_PATH = "/api";
|
||||||
|
export const REFRESH_COOKIE_PATH = "/api/auth/refresh";
|
||||||
|
export const TOKEN_TYPE = "Bearer" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cookie max age from ISO timestamp
|
||||||
|
*/
|
||||||
|
export function calculateCookieMaxAge(isoTimestamp: string): number {
|
||||||
|
const expiresAt = Date.parse(isoTimestamp);
|
||||||
|
if (Number.isNaN(expiresAt)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, expiresAt - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set authentication cookies on the response.
|
||||||
|
*
|
||||||
|
* Uses res.setSecureCookie if available (custom helper that sets
|
||||||
|
* httpOnly, secure, sameSite), otherwise falls back to standard res.cookie.
|
||||||
|
*/
|
||||||
|
export function setAuthCookies(res: Response, tokens: AuthTokens): void {
|
||||||
|
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
|
||||||
|
const refreshMaxAge = calculateCookieMaxAge(tokens.refreshExpiresAt);
|
||||||
|
|
||||||
|
const setSecureCookie = getSecureCookie(res);
|
||||||
|
if (setSecureCookie) {
|
||||||
|
// Use the custom setSecureCookie helper if available
|
||||||
|
setSecureCookie("access_token", tokens.accessToken, {
|
||||||
|
maxAge: accessMaxAge,
|
||||||
|
path: ACCESS_COOKIE_PATH,
|
||||||
|
});
|
||||||
|
setSecureCookie("refresh_token", tokens.refreshToken, {
|
||||||
|
maxAge: refreshMaxAge,
|
||||||
|
path: REFRESH_COOKIE_PATH,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to standard cookie with secure options
|
||||||
|
const isProduction = process.env["NODE_ENV"] === "production";
|
||||||
|
|
||||||
|
res.cookie("access_token", tokens.accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: ACCESS_COOKIE_PATH,
|
||||||
|
maxAge: accessMaxAge,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.cookie("refresh_token", tokens.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: REFRESH_COOKIE_PATH,
|
||||||
|
maxAge: refreshMaxAge,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear authentication cookies from the response.
|
||||||
|
* Also clears legacy cookies that may have been set on "/" path.
|
||||||
|
*/
|
||||||
|
export function clearAuthCookies(res: Response): void {
|
||||||
|
const setSecureCookie = getSecureCookie(res);
|
||||||
|
if (setSecureCookie) {
|
||||||
|
// Clear current cookie paths
|
||||||
|
setSecureCookie("access_token", "", { maxAge: 0, path: ACCESS_COOKIE_PATH });
|
||||||
|
setSecureCookie("refresh_token", "", { maxAge: 0, path: REFRESH_COOKIE_PATH });
|
||||||
|
|
||||||
|
// DEPRECATED: Clear legacy cookies that were set on `/`
|
||||||
|
// TODO: Remove after 90 days (2025-04-XX) when legacy cookies have expired
|
||||||
|
setSecureCookie("access_token", "", { maxAge: 0, path: "/" });
|
||||||
|
setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" });
|
||||||
|
} else {
|
||||||
|
// Fallback to standard cookie clearing
|
||||||
|
const isProduction = process.env["NODE_ENV"] === "production";
|
||||||
|
const clearOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
maxAge: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.cookie("access_token", "", { ...clearOptions, path: ACCESS_COOKIE_PATH });
|
||||||
|
res.cookie("refresh_token", "", { ...clearOptions, path: REFRESH_COOKIE_PATH });
|
||||||
|
|
||||||
|
// DEPRECATED: Clear legacy cookies
|
||||||
|
res.cookie("access_token", "", { ...clearOptions, path: "/" });
|
||||||
|
res.cookie("refresh_token", "", { ...clearOptions, path: "/" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build session info from tokens (for client response, excludes actual tokens)
|
||||||
|
*/
|
||||||
|
export function buildSessionInfo(tokens: AuthTokens): SessionInfo {
|
||||||
|
return {
|
||||||
|
expiresAt: tokens.expiresAt,
|
||||||
|
refreshExpiresAt: tokens.refreshExpiresAt,
|
||||||
|
tokenType: TOKEN_TYPE,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -25,10 +25,10 @@ import {
|
|||||||
} from "@customer-portal/domain/subscriptions";
|
} from "@customer-portal/domain/subscriptions";
|
||||||
import type { SimActionResponse, SimPlanChangeResult } from "@customer-portal/domain/subscriptions";
|
import type { SimActionResponse, SimPlanChangeResult } from "@customer-portal/domain/subscriptions";
|
||||||
import {
|
import {
|
||||||
simTopupRequestSchema,
|
simTopUpRequestSchema,
|
||||||
simChangePlanRequestSchema,
|
simPlanChangeRequestSchema,
|
||||||
simCancelRequestSchema,
|
simCancelRequestSchema,
|
||||||
simFeaturesRequestSchema,
|
simFeaturesUpdateRequestSchema,
|
||||||
simTopUpHistoryRequestSchema,
|
simTopUpHistoryRequestSchema,
|
||||||
simChangePlanFullRequestSchema,
|
simChangePlanFullRequestSchema,
|
||||||
simCancelFullRequestSchema,
|
simCancelFullRequestSchema,
|
||||||
@ -49,10 +49,10 @@ import {
|
|||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
|
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
|
||||||
class SimTopupRequestDto extends createZodDto(simTopupRequestSchema) {}
|
class SimTopUpRequestDto extends createZodDto(simTopUpRequestSchema) {}
|
||||||
class SimChangePlanRequestDto extends createZodDto(simChangePlanRequestSchema) {}
|
class SimPlanChangeRequestDto extends createZodDto(simPlanChangeRequestSchema) {}
|
||||||
class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {}
|
class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {}
|
||||||
class SimFeaturesRequestDto extends createZodDto(simFeaturesRequestSchema) {}
|
class SimFeaturesUpdateRequestDto extends createZodDto(simFeaturesUpdateRequestSchema) {}
|
||||||
class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {}
|
class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {}
|
||||||
class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {}
|
class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {}
|
||||||
class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {}
|
class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {}
|
||||||
@ -155,7 +155,7 @@ export class SimController {
|
|||||||
async topUpSim(
|
async topUpSim(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimTopupRequestDto
|
@Body() body: SimTopUpRequestDto
|
||||||
): Promise<SimActionResponse> {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.topUpSim(req.user.id, params.id, body);
|
await this.simManagementService.topUpSim(req.user.id, params.id, body);
|
||||||
return { message: "SIM top-up completed successfully" };
|
return { message: "SIM top-up completed successfully" };
|
||||||
@ -166,7 +166,7 @@ export class SimController {
|
|||||||
async changeSimPlan(
|
async changeSimPlan(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimChangePlanRequestDto
|
@Body() body: SimPlanChangeRequestDto
|
||||||
): Promise<SimPlanChangeResult> {
|
): Promise<SimPlanChangeResult> {
|
||||||
const result = await this.simManagementService.changeSimPlan(req.user.id, params.id, body);
|
const result = await this.simManagementService.changeSimPlan(req.user.id, params.id, body);
|
||||||
return {
|
return {
|
||||||
@ -202,7 +202,7 @@ export class SimController {
|
|||||||
async updateSimFeatures(
|
async updateSimFeatures(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimFeaturesRequestDto
|
@Body() body: SimFeaturesUpdateRequestDto
|
||||||
): Promise<SimActionResponse> {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.updateSimFeatures(req.user.id, params.id, body);
|
await this.simManagementService.updateSimFeatures(req.user.id, params.id, body);
|
||||||
return { message: "SIM features updated successfully" };
|
return { message: "SIM features updated successfully" };
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
export interface SimTopUpRequest {
|
|
||||||
quotaMb: number;
|
|
||||||
amount?: number;
|
|
||||||
currency?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SimPlanChangeRequest {
|
|
||||||
newPlanCode: "5GB" | "10GB" | "25GB" | "50GB";
|
|
||||||
effectiveDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SimCancelRequest {
|
|
||||||
scheduledAt?: string; // YYYYMMDD - optional, immediate if omitted
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SimTopUpHistoryRequest {
|
|
||||||
fromDate: string; // YYYYMMDD
|
|
||||||
toDate: string; // YYYYMMDD
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SimFeaturesUpdateRequest {
|
|
||||||
voiceMailEnabled?: boolean;
|
|
||||||
callWaitingEnabled?: boolean;
|
|
||||||
internationalRoamingEnabled?: boolean;
|
|
||||||
networkType?: "4G" | "5G";
|
|
||||||
}
|
|
||||||
@ -9,7 +9,12 @@ import { useCallback, useEffect } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useAuthStore } from "../stores/auth.store";
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
import { getPostLoginRedirect } from "@/features/auth/utils/route-protection";
|
import { getPostLoginRedirect } from "@/features/auth/utils/route-protection";
|
||||||
import type { SignupRequest, LoginRequest } from "@customer-portal/domain/auth";
|
import type { SignupRequest, LoginRequest, Permission } from "@customer-portal/domain/auth";
|
||||||
|
import {
|
||||||
|
PERMISSIONS,
|
||||||
|
hasPermission as checkPermission,
|
||||||
|
hasAnyPermission as checkAnyPermission,
|
||||||
|
} from "@customer-portal/domain/auth";
|
||||||
import type { LogoutReason } from "@/features/auth/utils/logout-reason";
|
import type { LogoutReason } from "@/features/auth/utils/logout-reason";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -279,22 +284,34 @@ export function useUser() {
|
|||||||
*/
|
*/
|
||||||
export function usePermissions() {
|
export function usePermissions() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const role = user?.role ?? "USER";
|
||||||
|
|
||||||
const hasRole = useCallback(
|
const hasRole = useCallback(
|
||||||
(role: string) => {
|
(targetRole: string) => {
|
||||||
if (!user?.role) return false;
|
if (!user?.role) return false;
|
||||||
return user.role === role;
|
return user.role === targetRole;
|
||||||
},
|
},
|
||||||
[user?.role]
|
[user?.role]
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasAnyRole = useCallback((roles: string[]) => roles.some(role => hasRole(role)), [hasRole]);
|
const hasAnyRole = useCallback(
|
||||||
|
(roles: string[]) => roles.some(targetRole => hasRole(targetRole)),
|
||||||
|
[hasRole]
|
||||||
|
);
|
||||||
|
|
||||||
const hasPermission = useCallback(() => false, []);
|
const hasPermission = useCallback(
|
||||||
const hasAnyPermission = useCallback(() => false, []);
|
(permission: Permission) => checkPermission(role, permission),
|
||||||
|
[role]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAnyPermission = useCallback(
|
||||||
|
(permissions: Permission[]) => checkAnyPermission(role, permissions),
|
||||||
|
[role]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
role: user?.role,
|
role: user?.role,
|
||||||
|
permissions: PERMISSIONS,
|
||||||
hasRole,
|
hasRole,
|
||||||
hasAnyRole,
|
hasAnyRole,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { normalizeOrderType } from "@customer-portal/domain/checkout";
|
|
||||||
import {
|
import {
|
||||||
ORDER_TYPE,
|
ORDER_TYPE,
|
||||||
buildOrderConfigurations,
|
buildOrderConfigurations,
|
||||||
@ -35,10 +34,12 @@ export class CheckoutParamsService {
|
|||||||
return ORDER_TYPE.INTERNET;
|
return ORDER_TYPE.INTERNET;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to normalize using domain logic
|
// Case-insensitive match against valid order types
|
||||||
const normalized = normalizeOrderType(typeParam);
|
const validTypes = Object.values(ORDER_TYPE) as string[];
|
||||||
if (normalized) {
|
const matchedType = validTypes.find(type => type.toLowerCase() === typeParam.toLowerCase());
|
||||||
return normalized;
|
|
||||||
|
if (matchedType) {
|
||||||
|
return matchedType as OrderTypeValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unsupported order type: ${typeParam}`);
|
throw new Error(`Unsupported order type: ${typeParam}`);
|
||||||
|
|||||||
@ -104,31 +104,8 @@ export const useCheckoutStore = create<CheckoutStore>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "checkout-store",
|
name: "checkout-store",
|
||||||
version: 3,
|
version: 1,
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
migrate: (persistedState: unknown, version: number) => {
|
|
||||||
if (!persistedState || typeof persistedState !== "object") {
|
|
||||||
return initialState;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = persistedState as Partial<CheckoutState>;
|
|
||||||
|
|
||||||
// Migration from v1/v2: strip out removed fields
|
|
||||||
if (version < 3) {
|
|
||||||
return {
|
|
||||||
cartItem: state.cartItem ?? null,
|
|
||||||
cartParamsSignature: state.cartParamsSignature ?? null,
|
|
||||||
checkoutSessionId: state.checkoutSessionId ?? null,
|
|
||||||
checkoutSessionExpiresAt: state.checkoutSessionExpiresAt ?? null,
|
|
||||||
cartUpdatedAt: state.cartUpdatedAt ?? null,
|
|
||||||
} as CheckoutState;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...initialState,
|
|
||||||
...state,
|
|
||||||
} as CheckoutState;
|
|
||||||
},
|
|
||||||
partialize: state => ({
|
partialize: state => ({
|
||||||
// Persist only essential data
|
// Persist only essential data
|
||||||
cartItem: state.cartItem
|
cartItem: state.cartItem
|
||||||
|
|||||||
@ -91,7 +91,7 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
|
|||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href={`/order?type=vpn&planSku=${encodeURIComponent(plan.sku)}`}
|
href={`/order?type=VPN&planSku=${encodeURIComponent(plan.sku)}`}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
rightIcon={<ArrowRight className="w-4 h-4" />}
|
rightIcon={<ArrowRight className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
|
|||||||
67
apps/portal/src/middleware.ts
Normal file
67
apps/portal/src/middleware.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public paths that don't require authentication.
|
||||||
|
* These routes are accessible without auth cookies.
|
||||||
|
*/
|
||||||
|
const PUBLIC_PATHS = ["/auth/", "/", "/services", "/about", "/contact", "/vpn-configuration"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a path is public (doesn't require authentication)
|
||||||
|
*/
|
||||||
|
function isPublicPath(pathname: string): boolean {
|
||||||
|
return PUBLIC_PATHS.some(publicPath => {
|
||||||
|
if (publicPath.endsWith("/")) {
|
||||||
|
// Path prefix match (e.g., /auth/ matches /auth/login)
|
||||||
|
return pathname.startsWith(publicPath);
|
||||||
|
}
|
||||||
|
// Exact match
|
||||||
|
return pathname === publicPath;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge middleware for optimistic auth checks.
|
||||||
|
*
|
||||||
|
* This middleware runs at the edge before requests reach the origin.
|
||||||
|
* It performs a fast cookie-based check to redirect unauthenticated
|
||||||
|
* users to the login page, reducing unnecessary BFF calls.
|
||||||
|
*
|
||||||
|
* Note: This is an optimistic check. The BFF performs the authoritative
|
||||||
|
* token validation. This middleware only checks for cookie presence.
|
||||||
|
*/
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// Allow public routes
|
||||||
|
if (isPublicPath(pathname)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for auth cookies (optimistic check - BFF validates the token)
|
||||||
|
const hasAccessToken = request.cookies.has("access_token");
|
||||||
|
const hasRefreshToken = request.cookies.has("refresh_token");
|
||||||
|
|
||||||
|
if (!hasAccessToken && !hasRefreshToken) {
|
||||||
|
// No auth cookies present - redirect to login
|
||||||
|
const loginUrl = new URL("/auth/login", request.url);
|
||||||
|
loginUrl.searchParams.set("next", pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except:
|
||||||
|
* - api (API routes)
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
* - public assets (images, fonts, etc.)
|
||||||
|
*/
|
||||||
|
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -105,3 +105,17 @@ export {
|
|||||||
MIGRATION_STEPS,
|
MIGRATION_STEPS,
|
||||||
type PasswordRequirementKey,
|
type PasswordRequirementKey,
|
||||||
} from "./forms.js";
|
} from "./forms.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RBAC Permissions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
PERMISSIONS,
|
||||||
|
ROLE_PERMISSIONS,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
hasAllPermissions,
|
||||||
|
getPermissionsForRole,
|
||||||
|
type Permission,
|
||||||
|
} from "./permissions.js";
|
||||||
|
|||||||
82
packages/domain/auth/permissions.ts
Normal file
82
packages/domain/auth/permissions.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* RBAC Permissions
|
||||||
|
*
|
||||||
|
* Defines the permission constants and role-permission mappings
|
||||||
|
* for the customer portal authorization system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PERMISSIONS = {
|
||||||
|
// Account permissions
|
||||||
|
ACCOUNT_READ: "account:read",
|
||||||
|
ACCOUNT_UPDATE: "account:update",
|
||||||
|
|
||||||
|
// Billing permissions
|
||||||
|
BILLING_READ: "billing:read",
|
||||||
|
BILLING_PAY: "billing:pay",
|
||||||
|
|
||||||
|
// Orders permissions
|
||||||
|
ORDERS_READ: "orders:read",
|
||||||
|
ORDERS_CREATE: "orders:create",
|
||||||
|
|
||||||
|
// Services permissions
|
||||||
|
SERVICES_READ: "services:read",
|
||||||
|
SERVICES_MANAGE: "services:manage",
|
||||||
|
|
||||||
|
// Support permissions
|
||||||
|
SUPPORT_READ: "support:read",
|
||||||
|
SUPPORT_CREATE: "support:create",
|
||||||
|
|
||||||
|
// Admin permissions
|
||||||
|
ADMIN_USERS: "admin:users",
|
||||||
|
ADMIN_AUDIT: "admin:audit",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||||
|
|
||||||
|
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||||
|
USER: [
|
||||||
|
PERMISSIONS.ACCOUNT_READ,
|
||||||
|
PERMISSIONS.ACCOUNT_UPDATE,
|
||||||
|
PERMISSIONS.BILLING_READ,
|
||||||
|
PERMISSIONS.BILLING_PAY,
|
||||||
|
PERMISSIONS.ORDERS_READ,
|
||||||
|
PERMISSIONS.ORDERS_CREATE,
|
||||||
|
PERMISSIONS.SERVICES_READ,
|
||||||
|
PERMISSIONS.SERVICES_MANAGE,
|
||||||
|
PERMISSIONS.SUPPORT_READ,
|
||||||
|
PERMISSIONS.SUPPORT_CREATE,
|
||||||
|
],
|
||||||
|
ADMIN: Object.values(PERMISSIONS) as Permission[],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a role has a specific permission
|
||||||
|
*/
|
||||||
|
export function hasPermission(role: string, permission: Permission): boolean {
|
||||||
|
const rolePermissions = ROLE_PERMISSIONS[role];
|
||||||
|
if (!rolePermissions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return rolePermissions.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a role has any of the specified permissions
|
||||||
|
*/
|
||||||
|
export function hasAnyPermission(role: string, permissions: Permission[]): boolean {
|
||||||
|
return permissions.some(permission => hasPermission(role, permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a role has all of the specified permissions
|
||||||
|
*/
|
||||||
|
export function hasAllPermissions(role: string, permissions: Permission[]): boolean {
|
||||||
|
return permissions.every(permission => hasPermission(role, permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all permissions for a role
|
||||||
|
*/
|
||||||
|
export function getPermissionsForRole(role: string): Permission[] {
|
||||||
|
return ROLE_PERMISSIONS[role] ?? [];
|
||||||
|
}
|
||||||
@ -19,25 +19,5 @@ export const CHECKOUT_ORDER_TYPE = {
|
|||||||
|
|
||||||
export type CheckoutOrderTypeValue = (typeof CHECKOUT_ORDER_TYPE)[keyof typeof CHECKOUT_ORDER_TYPE];
|
export type CheckoutOrderTypeValue = (typeof CHECKOUT_ORDER_TYPE)[keyof typeof CHECKOUT_ORDER_TYPE];
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert legacy uppercase order type to PascalCase
|
|
||||||
* Used for migrating old localStorage data
|
|
||||||
*/
|
|
||||||
export function normalizeOrderType(value: unknown): CheckoutOrderTypeValue | null {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
|
|
||||||
const upper = value.trim().toUpperCase();
|
|
||||||
switch (upper) {
|
|
||||||
case "INTERNET":
|
|
||||||
return "Internet";
|
|
||||||
case "SIM":
|
|
||||||
return "SIM";
|
|
||||||
case "VPN":
|
|
||||||
return "VPN";
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export types from schema
|
// Re-export types from schema
|
||||||
export type { OrderType, PriceBreakdownItem, CartItem } from "./schema.js";
|
export type { OrderType, PriceBreakdownItem, CartItem } from "./schema.js";
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
export {
|
export {
|
||||||
ORDER_TYPE,
|
ORDER_TYPE,
|
||||||
CHECKOUT_ORDER_TYPE,
|
CHECKOUT_ORDER_TYPE,
|
||||||
normalizeOrderType,
|
|
||||||
type OrderTypeValue,
|
type OrderTypeValue,
|
||||||
type CheckoutOrderTypeValue,
|
type CheckoutOrderTypeValue,
|
||||||
} from "./contract.js";
|
} from "./contract.js";
|
||||||
|
|||||||
@ -28,7 +28,6 @@ export type {
|
|||||||
Address, // Address structure (not "CustomerAddress")
|
Address, // Address structure (not "CustomerAddress")
|
||||||
AddressFormData, // Address form validation
|
AddressFormData, // Address form validation
|
||||||
ProfileEditFormData, // Profile edit form data
|
ProfileEditFormData, // Profile edit form data
|
||||||
ProfileDisplayData, // Profile display data (alias)
|
|
||||||
ResidenceCardVerificationStatus,
|
ResidenceCardVerificationStatus,
|
||||||
ResidenceCardVerification,
|
ResidenceCardVerification,
|
||||||
UserProfile, // Alias for User
|
UserProfile, // Alias for User
|
||||||
@ -46,7 +45,6 @@ export {
|
|||||||
addressSchema,
|
addressSchema,
|
||||||
addressFormSchema,
|
addressFormSchema,
|
||||||
profileEditFormSchema,
|
profileEditFormSchema,
|
||||||
profileDisplayDataSchema,
|
|
||||||
residenceCardVerificationStatusSchema,
|
residenceCardVerificationStatusSchema,
|
||||||
residenceCardVerificationSchema,
|
residenceCardVerificationSchema,
|
||||||
|
|
||||||
|
|||||||
@ -95,14 +95,6 @@ export const profileEditFormSchema = z.object({
|
|||||||
phonenumber: z.string().optional(),
|
phonenumber: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Profile display data - includes email for display (read-only)
|
|
||||||
* Used for displaying profile information
|
|
||||||
*/
|
|
||||||
export const profileDisplayDataSchema = profileEditFormSchema.extend({
|
|
||||||
// no extra fields (kept for backwards compatibility)
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UserAuth Schema (Portal Database - Auth State Only)
|
// UserAuth Schema (Portal Database - Auth State Only)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -421,7 +413,6 @@ export type UserRole = "USER" | "ADMIN";
|
|||||||
export type Address = z.infer<typeof addressSchema>;
|
export type Address = z.infer<typeof addressSchema>;
|
||||||
export type AddressFormData = z.infer<typeof addressFormSchema>;
|
export type AddressFormData = z.infer<typeof addressFormSchema>;
|
||||||
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
|
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
|
||||||
export type ProfileDisplayData = z.infer<typeof profileDisplayDataSchema>;
|
|
||||||
export type ResidenceCardVerificationStatus = z.infer<typeof residenceCardVerificationStatusSchema>;
|
export type ResidenceCardVerificationStatus = z.infer<typeof residenceCardVerificationStatusSchema>;
|
||||||
export type ResidenceCardVerification = z.infer<typeof residenceCardVerificationSchema>;
|
export type ResidenceCardVerification = z.infer<typeof residenceCardVerificationSchema>;
|
||||||
|
|
||||||
|
|||||||
@ -564,16 +564,6 @@ export type SimOrderActivationRequest = z.infer<typeof simOrderActivationRequest
|
|||||||
export type SimOrderActivationMnp = z.infer<typeof simOrderActivationMnpSchema>;
|
export type SimOrderActivationMnp = z.infer<typeof simOrderActivationMnpSchema>;
|
||||||
export type SimOrderActivationAddons = z.infer<typeof simOrderActivationAddonsSchema>;
|
export type SimOrderActivationAddons = z.infer<typeof simOrderActivationAddonsSchema>;
|
||||||
|
|
||||||
// Legacy aliases for backward compatibility
|
|
||||||
export const simTopupRequestSchema = simTopUpRequestSchema;
|
|
||||||
export type SimTopupRequest = SimTopUpRequest;
|
|
||||||
|
|
||||||
export const simChangePlanRequestSchema = simPlanChangeRequestSchema;
|
|
||||||
export type SimChangePlanRequest = SimPlanChangeRequest;
|
|
||||||
|
|
||||||
export const simFeaturesRequestSchema = simFeaturesUpdateRequestSchema;
|
|
||||||
export type SimFeaturesRequest = SimFeaturesUpdateRequest;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Inferred Types from Schemas (Schema-First Approach)
|
// Inferred Types from Schemas (Schema-First Approach)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user