fix(auth): restore JWT and align CRUD with backend routes

This commit is contained in:
Arthur Bassi 2026-06-15 11:17:40 -03:00
parent 7be969951c
commit 4a67d6b16f
17 changed files with 284 additions and 219 deletions

View file

@ -1,30 +1,15 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
const TEST_USER = { const TEST_USER = {
id: 1, token: "test-jwt-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "user@seahavenind.com", 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) { async function mockDashboardStats(page: Page) {
await page.route("**/api/Dashboard/Stats", async (route) => { await page.route("**/api/Dashboard/Stats", async (route) => {
await route.fulfill({ await route.fulfill({
@ -44,7 +29,9 @@ async function mockDashboardStats(page: Page) {
test.describe("Login page", () => { test.describe("Login page", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await mockSession(page); await page.addInitScript(() => {
localStorage.removeItem("auth");
});
await mockDashboardStats(page); await mockDashboardStats(page);
}); });
@ -86,13 +73,7 @@ test.describe("Login page", () => {
}); });
test("logs in successfully and navigates to dashboard", async ({ 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) => { await page.route("**/api/Authentication/Login", async (route) => {
authenticated = true;
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -109,5 +90,9 @@ test.describe("Login page", () => {
await expect(page).toHaveURL(/\/$/); await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Welcome back" })).not.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);
}); });
}); });

View file

@ -1,9 +1,6 @@
export const API_PATHS = { export const API_PATHS = {
authentication: { authentication: {
login: "Authentication/Login", login: "Authentication/Login",
logout: "Authentication/Logout",
refresh: "Authentication/Refresh",
getCurrentUser: "Authentication/GetCurrentUser",
changePassword: "Authentication/ChangePassword", changePassword: "Authentication/ChangePassword",
forgetPassword: "Authentication/ForgetPassword", forgetPassword: "Authentication/ForgetPassword",
verificationCode: "Authentication/VerificationCode", verificationCode: "Authentication/VerificationCode",
@ -15,6 +12,9 @@ export const API_PATHS = {
create: "WorkOrder/Create", create: "WorkOrder/Create",
update: "WorkOrder/Update", update: "WorkOrder/Update",
delete: "WorkOrder/DeleteWorkOrder", delete: "WorkOrder/DeleteWorkOrder",
addWorkorder: "WorkOrder/AddWorkorder",
editWorkorder: "WorkOrder/EditWorkorder",
deleteWorkorder: "WorkOrder/DeleteWorkorder",
addCommentJson: "WorkOrder/AddCommentJson", addCommentJson: "WorkOrder/AddCommentJson",
changeAssignment: "WorkOrder/ChangeAssignment", changeAssignment: "WorkOrder/ChangeAssignment",
updateChecklistItem: "WorkOrder/UpdateChecklistItem", updateChecklistItem: "WorkOrder/UpdateChecklistItem",
@ -54,6 +54,9 @@ export const API_PATHS = {
createEmployee: "Employee/CreateEmployee", createEmployee: "Employee/CreateEmployee",
updateEmployee: "Employee/UpdateEmployee", updateEmployee: "Employee/UpdateEmployee",
deleteEmployee: "Employee/DeleteEmployee", deleteEmployee: "Employee/DeleteEmployee",
addEmployees: "Employee/AddEmployees",
editEmployees: "Employee/EditEmployees",
deleteEmployees: "Employee/DeleteEmployees",
dropdown: "Employee/Dropdown", dropdown: "Employee/Dropdown",
}, },
contact: { contact: {
@ -64,6 +67,7 @@ export const API_PATHS = {
delete: "Contact/Delete", delete: "Contact/Delete",
addContacts: "Contact/AddContacts", addContacts: "Contact/AddContacts",
editContacts: "Contact/EditContacts", editContacts: "Contact/EditContacts",
deleteContacts: "Contact/DeleteContacts",
dropdown: "Contact/Dropdown", dropdown: "Contact/Dropdown",
getLocations: "Contact/GetLocations", getLocations: "Contact/GetLocations",
}, },
@ -85,6 +89,9 @@ export const API_PATHS = {
addAccount: "Account/AddAccount", addAccount: "Account/AddAccount",
editAccount: "Account/EditAccount", editAccount: "Account/EditAccount",
deleteAccount: "Account/DeleteAccount", deleteAccount: "Account/DeleteAccount",
addAccounts: "Account/AddAccounts",
editAccounts: "Account/EditAccounts",
deleteAccounts: "Account/DeleteAccounts",
dropdown: "Account/Dropdown", dropdown: "Account/Dropdown",
}, },
asset: { asset: {

View file

@ -4,9 +4,7 @@ import { API_PATHS } from "@/api/api-paths";
import { createApiErrorFromResponse, mapHttpStatusToMessage } from "@/api/api-error"; import { createApiErrorFromResponse, mapHttpStatusToMessage } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client"; import queryClient from "@/lib/query/query-client";
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
let isRefreshing = false;
let refreshPromise: Promise<boolean> | null = null;
function resolvePrefixUrl(url: string): string { function resolvePrefixUrl(url: string): string {
const trimmed = url.trim() || "/api"; const trimmed = url.trim() || "/api";
@ -26,6 +24,7 @@ function resolvePrefixUrl(url: string): string {
} }
function clearSessionAndRedirectToLogin(): void { function clearSessionAndRedirectToLogin(): void {
clearAuth();
queryClient.setQueryData(queryKeys.auth.session(), null); queryClient.setQueryData(queryKeys.auth.session(), null);
if (typeof window !== "undefined" && window.location.pathname !== "/login") { if (typeof window !== "undefined" && window.location.pathname !== "/login") {
@ -33,49 +32,25 @@ function clearSessionAndRedirectToLogin(): void {
} }
} }
async function refreshSession(): Promise<boolean> { function isAuthLoginRequest(request: Request): boolean {
if (isRefreshing && refreshPromise) { return request.url.includes(API_PATHS.authentication.login);
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)
);
} }
export const api: KyInstance = ky.create({ export const api: KyInstance = ky.create({
prefixUrl: resolvePrefixUrl(env.apiUrl), prefixUrl: resolvePrefixUrl(env.apiUrl),
credentials: "include",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
hooks: { hooks: {
beforeRequest: [
(request) => {
const token = getAccessToken();
if (token) {
request.headers.set("Authorization", token);
}
},
],
beforeError: [ beforeError: [
async (error: HTTPError) => { async (error: HTTPError) => {
const { response } = error; const { response } = error;
@ -92,22 +67,12 @@ export const api: KyInstance = ky.create({
], ],
afterResponse: [ afterResponse: [
async (request, _options, response) => { async (request, _options, response) => {
if (response.status !== 401 || isRefreshRequest(request) || isAuthSessionRequest(request)) { if (response.status !== 401 || isAuthLoginRequest(request)) {
return response; return response;
} }
const refreshed = await refreshSession(); clearSessionAndRedirectToLogin();
if (!refreshed) { return response;
clearSessionAndRedirectToLogin();
return response;
}
const retried = await ky(request, { credentials: "include" });
if (retried.status === 401) {
clearSessionAndRedirectToLogin();
}
return retried;
}, },
], ],
}, },

View file

@ -0,0 +1,5 @@
import { Navigate } from "react-router-dom";
export default function CalendarLayout() {
return <Navigate to="/" replace />;
}

View file

@ -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 { NavLink } from "react-router-dom";
import { import {
Box, Box,
@ -26,60 +9,12 @@ import {
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { navGroups, type NavItemDef } from "@/config/menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const SIDEBAR_WIDTH = 220; const SIDEBAR_WIDTH = 220;
const SIDEBAR_COLLAPSED_WIDTH = 56; 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 & { type NavItemProps = NavItemDef & {
collapsed: boolean; collapsed: boolean;
end?: boolean; end?: boolean;

View file

@ -38,8 +38,8 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
logout(); logout();
}; };
const userInitials = user?.name const userInitials = user?.fullname
? user.name ? user.fullname
.split(" ") .split(" ")
.map((part) => part[0]) .map((part) => part[0])
.join("") .join("")
@ -104,7 +104,7 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
/> />
<IconButton <IconButton
title={user?.name || "User"} title="Account"
onClick={(event) => setUserAnchor(event.currentTarget)} onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{ sx={{
color: "var(--color-header-text-muted)", color: "var(--color-header-text-muted)",
@ -168,7 +168,6 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
{userInitials} {userInitials}
</Avatar> </Avatar>
<Box> <Box>
<Typography variant="subtitle2">{user?.name || "User"}</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{user?.email || ""} {user?.email || ""}
</Typography> </Typography>

67
src/config/menu.ts Normal file
View file

@ -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" }],
},
];

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths"; 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 { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import { import {
mapAccount, mapAccount,
@ -38,24 +38,31 @@ export const accountsApi = {
}, },
getById: async (id: string | number): Promise<Account> => { getById: async (id: string | number): Promise<Account> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.accounts}/${id}`); const data = await apiGet<unknown>(API_PATHS.account.getById, {
searchParams: { id },
});
return mapAccount(handleApiResponse(data)); return mapAccount(handleApiResponse(data));
}, },
create: async (payload: unknown): Promise<Account> => { create: async (payload: unknown): Promise<Account> => {
const data = await apiPost<unknown>(API_PATHS.rest.accounts, mapAccountToBackend(payload)); const data = await apiPost<unknown>(
return mapAccount(handleApiResponse(data)); API_PATHS.account.addAccounts,
},
update: async (id: string | number, payload: unknown): Promise<Account> => {
const data = await apiPut<unknown>(
`${API_PATHS.rest.accounts}/${id}`,
mapAccountToBackend(payload), mapAccountToBackend(payload),
); );
return mapAccount(handleApiResponse(data)); return mapAccount(handleApiResponse(data));
}, },
update: async (id: string | number, payload: unknown): Promise<Account> => {
const data = await apiPost<unknown>(
API_PATHS.account.editAccounts,
mapAccountToBackend({ ...(payload as object), id }),
);
return mapAccount(handleApiResponse(data));
},
delete: async (id: string | number): Promise<void> => { delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.accounts}/${id}`); await apiGet<unknown>(API_PATHS.account.deleteAccounts, {
searchParams: { id },
});
}, },
}; };

View file

@ -1,13 +1,12 @@
/** /**
* Auth API contract (cookie-based session): * Auth API contract (JWT):
* - POST Authentication/Login — sets httpOnly session cookies * - POST Authentication/Login — returns token + user fields; persisted in localStorage
* - GET Authentication/GetCurrentUser — returns active user from cookie * - Session restored from stored login payload (no GetCurrentUser)
* - POST Authentication/Logout — clears session cookies
* - POST Authentication/Refresh — refreshes session cookie on 401
*/ */
import { API_PATHS } from "@/api/api-paths"; 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 { handleApiResponse } from "@/api/handle-api-response";
import { clearAuth, getAuthUser, isSessionValid, setAuthUser } from "@/lib/auth/auth-storage";
import type { import type {
AuthUser, AuthUser,
ChangePasswordPayload, ChangePasswordPayload,
@ -17,29 +16,24 @@ import type {
export async function login(credentials: LoginCredentials): Promise<AuthUser> { export async function login(credentials: LoginCredentials): Promise<AuthUser> {
const response = await apiPost<unknown>(API_PATHS.authentication.login, credentials); const response = await apiPost<unknown>(API_PATHS.authentication.login, credentials);
return handleApiResponse<AuthUser>(response); const user = handleApiResponse<AuthUser>(response);
setAuthUser(user);
return getAuthUser() ?? user;
} }
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
await apiPost(API_PATHS.authentication.logout); clearAuth();
} }
export async function getSession(): Promise<AuthUser | null> { export async function getSession(): Promise<AuthUser | null> {
try { const user = getAuthUser();
const response = await api.get(API_PATHS.authentication.getCurrentUser, {
throwHttpErrors: false,
});
if (!response.ok) { if (!user || !isSessionValid(user)) {
return null; clearAuth();
}
const payload = (await response.json()) as unknown;
const user = handleApiResponse<AuthUser | null>(payload);
return user ?? null;
} catch {
return null; return null;
} }
return user;
} }
export async function changePassword(data: ChangePasswordPayload): Promise<unknown> { export async function changePassword(data: ChangePasswordPayload): Promise<unknown> {

View file

@ -1,11 +1,12 @@
export interface AuthUser { export interface AuthUser {
id?: string | number; token: string;
expiration: string;
email: string;
userRoles: string;
phoneNumber: string;
fullname: string;
id: string | number;
name?: string; name?: string;
email?: string;
username?: string;
role?: string;
roles?: string[];
[key: string]: unknown;
} }
export interface LoginCredentials { export interface LoginCredentials {

View file

@ -27,8 +27,8 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
return useMutation({ return useMutation({
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials), mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
meta: { suppressErrorToast: true }, meta: { suppressErrorToast: true },
onSuccess: async () => { onSuccess: (user) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session() }); queryClient.setQueryData(queryKeys.auth.session(), user);
toast.success("Login successful!"); toast.success("Login successful!");
navigate("/"); navigate("/");
}, },

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths"; 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 { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import { import {
mapContact, mapContact,
@ -38,24 +38,31 @@ export const contactsApi = {
}, },
getById: async (id: string | number): Promise<Contact> => { getById: async (id: string | number): Promise<Contact> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.contacts}/${id}`); const data = await apiGet<unknown>(API_PATHS.contact.getById, {
searchParams: { id },
});
return mapContact(handleApiResponse(data)); return mapContact(handleApiResponse(data));
}, },
create: async (payload: unknown): Promise<Contact> => { create: async (payload: unknown): Promise<Contact> => {
const data = await apiPost<unknown>(API_PATHS.rest.contacts, mapContactToBackend(payload)); const data = await apiPost<unknown>(
return mapContact(handleApiResponse(data)); API_PATHS.contact.addContacts,
},
update: async (id: string | number, payload: unknown): Promise<Contact> => {
const data = await apiPut<unknown>(
`${API_PATHS.rest.contacts}/${id}`,
mapContactToBackend(payload), mapContactToBackend(payload),
); );
return mapContact(handleApiResponse(data)); return mapContact(handleApiResponse(data));
}, },
update: async (id: string | number, payload: unknown): Promise<Contact> => {
const data = await apiPost<unknown>(
API_PATHS.contact.editContacts,
mapContactToBackend({ ...(payload as object), id }),
);
return mapContact(handleApiResponse(data));
},
delete: async (id: string | number): Promise<void> => { delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.contacts}/${id}`); await apiGet<unknown>(API_PATHS.contact.deleteContacts, {
searchParams: { id },
});
}, },
}; };

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths"; 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 { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import { import {
mapEmployeeListItems, mapEmployeeListItems,
@ -38,25 +38,32 @@ export const employeesApi = {
}, },
getById: async (id: string | number): Promise<Employee | null> => { getById: async (id: string | number): Promise<Employee | null> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.employees}/${id}`); const data = await apiGet<unknown>(API_PATHS.employee.getById, {
searchParams: { id },
});
return transformEmployee(handleApiResponse(data)); return transformEmployee(handleApiResponse(data));
}, },
create: async (payload: Employee): Promise<Employee | null> => { create: async (payload: Employee): Promise<Employee | null> => {
const data = await apiPost<unknown>(API_PATHS.rest.employees, transformToBackend(payload)); const data = await apiPost<unknown>(
API_PATHS.employee.addEmployees,
transformToBackend(payload),
);
return transformEmployee(handleApiResponse(data)); return transformEmployee(handleApiResponse(data));
}, },
update: async (id: string | number, payload: Employee): Promise<Employee | null> => { update: async (id: string | number, payload: Employee): Promise<Employee | null> => {
const data = await apiPut<unknown>( const data = await apiPost<unknown>(
`${API_PATHS.rest.employees}/${id}`, API_PATHS.employee.editEmployees,
transformToBackend({ ...payload, id }), transformToBackend({ ...payload, id }),
); );
return transformEmployee(handleApiResponse(data)); return transformEmployee(handleApiResponse(data));
}, },
delete: async (id: string | number): Promise<void> => { delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.employees}/${id}`); await apiGet<unknown>(API_PATHS.employee.deleteEmployees, {
searchParams: { id },
});
}, },
getDropdown: () => apiGet<unknown>(API_PATHS.employee.dropdown), getDropdown: () => apiGet<unknown>(API_PATHS.employee.dropdown),

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths"; 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 { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import { import {
mapDispatchComment, mapDispatchComment,
@ -99,7 +99,7 @@ export const workOrdersApi = {
}, },
getById: async (id: string | number): Promise<WorkOrderDetail> => { getById: async (id: string | number): Promise<WorkOrderDetail> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.workOrders}/${id}`); const data = await apiGet<unknown>(API_PATHS.workOrder.getById, { searchParams: { id } });
return mapWorkOrderDetail(handleApiResponse(data)); return mapWorkOrderDetail(handleApiResponse(data));
}, },
@ -107,7 +107,7 @@ export const workOrdersApi = {
values: WorkOrderFormSchemaValues | WorkOrderFormValues, values: WorkOrderFormSchemaValues | WorkOrderFormValues,
): Promise<WorkOrderDetail> => { ): Promise<WorkOrderDetail> => {
const payload = mapWorkOrderFormToCreatePayload(values); const payload = mapWorkOrderFormToCreatePayload(values);
const data = await apiPost<unknown>(API_PATHS.rest.workOrders, payload); const data = await apiPost<unknown>(API_PATHS.workOrder.addWorkorder, payload);
return mapWorkOrderDetail(handleApiResponse(data)); return mapWorkOrderDetail(handleApiResponse(data));
}, },
@ -116,12 +116,12 @@ export const workOrdersApi = {
values: WorkOrderFormSchemaValues | WorkOrderFormValues, values: WorkOrderFormSchemaValues | WorkOrderFormValues,
): Promise<WorkOrderDetail> => { ): Promise<WorkOrderDetail> => {
const payload = mapWorkOrderFormToUpdatePayload(id, values); const payload = mapWorkOrderFormToUpdatePayload(id, values);
const data = await apiPut<unknown>(`${API_PATHS.rest.workOrders}/${id}`, payload); const data = await apiPost<unknown>(API_PATHS.workOrder.editWorkorder, payload);
return mapWorkOrderDetail(handleApiResponse(data)); return mapWorkOrderDetail(handleApiResponse(data));
}, },
delete: async (id: string | number): Promise<void> => { delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.workOrders}/${id}`); await apiDelete<unknown>(API_PATHS.workOrder.deleteWorkorder, { searchParams: { id } });
}, },
addComment: async ( addComment: async (

View file

@ -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;
}

View file

@ -5,12 +5,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "@/app/(auth)/login"; import LoginPage from "@/app/(auth)/login";
import * as authApi from "@/domain/auth/api/auth-api"; import * as authApi from "@/domain/auth/api/auth-api";
import type { AuthUser } from "@/domain/auth/types/auth-user";
import { renderWithProviders } from "@/test/test-utils"; import { renderWithProviders } from "@/test/test-utils";
const { toastSuccessMock } = vi.hoisted(() => ({ const { toastSuccessMock } = vi.hoisted(() => ({
toastSuccessMock: vi.fn(), 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", () => ({ vi.mock("@/domain/auth/api/auth-api", () => ({
getSession: vi.fn(), getSession: vi.fn(),
login: vi.fn(), login: vi.fn(),
@ -61,10 +72,7 @@ describe("LoginPage integration", () => {
}); });
it("logs in successfully and navigates to dashboard", async () => { it("logs in successfully and navigates to dashboard", async () => {
const userPayload = { id: 1, email: "user@seahavenind.com" }; vi.mocked(authApi.login).mockResolvedValue(TEST_USER);
vi.mocked(authApi.login).mockResolvedValue(userPayload);
vi.mocked(authApi.getSession).mockResolvedValue(userPayload);
renderWithProviders(<LoginRoutes />, { route: "/login" }); renderWithProviders(<LoginRoutes />, { route: "/login" });
@ -116,7 +124,7 @@ describe("LoginPage integration", () => {
}); });
it("shows loading state while login is pending", async () => { 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( vi.mocked(authApi.login).mockImplementation(
() => () =>
@ -124,7 +132,6 @@ describe("LoginPage integration", () => {
resolveLogin = resolve; resolveLogin = resolve;
}), }),
); );
vi.mocked(authApi.getSession).mockResolvedValue({ id: 1, email: "user@seahavenind.com" });
renderWithProviders(<LoginRoutes />, { route: "/login" }); renderWithProviders(<LoginRoutes />, { route: "/login" });
@ -142,7 +149,7 @@ describe("LoginPage integration", () => {
expect(within(form!).getByRole("button", { name: /signing in/i })).toBeDisabled(); expect(within(form!).getByRole("button", { name: /signing in/i })).toBeDisabled();
}); });
resolveLogin({ id: 1, email: "user@seahavenind.com" }); resolveLogin(TEST_USER);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("dashboard")).toBeInTheDocument(); expect(screen.getByTestId("dashboard")).toBeInTheDocument();

View file

@ -4,9 +4,20 @@ import { describe, expect, it, vi } from "vitest";
import { GuestRoute } from "@/components/auth/guest-route"; import { GuestRoute } from "@/components/auth/guest-route";
import { ProtectedRoute } from "@/components/auth/protected-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 { AuthContext, type AuthContextValue } from "@/providers/auth-context";
import { renderWithProviders } from "@/test/test-utils"; 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> = {}): AuthContextValue { function createAuthContextValue(overrides: Partial<AuthContextValue> = {}): AuthContextValue {
return { return {
user: null, user: null,
@ -51,7 +62,7 @@ describe("ProtectedRoute", () => {
</ProtectedRoute>, </ProtectedRoute>,
createAuthContextValue({ createAuthContextValue({
isAuthenticated: true, isAuthenticated: true,
user: { id: 1, email: "user@seahavenind.com" }, user: mockAuthUser,
}), }),
"/workorders", "/workorders",
); );
@ -81,7 +92,7 @@ describe("GuestRoute", () => {
</GuestRoute>, </GuestRoute>,
createAuthContextValue({ createAuthContextValue({
isAuthenticated: true, isAuthenticated: true,
user: { id: 1, email: "user@seahavenind.com" }, user: mockAuthUser,
}), }),
"/login", "/login",
); );