From fcc5872fda4d3c303609507be1106b7a40c20a36 Mon Sep 17 00:00:00 2001 From: NTumurbars <156628271+NTumurbars@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:23:56 +0900 Subject: [PATCH 1/5] refactor: unify api client usage around openapi client --- apps/portal/src/core/api/client.ts | 28 +++- apps/portal/src/core/api/index.ts | 3 +- .../features/account/hooks/useProfileData.ts | 19 +-- .../account/services/account.service.ts | 37 +++++ .../src/features/auth/services/auth.store.ts | 149 +++++++++++++++--- .../components/ChangePlanModal.tsx | 7 +- .../sim-management/components/SimActions.tsx | 8 +- .../components/SimFeatureToggles.tsx | 8 +- .../components/SimManagementSection.tsx | 31 ++-- .../sim-management/components/TopUpModal.tsx | 5 +- .../subscriptions/hooks/useSubscriptions.ts | 107 ++++++++----- packages/api-client/src/index.ts | 9 +- packages/api-client/src/runtime/client.ts | 45 +++++- 13 files changed, 334 insertions(+), 122 deletions(-) create mode 100644 apps/portal/src/features/account/services/account.service.ts diff --git a/apps/portal/src/core/api/client.ts b/apps/portal/src/core/api/client.ts index 946a24da..b1d846c6 100644 --- a/apps/portal/src/core/api/client.ts +++ b/apps/portal/src/core/api/client.ts @@ -1,13 +1,27 @@ /** - * Core API Client - * Minimal API client setup using the generated OpenAPI client + * Core API client configuration + * Wraps the shared generated client to inject portal-specific behavior like auth headers. */ -import { createClient } from "@customer-portal/api-client"; +import { + createClient as createOpenApiClient, + type ApiClient as GeneratedApiClient, + type AuthHeaderResolver, +} from "@customer-portal/api-client"; import { env } from "../config/env"; -// Create the type-safe API client -export const apiClient = createClient(env.NEXT_PUBLIC_API_BASE); +const baseUrl = env.NEXT_PUBLIC_API_BASE; -// Export the client type for use in hooks -export type { ApiClient } from "@customer-portal/api-client"; +let authHeaderResolver: AuthHeaderResolver | undefined; + +const resolveAuthHeader: AuthHeaderResolver = () => authHeaderResolver?.(); + +export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, { + getAuthHeader: resolveAuthHeader, +}); + +export const configureApiClientAuth = (resolver?: AuthHeaderResolver) => { + authHeaderResolver = resolver; +}; + +export type ApiClient = GeneratedApiClient; diff --git a/apps/portal/src/core/api/index.ts b/apps/portal/src/core/api/index.ts index e3689269..75bf298e 100644 --- a/apps/portal/src/core/api/index.ts +++ b/apps/portal/src/core/api/index.ts @@ -1,3 +1,4 @@ -export { apiClient } from "./client"; +export { apiClient, configureApiClientAuth } from "./client"; export { queryKeys } from "./query-keys"; export type { ApiClient } from "./client"; +export type { AuthHeaderResolver } from "@customer-portal/api-client"; diff --git a/apps/portal/src/features/account/hooks/useProfileData.ts b/apps/portal/src/features/account/hooks/useProfileData.ts index dcf9e216..4623182e 100644 --- a/apps/portal/src/features/account/hooks/useProfileData.ts +++ b/apps/portal/src/features/account/hooks/useProfileData.ts @@ -85,22 +85,11 @@ export function useProfileData() { const saveProfile = async (next: ProfileFormData) => { setIsSavingProfile(true); try { - const { tokens } = useAuthStore.getState(); - if (!tokens?.accessToken) throw new Error("Authentication required"); - const response = await fetch("/api/me", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${tokens.accessToken}`, - }, - body: JSON.stringify({ - firstName: next.firstName, - lastName: next.lastName, - phone: next.phone, - }), + const updatedUser = await accountService.updateProfile({ + firstName: next.firstName, + lastName: next.lastName, + phone: next.phone, }); - if (!response.ok) throw new Error("Failed to update profile"); - const updatedUser = (await response.json()) as Partial; useAuthStore.setState(state => ({ ...state, user: state.user ? { ...state.user, ...updatedUser } : state.user, diff --git a/apps/portal/src/features/account/services/account.service.ts b/apps/portal/src/features/account/services/account.service.ts new file mode 100644 index 00000000..c5ddbce4 --- /dev/null +++ b/apps/portal/src/features/account/services/account.service.ts @@ -0,0 +1,37 @@ +import { apiClient } from "@/core/api"; +import type { Address, AuthUser } from "@customer-portal/domain"; + +const ensureData = (data: T | undefined): T => { + if (typeof data === "undefined") { + throw new Error("No data returned from server"); + } + return data; +}; + +type ProfileUpdateInput = { + firstName?: string; + lastName?: string; + phone?: string; +}; + +export const accountService = { + async getProfile() { + const response = await apiClient.GET('/me'); + return ensureData(response.data); + }, + + async updateProfile(update: ProfileUpdateInput) { + const response = await apiClient.PATCH('/me', { body: update }); + return ensureData(response.data); + }, + + async getAddress() { + const response = await apiClient.GET('/me/address'); + return ensureData
(response.data); + }, + + async updateAddress(address: Address) { + const response = await apiClient.PATCH('/me/address', { body: address }); + return ensureData
(response.data); + }, +}; diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index b5283aed..af1e644c 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -5,7 +5,7 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; -import { apiClient } from "@/core/api"; +import { apiClient, configureApiClientAuth } from "@/core/api"; import type { AuthTokens, SignupRequest, @@ -17,6 +17,62 @@ import type { AuthError, } from "@customer-portal/domain"; +const DEFAULT_TOKEN_TYPE = "Bearer"; + +type RawAuthTokens = + | AuthTokens + | (Omit & { expiresAt: string | number | Date }); + +const toIsoString = (value: string | number | Date) => { + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === "number") { + return new Date(value).toISOString(); + } + + return value; +}; + +const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => ({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + tokenType: tokens.tokenType ?? DEFAULT_TOKEN_TYPE, + expiresAt: toIsoString(tokens.expiresAt), +}); + +const getExpiryTime = (tokens?: AuthTokens | null) => { + if (!tokens?.expiresAt) { + return null; + } + + const expiry = Date.parse(tokens.expiresAt); + return Number.isNaN(expiry) ? null : expiry; +}; + +const hasValidAccessToken = (tokens?: AuthTokens | null) => { + if (!tokens?.accessToken) { + return false; + } + + const expiry = getExpiryTime(tokens); + if (expiry === null) { + return false; + } + + return Date.now() < expiry; +}; + +const buildAuthorizationHeader = (tokens?: AuthTokens | null) => { + if (!tokens?.accessToken) { + return undefined; + } + + const tokenType = tokens.tokenType ?? DEFAULT_TOKEN_TYPE; + return `${tokenType} ${tokens.accessToken}`; +}; + interface AuthState { isAuthenticated: boolean; user: AuthUser | null; @@ -54,7 +110,7 @@ interface AuthStoreState extends AuthState { setLoading: (loading: boolean) => void; setError: (error: string | null) => void; setUser: (user: AuthUser | null) => void; - setTokens: (tokens: AuthTokens | null) => void; + setTokens: (tokens: RawAuthTokens | null) => void; } export const useAuthStore = create()( @@ -76,9 +132,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/login', { body: credentials }); if (response.data) { + const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens); set({ user: response.data.user, - tokens: response.data.tokens, + tokens, isAuthenticated: true, loading: false, error: null, @@ -104,9 +161,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/signup', { body: data }); if (response.data) { + const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens); set({ user: response.data.user, - tokens: response.data.tokens, + tokens, isAuthenticated: true, loading: false, error: null, @@ -164,9 +222,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/reset-password', { body: data }); const { user, tokens } = response.data!; + const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens); set({ user, - tokens, + tokens: normalizedTokens, isAuthenticated: true, loading: false, error: null, @@ -194,9 +253,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/change-password', { body: data }); const { user, tokens: newTokens } = response.data!; + const normalizedTokens = normalizeAuthTokens(newTokens as RawAuthTokens); set({ user, - tokens: newTokens, + tokens: normalizedTokens, loading: false, error: null, }); @@ -240,9 +300,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/set-password', { body: { email, password } }); const { user, tokens } = response.data!; + const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens); set({ user, - tokens, + tokens: normalizedTokens, isAuthenticated: true, loading: false, error: null, @@ -264,22 +325,26 @@ export const useAuthStore = create()( checkAuth: async () => { const { tokens } = get(); - if (!tokens?.accessToken) { - set({ isAuthenticated: false, loading: false, user: null, tokens: null, hasCheckedAuth: true }); - return; - } - - // Check if token is expired - if (Date.now() >= tokens.expiresAt) { - set({ isAuthenticated: false, loading: false, user: null, tokens: null, hasCheckedAuth: true }); + if (!hasValidAccessToken(tokens)) { + set({ + isAuthenticated: false, + loading: false, + user: null, + tokens: null, + hasCheckedAuth: true, + }); return; } set({ loading: true }); try { const response = await apiClient.GET('/me'); - const user = response.data!; - set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); + const user = response.data ?? null; + if (user) { + set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); + } else { + set({ loading: false, hasCheckedAuth: true }); + } } catch (error) { // Token is invalid, clear auth state console.info("Token validation failed, clearing auth state"); @@ -301,8 +366,14 @@ export const useAuthStore = create()( return; } + const expiry = getExpiryTime(tokens); + if (expiry === null) { + await checkAuth(); + return; + } + // Check if token needs refresh (expires within 5 minutes) - if (Date.now() >= tokens.expiresAt - 5 * 60 * 1000) { // 5 minutes before expiry + if (Date.now() >= expiry - 5 * 60 * 1000) { // For now, just re-validate the token // In a real implementation, you would call a refresh token endpoint await checkAuth(); @@ -318,7 +389,8 @@ export const useAuthStore = create()( setUser: (user: AuthUser | null) => set({ user }), - setTokens: (tokens: AuthTokens | null) => set({ tokens }), + setTokens: (tokens: RawAuthTokens | null) => + set({ tokens: tokens ? normalizeAuthTokens(tokens) : null }), }), { name: "auth-store", @@ -352,18 +424,45 @@ export const useAuthStore = create()( ) ); +const resolveAuthorizationHeader = () => + buildAuthorizationHeader(useAuthStore.getState().tokens); + +configureApiClientAuth(resolveAuthorizationHeader); + +export const getAuthorizationHeader = resolveAuthorizationHeader; + +export const selectAuthTokens = (state: AuthStoreState) => state.tokens; +export const selectIsAuthenticated = (state: AuthStoreState) => state.isAuthenticated; +export const selectHasValidAccessToken = (state: AuthStoreState) => + hasValidAccessToken(state.tokens); + +export const useAuthSession = () => + useAuthStore(state => ({ + isAuthenticated: state.isAuthenticated, + hasValidToken: hasValidAccessToken(state.tokens), + tokens: state.tokens, + })); + // Session timeout detection let sessionTimeoutId: NodeJS.Timeout | null = null; export const startSessionTimeout = () => { const checkSession = () => { const state = useAuthStore.getState(); - if (state.tokens?.accessToken) { - if (Date.now() >= state.tokens.expiresAt) { - void state.logout(); - } else { - void state.refreshSession(); - } + if (!state.tokens?.accessToken) { + return; + } + + const expiry = getExpiryTime(state.tokens); + if (expiry === null) { + void state.logout(); + return; + } + + if (Date.now() >= expiry) { + void state.logout(); + } else { + void state.refreshSession(); } }; diff --git a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx index c0d8309e..06c4b157 100644 --- a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx +++ b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx @@ -42,8 +42,11 @@ export function ChangePlanModal({ } setLoading(true); try { - await apiClient.post(`/subscriptions/${subscriptionId}/sim/change-plan`, { - newPlanCode: newPlanCode, + await apiClient.POST("/subscriptions/{id}/sim/change-plan", { + params: { path: { id: subscriptionId } }, + body: { + newPlanCode, + }, }); onSuccess(); } catch (e: unknown) { diff --git a/apps/portal/src/features/sim-management/components/SimActions.tsx b/apps/portal/src/features/sim-management/components/SimActions.tsx index 105d3791..5b436065 100644 --- a/apps/portal/src/features/sim-management/components/SimActions.tsx +++ b/apps/portal/src/features/sim-management/components/SimActions.tsx @@ -58,7 +58,9 @@ export function SimActions({ setError(null); try { - await apiClient.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`); + await apiClient.POST("/subscriptions/{id}/sim/reissue-esim", { + params: { path: { id: subscriptionId } }, + }); setSuccess("eSIM profile reissued successfully"); setShowReissueConfirm(false); @@ -75,7 +77,9 @@ export function SimActions({ setError(null); try { - await apiClient.post(`/subscriptions/${subscriptionId}/sim/cancel`, {}); + await apiClient.POST("/subscriptions/{id}/sim/cancel", { + params: { path: { id: subscriptionId } }, + }); setSuccess("SIM service cancelled successfully"); setShowCancelConfirm(false); diff --git a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx index 2db603c5..6ad58384 100644 --- a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx +++ b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx @@ -75,10 +75,10 @@ export function SimFeatureToggles({ if (nt !== initial.nt) featurePayload.networkType = nt; if (Object.keys(featurePayload).length > 0) { - await apiClient.post( - `/subscriptions/${subscriptionId}/sim/features`, - featurePayload - ); + await apiClient.POST("/subscriptions/{id}/sim/features", { + params: { path: { id: subscriptionId } }, + body: featurePayload, + }); } setSuccess("Changes submitted successfully"); diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index 6d762193..741dbf39 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -30,24 +30,31 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro try { setError(null); - const data = await apiClient.get<{ - details: SimDetails; - usage: SimUsage; - }>(`/subscriptions/${subscriptionId}/sim`); + const response = await apiClient.GET("/subscriptions/{id}/sim", { + params: { path: { id: subscriptionId } }, + }); - setSimInfo(data); + const payload = response.data as SimInfo | undefined; + + if (!payload) { + throw new Error("Failed to load SIM information"); + } + + setSimInfo(payload); } catch (err: unknown) { - const hasStatus = (v: unknown): v is { status: number } => - typeof v === "object" && - v !== null && - "status" in v && - typeof (v as { status: unknown }).status === "number"; + const hasStatus = (value: unknown): value is { status: number } => + typeof value === "object" && + value !== null && + "status" in value && + typeof (value as { status: unknown }).status === "number"; + if (hasStatus(err) && err.status === 400) { // Not a SIM subscription - this component shouldn't be shown setError("This subscription is not a SIM service"); - } else { - setError(err instanceof Error ? err.message : "Failed to load SIM information"); + return; } + + setError(err instanceof Error ? err.message : "Failed to load SIM information"); } finally { setLoading(false); } diff --git a/apps/portal/src/features/sim-management/components/TopUpModal.tsx b/apps/portal/src/features/sim-management/components/TopUpModal.tsx index 81d95c7d..da0b7bbc 100644 --- a/apps/portal/src/features/sim-management/components/TopUpModal.tsx +++ b/apps/portal/src/features/sim-management/components/TopUpModal.tsx @@ -45,7 +45,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU quotaMb: getCurrentAmountMb(), }; - await apiClient.post(`/subscriptions/${subscriptionId}/sim/top-up`, requestBody); + await apiClient.POST("/subscriptions/{id}/sim/top-up", { + params: { path: { id: subscriptionId } }, + body: requestBody, + }); onSuccess(); } catch (error: unknown) { diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 32ac755d..7c84dc5a 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -4,7 +4,7 @@ */ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useAuthStore } from "@/features/auth/services/auth.store"; +import { useAuthSession } from "@/features/auth/services/auth.store"; import { apiClient } from "@/core/api"; import type { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain"; @@ -17,26 +17,29 @@ interface UseSubscriptionsOptions { */ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { const { status } = options; - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscriptions", status], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const params = new URLSearchParams({ - ...(status && { status }), - }); - const res = await apiClient.get( - `/subscriptions?${params}` + const response = await apiClient.GET( + "/subscriptions", + status ? { params: { query: { status } } } : undefined ); - return res.data as SubscriptionList | Subscription[]; + + if (!response.data) { + return [] as Subscription[]; + } + + return response.data as SubscriptionList | Subscription[]; }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -44,21 +47,21 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { * Hook to fetch active subscriptions only */ export function useActiveSubscriptions() { - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscriptions", "active"], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const res = await apiClient.get(`/subscriptions/active`); - return res.data as Subscription[]; + const response = await apiClient.GET("/subscriptions/active"); + return (response.data ?? []) as Subscription[]; }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -66,7 +69,7 @@ export function useActiveSubscriptions() { * Hook to fetch subscription statistics */ export function useSubscriptionStats() { - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery<{ total: number; @@ -77,18 +80,18 @@ export function useSubscriptionStats() { }>({ queryKey: ["subscriptions", "stats"], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const res = await apiClient.get<{ - total: number; - active: number; - suspended: number; - cancelled: number; - pending: number; - }>(`/subscriptions/stats`); - return res.data as { + const response = await apiClient.GET("/subscriptions/stats"); + return (response.data ?? { + total: 0, + active: 0, + suspended: 0, + cancelled: 0, + pending: 0, + }) as { total: number; active: number; suspended: number; @@ -98,7 +101,7 @@ export function useSubscriptionStats() { }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -106,21 +109,28 @@ export function useSubscriptionStats() { * Hook to fetch a specific subscription */ export function useSubscription(subscriptionId: number) { - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscription", subscriptionId], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const res = await apiClient.get(`/subscriptions/${subscriptionId}`); - return res.data as Subscription; + const response = await apiClient.GET("/subscriptions/{id}", { + params: { path: { id: subscriptionId } }, + }); + + if (!response.data) { + throw new Error("Subscription not found"); + } + + return response.data as Subscription; }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -132,27 +142,36 @@ export function useSubscriptionInvoices( options: { page?: number; limit?: number } = {} ) { const { page = 1, limit = 10 } = options; - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscription-invoices", subscriptionId, page, limit], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const params = new URLSearchParams({ - page: page.toString(), - limit: limit.toString(), + const response = await apiClient.GET("/subscriptions/{id}/invoices", { + params: { + path: { id: subscriptionId }, + query: { page, limit }, + }, }); - const res = await apiClient.get( - `/subscriptions/${subscriptionId}/invoices?${params}` - ); - return unwrap(res) as InvoiceList; + + return ( + response.data ?? { + invoices: [], + pagination: { + page, + totalPages: 0, + totalItems: 0, + }, + } + ) as InvoiceList; }, staleTime: 60 * 1000, // 1 minute gcTime: 5 * 60 * 1000, // 5 minutes - enabled: isAuthenticated && !!tokens?.accessToken && !!subscriptionId, + enabled: isAuthenticated && hasValidToken && !!subscriptionId, }); } @@ -164,8 +183,10 @@ export function useSubscriptionAction() { return useMutation({ mutationFn: async ({ id, action }: { id: number; action: string }) => { - const res = await apiClient.post(`/subscriptions/${id}/actions`, { action }); - return unwrap(res); + await apiClient.POST("/subscriptions/{id}/actions", { + params: { path: { id } }, + body: { action }, + }); }, onSuccess: (_, { id }) => { // Invalidate relevant queries after successful action diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 520cf73c..a9ba5fb9 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,6 +1,7 @@ -// Re-export generated client and types export * as ApiTypes from "./__generated__/types"; export { createClient } from "./runtime/client"; -export type { ApiClient } from "./runtime/client"; - - +export type { + ApiClient, + AuthHeaderResolver, + CreateClientOptions, +} from "./runtime/client"; diff --git a/packages/api-client/src/runtime/client.ts b/packages/api-client/src/runtime/client.ts index bf4d87af..cf638b1a 100644 --- a/packages/api-client/src/runtime/client.ts +++ b/packages/api-client/src/runtime/client.ts @@ -1,12 +1,45 @@ -// Defer importing openapi-fetch until codegen deps are installed. -import createFetchClient from "openapi-fetch"; +import createOpenApiClient from "openapi-fetch"; import type { paths } from "../__generated__/types"; -export type ApiClient = ReturnType>; +export type ApiClient = ReturnType>; -export function createClient(baseUrl: string, _getAuthHeader?: () => string | undefined): ApiClient { - // Consumers can pass headers per call using the client's request options. - return createFetchClient({ baseUrl }); +export type AuthHeaderResolver = () => string | undefined; + +export interface CreateClientOptions { + getAuthHeader?: AuthHeaderResolver; } +export function createClient( + baseUrl: string, + options: CreateClientOptions = {} +): ApiClient { + const client = createOpenApiClient({ + baseUrl, + throwOnError: true, + }); + if (typeof client.use === "function" && options.getAuthHeader) { + const resolveAuthHeader = options.getAuthHeader; + + client.use({ + onRequest({ request }: { request: Request }) { + if (!request || typeof request.headers?.has !== "function") { + return; + } + + if (request.headers.has("Authorization")) { + return; + } + + const headerValue = resolveAuthHeader(); + if (!headerValue) { + return; + } + + request.headers.set("Authorization", headerValue); + }, + } as never); + } + + return client; +} From b6e50df81dc9fc1acae188e13d5d33c9c0e4d2ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 18 Sep 2025 07:29:49 +0000 Subject: [PATCH 2/5] Fix: Clear auth state when no user data is returned Co-authored-by: tumurbarsnarantuya --- apps/portal/src/features/auth/services/auth.store.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index af1e644c..bbbb01d4 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -343,7 +343,15 @@ export const useAuthStore = create()( if (user) { set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); } else { - set({ loading: false, hasCheckedAuth: true }); + // No user data returned, clear auth state + set({ + user: null, + tokens: null, + isAuthenticated: false, + loading: false, + error: null, + hasCheckedAuth: true, + }); } } catch (error) { // Token is invalid, clear auth state From 0749bb1fa0cf3da705a4a74b19baf1b0f7a07b8d Mon Sep 17 00:00:00 2001 From: NTumurbars <156628271+NTumurbars@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:39:18 +0900 Subject: [PATCH 3/5] Normalize auth token expiry handling --- apps/bff/src/modules/auth/auth.service.ts | 79 ++++++++++++-- .../auth/components/SessionTimeoutWarning.tsx | 102 +++++++++--------- .../src/features/auth/services/auth.store.ts | 7 +- packages/domain/src/common.ts | 3 + packages/domain/src/entities/user.ts | 4 +- 5 files changed, 131 insertions(+), 64 deletions(-) diff --git a/apps/bff/src/modules/auth/auth.service.ts b/apps/bff/src/modules/auth/auth.service.ts index a862e099..ad2be45f 100644 --- a/apps/bff/src/modules/auth/auth.service.ts +++ b/apps/bff/src/modules/auth/auth.service.ts @@ -23,7 +23,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util"; import { EmailService } from "@bff/infra/email/email.service"; -import { User as SharedUser } from "@customer-portal/domain"; +import type { AuthTokens, User as SharedUser } from "@customer-portal/domain"; import type { User as PrismaUser } from "@prisma/client"; import type { Request } from "express"; import { WhmcsClientResponse } from "@bff/integrations/whmcs/types/whmcs-api.types"; @@ -33,6 +33,8 @@ import { PrismaService } from "@bff/infra/database/prisma.service"; export class AuthService { private readonly MAX_LOGIN_ATTEMPTS = 5; private readonly LOCKOUT_DURATION_MINUTES = 15; + private readonly DEFAULT_TOKEN_TYPE = "Bearer"; + private readonly DEFAULT_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; constructor( private usersService: UsersService, @@ -384,7 +386,7 @@ export class AuthService { user: this.sanitizeUser( freshUser ?? ({ id: createdUserId, email } as unknown as PrismaUser) ), - ...tokens, + tokens, }; } catch (error) { // Log failed signup @@ -433,7 +435,7 @@ export class AuthService { const tokens = this.generateTokens(user); return { user: this.sanitizeUser(user), - ...tokens, + tokens, }; } @@ -589,7 +591,7 @@ export class AuthService { return { user: this.sanitizeUser(updatedUser), - ...tokens, + tokens, }; } @@ -717,10 +719,16 @@ export class AuthService { } // Helper methods - private generateTokens(user: { id: string; email: string; role?: string }) { + private generateTokens(user: { id: string; email: string; role?: string }): AuthTokens { const payload = { email: user.email, sub: user.id, role: user.role }; + const accessToken = this.jwtService.sign(payload); + + const tokenType = this.configService.get("JWT_TOKEN_TYPE") ?? this.DEFAULT_TOKEN_TYPE; + return { - access_token: this.jwtService.sign(payload), + accessToken, + tokenType, + expiresAt: this.resolveAccessTokenExpiry(accessToken), }; } @@ -797,6 +805,61 @@ export class AuthService { return sanitizeWhmcsRedirectPath(path); } + private resolveAccessTokenExpiry(accessToken: string): string { + try { + const decoded = this.jwtService.decode(accessToken); + if (decoded && typeof decoded === "object" && typeof decoded.exp === "number") { + return new Date(decoded.exp * 1000).toISOString(); + } + } catch (error) { + this.logger.debug("Failed to decode JWT for expiry", { error: getErrorMessage(error) }); + } + + const configuredExpiry = this.configService.get("JWT_EXPIRES_IN", "7d"); + const fallbackMs = this.parseExpiresInToMs(configuredExpiry); + return new Date(Date.now() + fallbackMs).toISOString(); + } + + private parseExpiresInToMs(expiresIn: string | number | undefined): number { + if (typeof expiresIn === "number" && Number.isFinite(expiresIn)) { + return expiresIn * 1000; + } + + if (!expiresIn) { + return this.DEFAULT_TOKEN_EXPIRY_MS; + } + + const raw = expiresIn.toString().trim(); + if (!raw) { + return this.DEFAULT_TOKEN_EXPIRY_MS; + } + + const unit = raw.slice(-1); + const magnitude = Number(raw.slice(0, -1)); + + if (Number.isFinite(magnitude)) { + switch (unit) { + case "s": + return magnitude * 1000; + case "m": + return magnitude * 60 * 1000; + case "h": + return magnitude * 60 * 60 * 1000; + case "d": + return magnitude * 24 * 60 * 60 * 1000; + default: + break; + } + } + + const numericValue = Number(raw); + if (Number.isFinite(numericValue)) { + return numericValue * 1000; + } + + return this.DEFAULT_TOKEN_EXPIRY_MS; + } + async requestPasswordReset(email: string): Promise { const user = await this.usersService.findByEmailInternal(email); // Always act as if successful to avoid account enumeration @@ -854,7 +917,7 @@ export class AuthService { return { user: this.sanitizeUser(updatedUser), - ...tokens, + tokens, }; } catch (error) { this.logger.error("Reset password failed", { error: getErrorMessage(error) }); @@ -980,7 +1043,7 @@ export class AuthService { const tokens = this.generateTokens(updatedUser); return { user: this.sanitizeUser(updatedUser), - ...tokens, + tokens, }; } diff --git a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx index 5a08d27e..e8a25074 100644 --- a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx +++ b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx @@ -1,7 +1,7 @@ "use client"; import { logger } from "@customer-portal/logging"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useAuthStore } from "@/features/auth/services/auth.store"; import { Button } from "@/components/ui/button"; @@ -15,70 +15,70 @@ export function SessionTimeoutWarning({ const { isAuthenticated, tokens, logout, checkAuth } = useAuthStore(); const [showWarning, setShowWarning] = useState(false); const [timeLeft, setTimeLeft] = useState(0); + const expiryRef = useRef(null); useEffect(() => { - if (!isAuthenticated || !tokens?.accessToken) { + if (!isAuthenticated || !tokens?.expiresAt) { + expiryRef.current = null; + setShowWarning(false); + setTimeLeft(0); return undefined; } - // Parse JWT to get expiry time - try { - const parts = tokens.accessToken.split("."); - if (parts.length !== 3) { - throw new Error("Invalid token format"); - } + const expiryTime = Date.parse(tokens.expiresAt); + if (Number.isNaN(expiryTime)) { + logger.warn("Invalid expiresAt on auth tokens", { expiresAt: tokens.expiresAt }); + expiryRef.current = null; + setShowWarning(false); + setTimeLeft(0); + return undefined; + } - const payload = JSON.parse(atob(parts[1])) as { exp?: number }; - if (!payload.exp) { - logger.warn("Token does not have expiration time"); - return undefined; - } + expiryRef.current = expiryTime; - const expiryTime = payload.exp * 1000; // Convert to milliseconds - const currentTime = Date.now(); - const warningThreshold = warningTime * 60 * 1000; // Convert to milliseconds - - const timeUntilExpiry = expiryTime - currentTime; - const timeUntilWarning = timeUntilExpiry - warningThreshold; - - if (timeUntilExpiry <= 0) { - // Token already expired - void logout(); - return undefined; - } - - if (timeUntilWarning <= 0) { - // Should show warning immediately - setShowWarning(true); - setTimeLeft(Math.ceil(timeUntilExpiry / 1000 / 60)); // Minutes left - return undefined; - } else { - // Set timeout to show warning - const warningTimeout = setTimeout(() => { - setShowWarning(true); - setTimeLeft(warningTime); - }, timeUntilWarning); - - return () => clearTimeout(warningTimeout); - } - } catch (error) { - logger.error(error, "Error parsing JWT token"); + if (Date.now() >= expiryTime) { void logout(); return undefined; } - }, [isAuthenticated, tokens, warningTime, logout]); + + const warningThreshold = warningTime * 60 * 1000; + const now = Date.now(); + const timeUntilExpiry = expiryTime - now; + const timeUntilWarning = timeUntilExpiry - warningThreshold; + + if (timeUntilWarning <= 0) { + setShowWarning(true); + setTimeLeft(Math.max(1, Math.ceil(timeUntilExpiry / (60 * 1000)))); + return undefined; + } + + const warningTimeout = setTimeout(() => { + setShowWarning(true); + setTimeLeft( + Math.max(1, Math.ceil((expiryRef.current! - Date.now()) / (60 * 1000))) + ); + }, timeUntilWarning); + + return () => clearTimeout(warningTimeout); + }, [isAuthenticated, tokens?.expiresAt, warningTime, logout]); useEffect(() => { - if (!showWarning) return undefined; + if (!showWarning || !expiryRef.current) return undefined; const interval = setInterval(() => { - setTimeLeft(prev => { - if (prev <= 1) { - void logout(); - return 0; - } - return prev - 1; - }); + const expiryTime = expiryRef.current; + if (!expiryTime) { + return; + } + + const remaining = expiryTime - Date.now(); + if (remaining <= 0) { + setTimeLeft(0); + void logout(); + return; + } + + setTimeLeft(Math.max(1, Math.ceil(remaining / (60 * 1000)))); }, 60000); return () => clearInterval(interval); diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index af1e644c..0d21afdb 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -15,15 +15,16 @@ import type { ResetPasswordRequest, ChangePasswordRequest, AuthError, + IsoDateTimeString, } from "@customer-portal/domain"; const DEFAULT_TOKEN_TYPE = "Bearer"; type RawAuthTokens = | AuthTokens - | (Omit & { expiresAt: string | number | Date }); + | (Omit & { expiresAt: IsoDateTimeString | number | Date }); -const toIsoString = (value: string | number | Date) => { +const toIsoString = (value: string | number | Date): IsoDateTimeString => { if (value instanceof Date) { return value.toISOString(); } @@ -32,7 +33,7 @@ const toIsoString = (value: string | number | Date) => { return new Date(value).toISOString(); } - return value; + return value as IsoDateTimeString; }; const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => ({ diff --git a/packages/domain/src/common.ts b/packages/domain/src/common.ts index a89b954b..3f1980c5 100644 --- a/packages/domain/src/common.ts +++ b/packages/domain/src/common.ts @@ -46,6 +46,9 @@ export const isOrderId = (id: string): id is OrderId => typeof id === 'string'; export const isInvoiceId = (id: string): id is InvoiceId => typeof id === 'string'; export const isWhmcsClientId = (id: number): id is WhmcsClientId => typeof id === 'number'; +// Shared ISO8601 timestamp string type used for serialized dates +export type IsoDateTimeString = string; + // ===================================================== // BASE ENTITY INTERFACES // ===================================================== diff --git a/packages/domain/src/entities/user.ts b/packages/domain/src/entities/user.ts index 79b00b5c..556f71b2 100644 --- a/packages/domain/src/entities/user.ts +++ b/packages/domain/src/entities/user.ts @@ -1,5 +1,5 @@ // User and authentication types -import type { BaseEntity, Address } from "../common"; +import type { BaseEntity, Address, IsoDateTimeString } from "../common"; export interface User extends BaseEntity { email: string; @@ -46,7 +46,7 @@ export interface Activity { export interface AuthTokens { accessToken: string; refreshToken?: string; - expiresAt: string; // ISO + expiresAt: IsoDateTimeString; tokenType?: string; } From e9acbd899c5b191c1cff8f068290a7d402852ba8 Mon Sep 17 00:00:00 2001 From: NTumurbars <156628271+NTumurbars@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:39:57 +0900 Subject: [PATCH 4/5] refactor: layer portal routes through feature modules --- apps/portal/ARCHITECTURE.md | 72 ++-- .../account/loading.tsx | 0 .../account/page.tsx | 0 .../account/profile/page.tsx | 0 .../billing/invoices/[id]/loading.tsx | 0 .../billing/invoices/[id]/page.tsx | 0 .../billing/invoices/loading.tsx | 0 .../billing/invoices/page.tsx | 0 .../billing/payments/loading.tsx | 0 .../billing/payments/page.tsx | 0 .../catalog/internet/configure/page.tsx | 0 .../catalog/internet/page.tsx | 0 .../catalog/loading.tsx | 0 .../catalog/page.tsx | 0 .../catalog/sim/configure/page.tsx | 0 .../catalog/sim/page.tsx | 0 .../catalog/vpn/page.tsx | 0 .../checkout/loading.tsx | 0 .../checkout/page.tsx | 0 .../dashboard/loading.tsx | 0 .../app/(authenticated)/dashboard/page.tsx | 5 + .../{(portal) => (authenticated)}/layout.tsx | 0 .../orders/[id]/loading.tsx | 0 .../orders/[id]/page.tsx | 0 .../orders/loading.tsx | 0 .../orders/page.tsx | 0 .../subscriptions/[id]/loading.tsx | 0 .../subscriptions/[id]/page.tsx | 0 .../subscriptions/[id]/sim/cancel/page.tsx | 0 .../[id]/sim/change-plan/page.tsx | 0 .../subscriptions/[id]/sim/reissue/page.tsx | 0 .../subscriptions/[id]/sim/top-up/page.tsx | 0 .../subscriptions/loading.tsx | 0 .../subscriptions/page.tsx | 0 .../support/cases/loading.tsx | 0 .../(authenticated)/support/cases/page.tsx | 5 + .../support/new/loading.tsx | 0 .../app/(authenticated)/support/new/page.tsx | 5 + .../src/app/(portal)/support/new/page.tsx | 260 ------------- .../auth/forgot-password/page.tsx | 0 .../{ => (public)}/auth/link-whmcs/page.tsx | 0 .../src/app/{ => (public)}/auth/loading.tsx | 0 .../app/{ => (public)}/auth/login/page.tsx | 0 .../auth/reset-password/page.tsx | 0 .../{ => (public)}/auth/set-password/page.tsx | 0 .../app/{ => (public)}/auth/signup/page.tsx | 0 apps/portal/src/app/(public)/loading.tsx | 5 + apps/portal/src/app/(public)/page.tsx | 5 + apps/portal/src/app/page.tsx | 288 -------------- apps/portal/src/features/dashboard/index.ts | 1 + .../dashboard/views/DashboardView.tsx} | 59 +-- .../src/features/dashboard/views/index.ts | 1 + apps/portal/src/features/index.ts | 2 + apps/portal/src/features/marketing/index.ts | 2 + .../views/PublicLandingLoadingView.tsx} | 11 +- .../marketing/views/PublicLandingView.tsx | 368 ++++++++++++++++++ apps/portal/src/features/support/index.ts | 10 +- .../support/views/NewSupportCaseView.tsx | 247 ++++++++++++ .../support/views/SupportCasesView.tsx} | 28 +- .../src/features/support/views/index.ts | 2 + apps/portal/tsconfig.json | 6 + docs/SUBSCRIPTION-SERVICE-MANAGEMENT.md | 2 +- eslint.config.mjs | 12 +- 63 files changed, 761 insertions(+), 635 deletions(-) rename apps/portal/src/app/{(portal) => (authenticated)}/account/loading.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/account/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/account/profile/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/billing/invoices/[id]/loading.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/billing/invoices/[id]/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/billing/invoices/loading.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/billing/invoices/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/billing/payments/loading.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/billing/payments/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/catalog/internet/configure/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/catalog/internet/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/catalog/loading.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/catalog/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/catalog/sim/configure/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/catalog/sim/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/catalog/vpn/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/checkout/loading.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/checkout/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/dashboard/loading.tsx (100%) create mode 100644 apps/portal/src/app/(authenticated)/dashboard/page.tsx rename apps/portal/src/app/{(portal) => (authenticated)}/layout.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/orders/[id]/loading.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/orders/[id]/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/orders/loading.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/orders/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/subscriptions/[id]/loading.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/subscriptions/[id]/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/subscriptions/[id]/sim/cancel/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/subscriptions/[id]/sim/change-plan/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/subscriptions/[id]/sim/reissue/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/subscriptions/[id]/sim/top-up/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/subscriptions/loading.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/subscriptions/page.tsx (100%) rename apps/portal/src/app/{(portal) => (authenticated)}/support/cases/loading.tsx (100%) create mode 100644 apps/portal/src/app/(authenticated)/support/cases/page.tsx rename apps/portal/src/app/{(portal) => (authenticated)}/support/new/loading.tsx (100%) create mode 100644 apps/portal/src/app/(authenticated)/support/new/page.tsx delete mode 100644 apps/portal/src/app/(portal)/support/new/page.tsx rename apps/portal/src/app/{ => (public)}/auth/forgot-password/page.tsx (100%) rename apps/portal/src/app/{ => (public)}/auth/link-whmcs/page.tsx (100%) rename apps/portal/src/app/{ => (public)}/auth/loading.tsx (100%) rename apps/portal/src/app/{ => (public)}/auth/login/page.tsx (100%) rename apps/portal/src/app/{ => (public)}/auth/reset-password/page.tsx (100%) rename apps/portal/src/app/{ => (public)}/auth/set-password/page.tsx (100%) rename apps/portal/src/app/{ => (public)}/auth/signup/page.tsx (100%) create mode 100644 apps/portal/src/app/(public)/loading.tsx create mode 100644 apps/portal/src/app/(public)/page.tsx delete mode 100644 apps/portal/src/app/page.tsx rename apps/portal/src/{app/(portal)/dashboard/page.tsx => features/dashboard/views/DashboardView.tsx} (90%) create mode 100644 apps/portal/src/features/dashboard/views/index.ts create mode 100644 apps/portal/src/features/marketing/index.ts rename apps/portal/src/{app/loading.tsx => features/marketing/views/PublicLandingLoadingView.tsx} (85%) create mode 100644 apps/portal/src/features/marketing/views/PublicLandingView.tsx create mode 100644 apps/portal/src/features/support/views/NewSupportCaseView.tsx rename apps/portal/src/{app/(portal)/support/cases/page.tsx => features/support/views/SupportCasesView.tsx} (95%) create mode 100644 apps/portal/src/features/support/views/index.ts diff --git a/apps/portal/ARCHITECTURE.md b/apps/portal/ARCHITECTURE.md index 4b725daf..c2d3f421 100644 --- a/apps/portal/ARCHITECTURE.md +++ b/apps/portal/ARCHITECTURE.md @@ -6,26 +6,31 @@ This document outlines the new feature-driven architecture implemented for the c ``` apps/portal/src/ -├── app/ # Next.js App Router pages -├── components/ # Shared UI components (Design System) -│ ├── ui/ # Base UI components (atoms) -│ ├── layout/ # Layout components (organisms) -│ └── common/ # Shared business components (molecules) -├── features/ # Feature-specific modules -│ ├── auth/ # Authentication feature -│ ├── dashboard/ # Dashboard feature -│ ├── billing/ # Billing feature -│ ├── subscriptions/ # Subscriptions feature -│ ├── catalog/ # Product catalog feature -│ └── support/ # Support feature -├── lib/ # Core utilities and services (replaces core/shared) -│ ├── api/ # API client and base services -│ ├── query.ts # Query client and keys -│ ├── env.ts # Runtime env parsing -│ ├── types/ # Shared TypeScript types -│ └── utils/ # Utility functions (cn, currency, error-display, ...) -├── providers/ # React context providers (e.g., QueryProvider) -└── styles/ # Global styles and design tokens +├── app/ # Next.js App Router entry points (route groups only) +│ ├── (public)/ # Marketing + auth routes, pages import feature views +│ ├── (authenticated)/ # Signed-in portal routes, thin wrappers around features +│ ├── api/ # App Router API routes +│ ├── favicon.ico / globals.css # Global assets +│ └── layout.tsx # Root layout/providers +├── components/ # Shared UI components (design system atoms/molecules) +│ ├── ui/ +│ ├── layout/ +│ └── common/ +├── core/ # App-wide configuration (env, logger, providers) +├── features/ # Feature-specific modules composed by routes +│ ├── account/ +│ ├── auth/ +│ ├── billing/ +│ ├── catalog/ +│ ├── dashboard/ +│ ├── marketing/ +│ ├── orders/ +│ ├── service-management/ +│ ├── subscriptions/ +│ └── support/ +├── shared/ # Cross-feature helpers (e.g., constants, locale data) +├── styles/ # Global styles and design tokens +└── types/ # Portal-specific TypeScript types ``` ## Design Principles @@ -48,7 +53,7 @@ Each feature module contains: - `utils/`: Utility functions ### 4. Centralized Shared Resources -Common utilities, types, and components are centralized in the `lib/` and `components/` directories. +Common utilities, types, and components are centralized in the `core/`, `shared/`, and `components/` directories. ## Feature Module Structure @@ -118,20 +123,21 @@ import { DataTable } from '@/components/common'; import type { User, ApiResponse } from '@/types'; // Utility imports -import { designSystem } from '@/lib/design-system'; -// Prefer feature services/hooks over direct apiClient usage in pages -import { apiClient } from '@/lib/api/client'; +import { QueryProvider } from '@/core/providers'; +// Prefer feature services/hooks over direct api usage in pages +import { logger } from '@/core/config'; ``` ### Path Mappings - `@/*` - Root src directory - `@/components/*` - Component library +- `@/core/*` - App-wide configuration and providers - `@/features/*` - Feature modules -- `@/lib/*` - Core utilities -- `@/types` - Type definitions +- `@/shared/*` - Shared helpers/constants - `@/styles/*` - Style files -- `@shared/*` - Shared package +- `@/types/*` - Portal-specific types +- `@shared/*` - Shared package exports ## Migration Strategy @@ -179,6 +185,14 @@ The migration to this new architecture will be done incrementally: This ensures pages remain declarative and the feature layer encapsulates logic. +### Route Layering + +- `(public)`: marketing landing and authentication flows. These routes render feature views such as `marketing/PublicLandingView` and `auth` screens while remaining server components by default. +- `(authenticated)`: signed-in portal experience. Pages import dashboard, billing, subscriptions, etc. from the feature layer and rely on the shared route-group layout to provide navigation. +- `api/`: App Router API endpoints remain colocated under `src/app/api` and can reuse feature services for data access. + +Only `layout.tsx`, `page.tsx`, and `loading.tsx` files live inside the route groups. All reusable UI, hooks, and services live under `src/features/**` to keep routing concerns thin. + ### Current Feature Hooks/Services - Catalog @@ -191,3 +205,7 @@ This ensures pages remain declarative and the feature layer encapsulates logic. - Service: `ordersService` (list/detail/create) - Account - Service: `accountService` (`/me/address`) +- Support + - Views: `SupportCasesView`, `NewSupportCaseView` (mock data, ready for API wiring) +- Marketing + - Views: `PublicLandingView`, `PublicLandingLoadingView` diff --git a/apps/portal/src/app/(portal)/account/loading.tsx b/apps/portal/src/app/(authenticated)/account/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/account/loading.tsx rename to apps/portal/src/app/(authenticated)/account/loading.tsx diff --git a/apps/portal/src/app/(portal)/account/page.tsx b/apps/portal/src/app/(authenticated)/account/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/account/page.tsx rename to apps/portal/src/app/(authenticated)/account/page.tsx diff --git a/apps/portal/src/app/(portal)/account/profile/page.tsx b/apps/portal/src/app/(authenticated)/account/profile/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/account/profile/page.tsx rename to apps/portal/src/app/(authenticated)/account/profile/page.tsx diff --git a/apps/portal/src/app/(portal)/billing/invoices/[id]/loading.tsx b/apps/portal/src/app/(authenticated)/billing/invoices/[id]/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/invoices/[id]/loading.tsx rename to apps/portal/src/app/(authenticated)/billing/invoices/[id]/loading.tsx diff --git a/apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx b/apps/portal/src/app/(authenticated)/billing/invoices/[id]/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx rename to apps/portal/src/app/(authenticated)/billing/invoices/[id]/page.tsx diff --git a/apps/portal/src/app/(portal)/billing/invoices/loading.tsx b/apps/portal/src/app/(authenticated)/billing/invoices/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/invoices/loading.tsx rename to apps/portal/src/app/(authenticated)/billing/invoices/loading.tsx diff --git a/apps/portal/src/app/(portal)/billing/invoices/page.tsx b/apps/portal/src/app/(authenticated)/billing/invoices/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/invoices/page.tsx rename to apps/portal/src/app/(authenticated)/billing/invoices/page.tsx diff --git a/apps/portal/src/app/(portal)/billing/payments/loading.tsx b/apps/portal/src/app/(authenticated)/billing/payments/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/payments/loading.tsx rename to apps/portal/src/app/(authenticated)/billing/payments/loading.tsx diff --git a/apps/portal/src/app/(portal)/billing/payments/page.tsx b/apps/portal/src/app/(authenticated)/billing/payments/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/payments/page.tsx rename to apps/portal/src/app/(authenticated)/billing/payments/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx b/apps/portal/src/app/(authenticated)/catalog/internet/configure/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/internet/configure/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/internet/page.tsx b/apps/portal/src/app/(authenticated)/catalog/internet/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/internet/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/internet/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/loading.tsx b/apps/portal/src/app/(authenticated)/catalog/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/loading.tsx rename to apps/portal/src/app/(authenticated)/catalog/loading.tsx diff --git a/apps/portal/src/app/(portal)/catalog/page.tsx b/apps/portal/src/app/(authenticated)/catalog/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx b/apps/portal/src/app/(authenticated)/catalog/sim/configure/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/sim/configure/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/sim/page.tsx b/apps/portal/src/app/(authenticated)/catalog/sim/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/sim/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/sim/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/vpn/page.tsx b/apps/portal/src/app/(authenticated)/catalog/vpn/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/vpn/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/vpn/page.tsx diff --git a/apps/portal/src/app/(portal)/checkout/loading.tsx b/apps/portal/src/app/(authenticated)/checkout/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/checkout/loading.tsx rename to apps/portal/src/app/(authenticated)/checkout/loading.tsx diff --git a/apps/portal/src/app/(portal)/checkout/page.tsx b/apps/portal/src/app/(authenticated)/checkout/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/checkout/page.tsx rename to apps/portal/src/app/(authenticated)/checkout/page.tsx diff --git a/apps/portal/src/app/(portal)/dashboard/loading.tsx b/apps/portal/src/app/(authenticated)/dashboard/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/dashboard/loading.tsx rename to apps/portal/src/app/(authenticated)/dashboard/loading.tsx diff --git a/apps/portal/src/app/(authenticated)/dashboard/page.tsx b/apps/portal/src/app/(authenticated)/dashboard/page.tsx new file mode 100644 index 00000000..0871d6d4 --- /dev/null +++ b/apps/portal/src/app/(authenticated)/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { DashboardView } from "@/features/dashboard"; + +export default function DashboardPage() { + return ; +} diff --git a/apps/portal/src/app/(portal)/layout.tsx b/apps/portal/src/app/(authenticated)/layout.tsx similarity index 100% rename from apps/portal/src/app/(portal)/layout.tsx rename to apps/portal/src/app/(authenticated)/layout.tsx diff --git a/apps/portal/src/app/(portal)/orders/[id]/loading.tsx b/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/orders/[id]/loading.tsx rename to apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx diff --git a/apps/portal/src/app/(portal)/orders/[id]/page.tsx b/apps/portal/src/app/(authenticated)/orders/[id]/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/orders/[id]/page.tsx rename to apps/portal/src/app/(authenticated)/orders/[id]/page.tsx diff --git a/apps/portal/src/app/(portal)/orders/loading.tsx b/apps/portal/src/app/(authenticated)/orders/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/orders/loading.tsx rename to apps/portal/src/app/(authenticated)/orders/loading.tsx diff --git a/apps/portal/src/app/(portal)/orders/page.tsx b/apps/portal/src/app/(authenticated)/orders/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/orders/page.tsx rename to apps/portal/src/app/(authenticated)/orders/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/loading.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/loading.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/loading.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/cancel/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/cancel/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/change-plan/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/change-plan/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/sim/change-plan/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/change-plan/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/reissue/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/sim/reissue/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/top-up/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/top-up/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/sim/top-up/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/top-up/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/loading.tsx b/apps/portal/src/app/(authenticated)/subscriptions/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/loading.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/loading.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/page.tsx diff --git a/apps/portal/src/app/(portal)/support/cases/loading.tsx b/apps/portal/src/app/(authenticated)/support/cases/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/support/cases/loading.tsx rename to apps/portal/src/app/(authenticated)/support/cases/loading.tsx diff --git a/apps/portal/src/app/(authenticated)/support/cases/page.tsx b/apps/portal/src/app/(authenticated)/support/cases/page.tsx new file mode 100644 index 00000000..54a27c29 --- /dev/null +++ b/apps/portal/src/app/(authenticated)/support/cases/page.tsx @@ -0,0 +1,5 @@ +import { SupportCasesView } from "@/features/support"; + +export default function SupportCasesPage() { + return ; +} diff --git a/apps/portal/src/app/(portal)/support/new/loading.tsx b/apps/portal/src/app/(authenticated)/support/new/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/support/new/loading.tsx rename to apps/portal/src/app/(authenticated)/support/new/loading.tsx diff --git a/apps/portal/src/app/(authenticated)/support/new/page.tsx b/apps/portal/src/app/(authenticated)/support/new/page.tsx new file mode 100644 index 00000000..65c960da --- /dev/null +++ b/apps/portal/src/app/(authenticated)/support/new/page.tsx @@ -0,0 +1,5 @@ +import { NewSupportCaseView } from "@/features/support"; + +export default function NewSupportCasePage() { + return ; +} diff --git a/apps/portal/src/app/(portal)/support/new/page.tsx b/apps/portal/src/app/(portal)/support/new/page.tsx deleted file mode 100644 index aa113065..00000000 --- a/apps/portal/src/app/(portal)/support/new/page.tsx +++ /dev/null @@ -1,260 +0,0 @@ -"use client"; -import { logger } from "@/core/config"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { - ArrowLeftIcon, - PaperAirplaneIcon, - ExclamationCircleIcon, - InformationCircleIcon, -} from "@heroicons/react/24/outline"; - -export default function NewSupportCasePage() { - const router = useRouter(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [formData, setFormData] = useState({ - subject: "", - category: "Technical", - priority: "Medium", - description: "", - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - - // Mock submission - would normally send to API - void (async () => { - try { - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Redirect to cases list with success message - router.push("/support/cases?created=true"); - } catch (error) { - logger.error(error, "Error creating case"); - } finally { - setIsSubmitting(false); - } - })(); - }; - - const handleInputChange = (field: string, value: string) => { - setFormData(prev => ({ - ...prev, - [field]: value, - })); - }; - - const isFormValid = formData.subject.trim() && formData.description.trim(); - - return ( -
-
- {/* Header */} -
-
- -
- -
-

Create Support Case

-

Get help from our support team

-
-
- - {/* Help Tips */} -
-
-
- -
-
-

Before creating a case

-
-
    -
  • Check our knowledge base for common solutions
  • -
  • Include relevant error messages or screenshots
  • -
  • Provide detailed steps to reproduce the issue
  • -
  • Mention your service or subscription if applicable
  • -
-
-
-
-
- - {/* Form */} -
-
- {/* Subject */} -
- - handleInputChange("subject", e.target.value)} - placeholder="Brief description of your issue" - className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500" - required - /> -
- - {/* Category and Priority */} -
-
- - -
- -
- - -
-
- - {/* Description */} -
- -