diff --git a/e2e/auth/login.spec.ts b/e2e/auth/login.spec.ts index 852fe779..26bbafe6 100644 --- a/e2e/auth/login.spec.ts +++ b/e2e/auth/login.spec.ts @@ -1,30 +1,15 @@ import { expect, test, type Page } from "@playwright/test"; const TEST_USER = { - id: 1, + token: "test-jwt-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), email: "user@seahavenind.com", - name: "Test User", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Test User", + id: "1", }; -async function mockSession(page: Page, isAuthenticated: () => boolean = () => false) { - await page.route("**/api/Authentication/GetCurrentUser", async (route) => { - if (isAuthenticated()) { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ data: TEST_USER }), - }); - return; - } - - await route.fulfill({ - status: 401, - contentType: "application/json", - body: JSON.stringify({ message: "Unauthorized" }), - }); - }); -} - async function mockDashboardStats(page: Page) { await page.route("**/api/Dashboard/Stats", async (route) => { await route.fulfill({ @@ -44,7 +29,9 @@ async function mockDashboardStats(page: Page) { test.describe("Login page", () => { test.beforeEach(async ({ page }) => { - await mockSession(page); + await page.addInitScript(() => { + localStorage.removeItem("auth"); + }); await mockDashboardStats(page); }); @@ -86,13 +73,7 @@ test.describe("Login page", () => { }); test("logs in successfully and navigates to dashboard", async ({ page }) => { - let authenticated = false; - - await page.unroute("**/api/Authentication/GetCurrentUser"); - await mockSession(page, () => authenticated); - await page.route("**/api/Authentication/Login", async (route) => { - authenticated = true; await route.fulfill({ status: 200, contentType: "application/json", @@ -109,5 +90,9 @@ test.describe("Login page", () => { await expect(page).toHaveURL(/\/$/); await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Welcome back" })).not.toBeVisible(); + + const storedAuth = await page.evaluate(() => localStorage.getItem("auth")); + expect(storedAuth).toContain(TEST_USER.token); + expect(storedAuth).toContain(TEST_USER.fullname); }); }); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index ef52f105..62f0a68f 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -1,9 +1,6 @@ export const API_PATHS = { authentication: { login: "Authentication/Login", - logout: "Authentication/Logout", - refresh: "Authentication/Refresh", - getCurrentUser: "Authentication/GetCurrentUser", changePassword: "Authentication/ChangePassword", forgetPassword: "Authentication/ForgetPassword", verificationCode: "Authentication/VerificationCode", @@ -15,6 +12,9 @@ export const API_PATHS = { create: "WorkOrder/Create", update: "WorkOrder/Update", delete: "WorkOrder/DeleteWorkOrder", + addWorkorder: "WorkOrder/AddWorkorder", + editWorkorder: "WorkOrder/EditWorkorder", + deleteWorkorder: "WorkOrder/DeleteWorkorder", addCommentJson: "WorkOrder/AddCommentJson", changeAssignment: "WorkOrder/ChangeAssignment", updateChecklistItem: "WorkOrder/UpdateChecklistItem", @@ -54,6 +54,9 @@ export const API_PATHS = { createEmployee: "Employee/CreateEmployee", updateEmployee: "Employee/UpdateEmployee", deleteEmployee: "Employee/DeleteEmployee", + addEmployees: "Employee/AddEmployees", + editEmployees: "Employee/EditEmployees", + deleteEmployees: "Employee/DeleteEmployees", dropdown: "Employee/Dropdown", }, contact: { @@ -64,6 +67,7 @@ export const API_PATHS = { delete: "Contact/Delete", addContacts: "Contact/AddContacts", editContacts: "Contact/EditContacts", + deleteContacts: "Contact/DeleteContacts", dropdown: "Contact/Dropdown", getLocations: "Contact/GetLocations", }, @@ -85,6 +89,9 @@ export const API_PATHS = { addAccount: "Account/AddAccount", editAccount: "Account/EditAccount", deleteAccount: "Account/DeleteAccount", + addAccounts: "Account/AddAccounts", + editAccounts: "Account/EditAccounts", + deleteAccounts: "Account/DeleteAccounts", dropdown: "Account/Dropdown", }, asset: { diff --git a/src/api/api.ts b/src/api/api.ts index 2c9ccaf4..8a0e0d33 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -4,9 +4,7 @@ import { API_PATHS } from "@/api/api-paths"; import { createApiErrorFromResponse, mapHttpStatusToMessage } from "@/api/api-error"; import { queryKeys } from "@/infra/query-key/query-key"; import queryClient from "@/lib/query/query-client"; - -let isRefreshing = false; -let refreshPromise: Promise | null = null; +import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; function resolvePrefixUrl(url: string): string { const trimmed = url.trim() || "/api"; @@ -26,6 +24,7 @@ function resolvePrefixUrl(url: string): string { } function clearSessionAndRedirectToLogin(): void { + clearAuth(); queryClient.setQueryData(queryKeys.auth.session(), null); if (typeof window !== "undefined" && window.location.pathname !== "/login") { @@ -33,49 +32,25 @@ function clearSessionAndRedirectToLogin(): void { } } -async function refreshSession(): Promise { - if (isRefreshing && refreshPromise) { - return refreshPromise; - } - - isRefreshing = true; - refreshPromise = (async () => { - try { - const response = await api.post(API_PATHS.authentication.refresh, { - retry: { limit: 0 }, - }); - return response.ok; - } catch { - return false; - } finally { - isRefreshing = false; - refreshPromise = null; - } - })(); - - return refreshPromise; -} - -function isRefreshRequest(request: Request): boolean { - return request.url.includes(API_PATHS.authentication.refresh); -} - -function isAuthSessionRequest(request: Request): boolean { - return ( - request.url.includes(API_PATHS.authentication.getCurrentUser) || - request.url.includes(API_PATHS.authentication.login) || - request.url.includes(API_PATHS.authentication.logout) - ); +function isAuthLoginRequest(request: Request): boolean { + return request.url.includes(API_PATHS.authentication.login); } export const api: KyInstance = ky.create({ prefixUrl: resolvePrefixUrl(env.apiUrl), - credentials: "include", headers: { Accept: "application/json", "Content-Type": "application/json", }, hooks: { + beforeRequest: [ + (request) => { + const token = getAccessToken(); + if (token) { + request.headers.set("Authorization", token); + } + }, + ], beforeError: [ async (error: HTTPError) => { const { response } = error; @@ -92,22 +67,12 @@ export const api: KyInstance = ky.create({ ], afterResponse: [ async (request, _options, response) => { - if (response.status !== 401 || isRefreshRequest(request) || isAuthSessionRequest(request)) { + if (response.status !== 401 || isAuthLoginRequest(request)) { return response; } - const refreshed = await refreshSession(); - if (!refreshed) { - clearSessionAndRedirectToLogin(); - return response; - } - - const retried = await ky(request, { credentials: "include" }); - if (retried.status === 401) { - clearSessionAndRedirectToLogin(); - } - - return retried; + clearSessionAndRedirectToLogin(); + return response; }, ], }, diff --git a/src/app/(protected)/calendar/_layout.tsx b/src/app/(protected)/calendar/_layout.tsx new file mode 100644 index 00000000..a7f9468b --- /dev/null +++ b/src/app/(protected)/calendar/_layout.tsx @@ -0,0 +1,5 @@ +import { Navigate } from "react-router-dom"; + +export default function CalendarLayout() { + return ; +} diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index c16f3950..8f0d1c9a 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -1,20 +1,3 @@ -import { - FaAddressBook, - FaBell, - FaCalendarAlt, - FaClipboardList, - FaCog, - FaCubes, - FaDollarSign, - FaFileInvoiceDollar, - FaMapMarkerAlt, - FaMoneyCheckAlt, - FaTachometerAlt, - FaTools, - FaTruck, - FaUserTie, -} from "react-icons/fa"; -import type { IconType } from "react-icons"; import { NavLink } from "react-router-dom"; import { Box, @@ -26,60 +9,12 @@ import { Typography, } from "@mui/material"; +import { navGroups, type NavItemDef } from "@/config/menu"; import { cn } from "@/lib/utils"; const SIDEBAR_WIDTH = 220; const SIDEBAR_COLLAPSED_WIDTH = 56; -type NavItemDef = { - label: string; - icon: IconType; - path: string; -}; - -const navGroups: { title: string; items: NavItemDef[] }[] = [ - { - title: "Overview", - items: [ - { label: "Dashboard", icon: FaTachometerAlt, path: "/" }, - { label: "Calendar", icon: FaCalendarAlt, path: "/calendar" }, - ], - }, - { - title: "Operations", - items: [ - { label: "Work Orders", icon: FaTools, path: "/workorders" }, - { label: "Uplifts", icon: FaDollarSign, path: "/uplifts" }, - { label: "Follow Ups", icon: FaBell, path: "/followups" }, - { label: "PM Schedules", icon: FaClipboardList, path: "/pmschedules" }, - ], - }, - { - title: "Vendors", - items: [ - { label: "Vendors", icon: FaTruck, path: "/vendors" }, - { label: "Vendor POs", icon: FaFileInvoiceDollar, path: "/vendor-pos" }, - ], - }, - { - title: "Customers & Sites", - items: [ - { label: "Accounts", icon: FaMoneyCheckAlt, path: "/accounts" }, - { label: "Contacts", icon: FaAddressBook, path: "/contacts" }, - { label: "Locations", icon: FaMapMarkerAlt, path: "/locations" }, - { label: "Assets", icon: FaCubes, path: "/assets" }, - ], - }, - { - title: "Internal", - items: [{ label: "Employees", icon: FaUserTie, path: "/employees" }], - }, - { - title: "System", - items: [{ label: "Settings", icon: FaCog, path: "/settings/dropdowns" }], - }, -]; - type NavItemProps = NavItemDef & { collapsed: boolean; end?: boolean; diff --git a/src/components/layout/app-topbar.tsx b/src/components/layout/app-topbar.tsx index 7266e47e..80750b58 100644 --- a/src/components/layout/app-topbar.tsx +++ b/src/components/layout/app-topbar.tsx @@ -38,8 +38,8 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) { logout(); }; - const userInitials = user?.name - ? user.name + const userInitials = user?.fullname + ? user.fullname .split(" ") .map((part) => part[0]) .join("") @@ -104,7 +104,7 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) { /> setUserAnchor(event.currentTarget)} sx={{ color: "var(--color-header-text-muted)", @@ -168,7 +168,6 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) { {userInitials} - {user?.name || "User"} {user?.email || ""} diff --git a/src/config/menu.ts b/src/config/menu.ts new file mode 100644 index 00000000..cc2bfdc9 --- /dev/null +++ b/src/config/menu.ts @@ -0,0 +1,67 @@ +import { + FaAddressBook, + FaBell, + FaClipboardList, + FaCog, + FaCubes, + FaDollarSign, + FaFileInvoiceDollar, + FaMapMarkerAlt, + FaMoneyCheckAlt, + FaTachometerAlt, + FaTools, + FaTruck, + FaUserTie, +} from "react-icons/fa"; +import type { IconType } from "react-icons"; + +export type NavItemDef = { + label: string; + icon: IconType; + path: string; +}; + +export type NavGroup = { + title: string; + items: NavItemDef[]; +}; + +export const navGroups: NavGroup[] = [ + { + title: "Overview", + items: [{ label: "Dashboard", icon: FaTachometerAlt, path: "/" }], + }, + { + title: "Operations", + items: [ + { label: "Work Orders", icon: FaTools, path: "/workorders" }, + { label: "Uplifts", icon: FaDollarSign, path: "/uplifts" }, + { label: "Follow Ups", icon: FaBell, path: "/followups" }, + { label: "PM Schedules", icon: FaClipboardList, path: "/pmschedules" }, + ], + }, + { + title: "Vendors", + items: [ + { label: "Vendors", icon: FaTruck, path: "/vendors" }, + { label: "Vendor POs", icon: FaFileInvoiceDollar, path: "/vendor-pos" }, + ], + }, + { + title: "Customers & Sites", + items: [ + { label: "Accounts", icon: FaMoneyCheckAlt, path: "/accounts" }, + { label: "Contacts", icon: FaAddressBook, path: "/contacts" }, + { label: "Locations", icon: FaMapMarkerAlt, path: "/locations" }, + { label: "Assets", icon: FaCubes, path: "/assets" }, + ], + }, + { + title: "Internal", + items: [{ label: "Employees", icon: FaUserTie, path: "/employees" }], + }, + { + title: "System", + items: [{ label: "Settings", icon: FaCog, path: "/settings/dropdowns" }], + }, +]; diff --git a/src/domain/accounts/api/accounts-api.ts b/src/domain/accounts/api/accounts-api.ts index c03fdac0..7f713a31 100644 --- a/src/domain/accounts/api/accounts-api.ts +++ b/src/domain/accounts/api/accounts-api.ts @@ -1,5 +1,5 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; +import { apiGet, apiPost } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { mapAccount, @@ -38,24 +38,31 @@ export const accountsApi = { }, getById: async (id: string | number): Promise => { - const data = await apiGet(`${API_PATHS.rest.accounts}/${id}`); + const data = await apiGet(API_PATHS.account.getById, { + searchParams: { id }, + }); return mapAccount(handleApiResponse(data)); }, create: async (payload: unknown): Promise => { - const data = await apiPost(API_PATHS.rest.accounts, mapAccountToBackend(payload)); - return mapAccount(handleApiResponse(data)); - }, - - update: async (id: string | number, payload: unknown): Promise => { - const data = await apiPut( - `${API_PATHS.rest.accounts}/${id}`, + const data = await apiPost( + API_PATHS.account.addAccounts, mapAccountToBackend(payload), ); return mapAccount(handleApiResponse(data)); }, + update: async (id: string | number, payload: unknown): Promise => { + const data = await apiPost( + API_PATHS.account.editAccounts, + mapAccountToBackend({ ...(payload as object), id }), + ); + return mapAccount(handleApiResponse(data)); + }, + delete: async (id: string | number): Promise => { - await apiDelete(`${API_PATHS.rest.accounts}/${id}`); + await apiGet(API_PATHS.account.deleteAccounts, { + searchParams: { id }, + }); }, }; diff --git a/src/domain/auth/api/auth-api.ts b/src/domain/auth/api/auth-api.ts index 0ec9a825..279baaa7 100644 --- a/src/domain/auth/api/auth-api.ts +++ b/src/domain/auth/api/auth-api.ts @@ -1,13 +1,12 @@ /** - * Auth API contract (cookie-based session): - * - POST Authentication/Login — sets httpOnly session cookies - * - GET Authentication/GetCurrentUser — returns active user from cookie - * - POST Authentication/Logout — clears session cookies - * - POST Authentication/Refresh — refreshes session cookie on 401 + * Auth API contract (JWT): + * - POST Authentication/Login — returns token + user fields; persisted in localStorage + * - Session restored from stored login payload (no GetCurrentUser) */ import { API_PATHS } from "@/api/api-paths"; -import { api, apiPost } from "@/api/api"; +import { apiPost } from "@/api/api"; import { handleApiResponse } from "@/api/handle-api-response"; +import { clearAuth, getAuthUser, isSessionValid, setAuthUser } from "@/lib/auth/auth-storage"; import type { AuthUser, ChangePasswordPayload, @@ -17,29 +16,24 @@ import type { export async function login(credentials: LoginCredentials): Promise { const response = await apiPost(API_PATHS.authentication.login, credentials); - return handleApiResponse(response); + const user = handleApiResponse(response); + setAuthUser(user); + return getAuthUser() ?? user; } export async function logout(): Promise { - await apiPost(API_PATHS.authentication.logout); + clearAuth(); } export async function getSession(): Promise { - try { - const response = await api.get(API_PATHS.authentication.getCurrentUser, { - throwHttpErrors: false, - }); + const user = getAuthUser(); - if (!response.ok) { - return null; - } - - const payload = (await response.json()) as unknown; - const user = handleApiResponse(payload); - return user ?? null; - } catch { + if (!user || !isSessionValid(user)) { + clearAuth(); return null; } + + return user; } export async function changePassword(data: ChangePasswordPayload): Promise { diff --git a/src/domain/auth/types/auth-user.ts b/src/domain/auth/types/auth-user.ts index 22a94c76..6c5faf2a 100644 --- a/src/domain/auth/types/auth-user.ts +++ b/src/domain/auth/types/auth-user.ts @@ -1,11 +1,12 @@ export interface AuthUser { - id?: string | number; + token: string; + expiration: string; + email: string; + userRoles: string; + phoneNumber: string; + fullname: string; + id: string | number; name?: string; - email?: string; - username?: string; - role?: string; - roles?: string[]; - [key: string]: unknown; } export interface LoginCredentials { diff --git a/src/domain/auth/use-cases/use-login.ts b/src/domain/auth/use-cases/use-login.ts index b677e9ae..00aa53a6 100644 --- a/src/domain/auth/use-cases/use-login.ts +++ b/src/domain/auth/use-cases/use-login.ts @@ -27,8 +27,8 @@ export function useLogin(): UseMutationResult return useMutation({ mutationFn: (credentials: LoginCredentials) => authApi.login(credentials), meta: { suppressErrorToast: true }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session() }); + onSuccess: (user) => { + queryClient.setQueryData(queryKeys.auth.session(), user); toast.success("Login successful!"); navigate("/"); }, diff --git a/src/domain/contacts/api/contacts-api.ts b/src/domain/contacts/api/contacts-api.ts index 852254d4..8de0848e 100644 --- a/src/domain/contacts/api/contacts-api.ts +++ b/src/domain/contacts/api/contacts-api.ts @@ -1,5 +1,5 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; +import { apiGet, apiPost } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { mapContact, @@ -38,24 +38,31 @@ export const contactsApi = { }, getById: async (id: string | number): Promise => { - const data = await apiGet(`${API_PATHS.rest.contacts}/${id}`); + const data = await apiGet(API_PATHS.contact.getById, { + searchParams: { id }, + }); return mapContact(handleApiResponse(data)); }, create: async (payload: unknown): Promise => { - const data = await apiPost(API_PATHS.rest.contacts, mapContactToBackend(payload)); - return mapContact(handleApiResponse(data)); - }, - - update: async (id: string | number, payload: unknown): Promise => { - const data = await apiPut( - `${API_PATHS.rest.contacts}/${id}`, + const data = await apiPost( + API_PATHS.contact.addContacts, mapContactToBackend(payload), ); return mapContact(handleApiResponse(data)); }, + update: async (id: string | number, payload: unknown): Promise => { + const data = await apiPost( + API_PATHS.contact.editContacts, + mapContactToBackend({ ...(payload as object), id }), + ); + return mapContact(handleApiResponse(data)); + }, + delete: async (id: string | number): Promise => { - await apiDelete(`${API_PATHS.rest.contacts}/${id}`); + await apiGet(API_PATHS.contact.deleteContacts, { + searchParams: { id }, + }); }, }; diff --git a/src/domain/employees/api/employees-api.ts b/src/domain/employees/api/employees-api.ts index 0ea7b6c6..c3c3fb0d 100644 --- a/src/domain/employees/api/employees-api.ts +++ b/src/domain/employees/api/employees-api.ts @@ -1,5 +1,5 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; +import { apiGet, apiPost } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { mapEmployeeListItems, @@ -38,25 +38,32 @@ export const employeesApi = { }, getById: async (id: string | number): Promise => { - const data = await apiGet(`${API_PATHS.rest.employees}/${id}`); + const data = await apiGet(API_PATHS.employee.getById, { + searchParams: { id }, + }); return transformEmployee(handleApiResponse(data)); }, create: async (payload: Employee): Promise => { - const data = await apiPost(API_PATHS.rest.employees, transformToBackend(payload)); + const data = await apiPost( + API_PATHS.employee.addEmployees, + transformToBackend(payload), + ); return transformEmployee(handleApiResponse(data)); }, update: async (id: string | number, payload: Employee): Promise => { - const data = await apiPut( - `${API_PATHS.rest.employees}/${id}`, + const data = await apiPost( + API_PATHS.employee.editEmployees, transformToBackend({ ...payload, id }), ); return transformEmployee(handleApiResponse(data)); }, delete: async (id: string | number): Promise => { - await apiDelete(`${API_PATHS.rest.employees}/${id}`); + await apiGet(API_PATHS.employee.deleteEmployees, { + searchParams: { id }, + }); }, getDropdown: () => apiGet(API_PATHS.employee.dropdown), diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index 5059ed57..f62f7a30 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -1,5 +1,5 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; +import { apiDelete, apiGet, apiPost } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { mapDispatchComment, @@ -99,7 +99,7 @@ export const workOrdersApi = { }, getById: async (id: string | number): Promise => { - const data = await apiGet(`${API_PATHS.rest.workOrders}/${id}`); + const data = await apiGet(API_PATHS.workOrder.getById, { searchParams: { id } }); return mapWorkOrderDetail(handleApiResponse(data)); }, @@ -107,7 +107,7 @@ export const workOrdersApi = { values: WorkOrderFormSchemaValues | WorkOrderFormValues, ): Promise => { const payload = mapWorkOrderFormToCreatePayload(values); - const data = await apiPost(API_PATHS.rest.workOrders, payload); + const data = await apiPost(API_PATHS.workOrder.addWorkorder, payload); return mapWorkOrderDetail(handleApiResponse(data)); }, @@ -116,12 +116,12 @@ export const workOrdersApi = { values: WorkOrderFormSchemaValues | WorkOrderFormValues, ): Promise => { const payload = mapWorkOrderFormToUpdatePayload(id, values); - const data = await apiPut(`${API_PATHS.rest.workOrders}/${id}`, payload); + const data = await apiPost(API_PATHS.workOrder.editWorkorder, payload); return mapWorkOrderDetail(handleApiResponse(data)); }, delete: async (id: string | number): Promise => { - await apiDelete(`${API_PATHS.rest.workOrders}/${id}`); + await apiDelete(API_PATHS.workOrder.deleteWorkorder, { searchParams: { id } }); }, addComment: async ( diff --git a/src/lib/auth/auth-storage.ts b/src/lib/auth/auth-storage.ts new file mode 100644 index 00000000..efb5d6fc --- /dev/null +++ b/src/lib/auth/auth-storage.ts @@ -0,0 +1,68 @@ +import type { AuthUser } from "@/domain/auth/types/auth-user"; + +const AUTH_STORAGE_KEY = "auth"; + +function parseExpiration(expiration: string): number | null { + const asNumber = Number(expiration); + if (!Number.isNaN(asNumber) && asNumber > 0) { + return asNumber > 1e12 ? asNumber : asNumber * 1000; + } + + const parsed = Date.parse(expiration); + return Number.isNaN(parsed) ? null : parsed; +} + +export function setAuthUser(user: AuthUser): void { + if (typeof window === "undefined") { + return; + } + + localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(user)); +} + +export function getAuthUser(): AuthUser | null { + if (typeof window === "undefined") { + return null; + } + + const raw = localStorage.getItem(AUTH_STORAGE_KEY); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as AuthUser; + } catch { + return null; + } +} + +export function getAccessToken(): string | null { + const user = getAuthUser(); + if (!user?.token || typeof user.token !== "string") { + return null; + } + + return `Bearer ${user.token}`; +} + +export function clearAuth(): void { + if (typeof window === "undefined") { + return; + } + + localStorage.removeItem(AUTH_STORAGE_KEY); +} + +export function isSessionValid(user: AuthUser | null): boolean { + if (!user?.token || !user.expiration) { + return false; + } + + const expiresAt = parseExpiration(user.expiration); + if (expiresAt === null) { + return false; + } + + return Date.now() < expiresAt; +} diff --git a/src/test/app/(auth)/login.integration.test.tsx b/src/test/app/(auth)/login.integration.test.tsx index c2384ea9..c590374b 100644 --- a/src/test/app/(auth)/login.integration.test.tsx +++ b/src/test/app/(auth)/login.integration.test.tsx @@ -5,12 +5,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import LoginPage from "@/app/(auth)/login"; import * as authApi from "@/domain/auth/api/auth-api"; +import type { AuthUser } from "@/domain/auth/types/auth-user"; import { renderWithProviders } from "@/test/test-utils"; const { toastSuccessMock } = vi.hoisted(() => ({ toastSuccessMock: vi.fn(), })); +const TEST_USER: AuthUser = { + token: "test-jwt-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "user@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Test User", + id: 1, +}; + vi.mock("@/domain/auth/api/auth-api", () => ({ getSession: vi.fn(), login: vi.fn(), @@ -61,10 +72,7 @@ describe("LoginPage integration", () => { }); it("logs in successfully and navigates to dashboard", async () => { - const userPayload = { id: 1, email: "user@seahavenind.com" }; - - vi.mocked(authApi.login).mockResolvedValue(userPayload); - vi.mocked(authApi.getSession).mockResolvedValue(userPayload); + vi.mocked(authApi.login).mockResolvedValue(TEST_USER); renderWithProviders(, { route: "/login" }); @@ -116,7 +124,7 @@ describe("LoginPage integration", () => { }); it("shows loading state while login is pending", async () => { - let resolveLogin: (value: { id: number; email: string }) => void = () => {}; + let resolveLogin: (value: AuthUser) => void = () => {}; vi.mocked(authApi.login).mockImplementation( () => @@ -124,7 +132,6 @@ describe("LoginPage integration", () => { resolveLogin = resolve; }), ); - vi.mocked(authApi.getSession).mockResolvedValue({ id: 1, email: "user@seahavenind.com" }); renderWithProviders(, { route: "/login" }); @@ -142,7 +149,7 @@ describe("LoginPage integration", () => { expect(within(form!).getByRole("button", { name: /signing in/i })).toBeDisabled(); }); - resolveLogin({ id: 1, email: "user@seahavenind.com" }); + resolveLogin(TEST_USER); await waitFor(() => { expect(screen.getByTestId("dashboard")).toBeInTheDocument(); diff --git a/src/test/components/auth/auth-routes.test.tsx b/src/test/components/auth/auth-routes.test.tsx index d5135ca8..c39dd5f5 100644 --- a/src/test/components/auth/auth-routes.test.tsx +++ b/src/test/components/auth/auth-routes.test.tsx @@ -4,9 +4,20 @@ import { describe, expect, it, vi } from "vitest"; import { GuestRoute } from "@/components/auth/guest-route"; import { ProtectedRoute } from "@/components/auth/protected-route"; +import type { AuthUser } from "@/domain/auth/types/auth-user"; import { AuthContext, type AuthContextValue } from "@/providers/auth-context"; import { renderWithProviders } from "@/test/test-utils"; +const mockAuthUser: AuthUser = { + token: "test-jwt-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "user@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Test User", + id: 1, +}; + function createAuthContextValue(overrides: Partial = {}): AuthContextValue { return { user: null, @@ -51,7 +62,7 @@ describe("ProtectedRoute", () => { , createAuthContextValue({ isAuthenticated: true, - user: { id: 1, email: "user@seahavenind.com" }, + user: mockAuthUser, }), "/workorders", ); @@ -81,7 +92,7 @@ describe("GuestRoute", () => { , createAuthContextValue({ isAuthenticated: true, - user: { id: 1, email: "user@seahavenind.com" }, + user: mockAuthUser, }), "/login", );