mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 16:32:06 +00:00
fix(auth): restore JWT and align CRUD with backend routes
This commit is contained in:
parent
7be969951c
commit
4a67d6b16f
17 changed files with 284 additions and 219 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<boolean> | 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<boolean> {
|
||||
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;
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
5
src/app/(protected)/calendar/_layout.tsx
Normal file
5
src/app/(protected)/calendar/_layout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Navigate } from "react-router-dom";
|
||||
|
||||
export default function CalendarLayout() {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
/>
|
||||
|
||||
<IconButton
|
||||
title={user?.name || "User"}
|
||||
title="Account"
|
||||
onClick={(event) => setUserAnchor(event.currentTarget)}
|
||||
sx={{
|
||||
color: "var(--color-header-text-muted)",
|
||||
|
|
@ -168,7 +168,6 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
|
|||
{userInitials}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{user?.name || "User"}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{user?.email || ""}
|
||||
</Typography>
|
||||
|
|
|
|||
67
src/config/menu.ts
Normal file
67
src/config/menu.ts
Normal 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" }],
|
||||
},
|
||||
];
|
||||
|
|
@ -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<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));
|
||||
},
|
||||
|
||||
create: async (payload: unknown): Promise<Account> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.rest.accounts, mapAccountToBackend(payload));
|
||||
return mapAccount(handleApiResponse(data));
|
||||
},
|
||||
|
||||
update: async (id: string | number, payload: unknown): Promise<Account> => {
|
||||
const data = await apiPut<unknown>(
|
||||
`${API_PATHS.rest.accounts}/${id}`,
|
||||
const data = await apiPost<unknown>(
|
||||
API_PATHS.account.addAccounts,
|
||||
mapAccountToBackend(payload),
|
||||
);
|
||||
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> => {
|
||||
await apiDelete<unknown>(`${API_PATHS.rest.accounts}/${id}`);
|
||||
await apiGet<unknown>(API_PATHS.account.deleteAccounts, {
|
||||
searchParams: { id },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<AuthUser> {
|
||||
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> {
|
||||
await apiPost(API_PATHS.authentication.logout);
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<AuthUser | null> {
|
||||
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<AuthUser | null>(payload);
|
||||
return user ?? null;
|
||||
} catch {
|
||||
if (!user || !isSessionValid(user)) {
|
||||
clearAuth();
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function changePassword(data: ChangePasswordPayload): Promise<unknown> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
|
|||
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("/");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<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));
|
||||
},
|
||||
|
||||
create: async (payload: unknown): Promise<Contact> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.rest.contacts, mapContactToBackend(payload));
|
||||
return mapContact(handleApiResponse(data));
|
||||
},
|
||||
|
||||
update: async (id: string | number, payload: unknown): Promise<Contact> => {
|
||||
const data = await apiPut<unknown>(
|
||||
`${API_PATHS.rest.contacts}/${id}`,
|
||||
const data = await apiPost<unknown>(
|
||||
API_PATHS.contact.addContacts,
|
||||
mapContactToBackend(payload),
|
||||
);
|
||||
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> => {
|
||||
await apiDelete<unknown>(`${API_PATHS.rest.contacts}/${id}`);
|
||||
await apiGet<unknown>(API_PATHS.contact.deleteContacts, {
|
||||
searchParams: { id },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<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));
|
||||
},
|
||||
|
||||
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));
|
||||
},
|
||||
|
||||
update: async (id: string | number, payload: Employee): Promise<Employee | null> => {
|
||||
const data = await apiPut<unknown>(
|
||||
`${API_PATHS.rest.employees}/${id}`,
|
||||
const data = await apiPost<unknown>(
|
||||
API_PATHS.employee.editEmployees,
|
||||
transformToBackend({ ...payload, id }),
|
||||
);
|
||||
return transformEmployee(handleApiResponse(data));
|
||||
},
|
||||
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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<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));
|
||||
},
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ export const workOrdersApi = {
|
|||
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
|
||||
): Promise<WorkOrderDetail> => {
|
||||
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));
|
||||
},
|
||||
|
||||
|
|
@ -116,12 +116,12 @@ export const workOrdersApi = {
|
|||
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
|
||||
): Promise<WorkOrderDetail> => {
|
||||
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));
|
||||
},
|
||||
|
||||
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 (
|
||||
|
|
|
|||
68
src/lib/auth/auth-storage.ts
Normal file
68
src/lib/auth/auth-storage.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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(<LoginRoutes />, { 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(<LoginRoutes />, { 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();
|
||||
|
|
|
|||
|
|
@ -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> = {}): AuthContextValue {
|
||||
return {
|
||||
user: null,
|
||||
|
|
@ -51,7 +62,7 @@ describe("ProtectedRoute", () => {
|
|||
</ProtectedRoute>,
|
||||
createAuthContextValue({
|
||||
isAuthenticated: true,
|
||||
user: { id: 1, email: "user@seahavenind.com" },
|
||||
user: mockAuthUser,
|
||||
}),
|
||||
"/workorders",
|
||||
);
|
||||
|
|
@ -81,7 +92,7 @@ describe("GuestRoute", () => {
|
|||
</GuestRoute>,
|
||||
createAuthContextValue({
|
||||
isAuthenticated: true,
|
||||
user: { id: 1, email: "user@seahavenind.com" },
|
||||
user: mockAuthUser,
|
||||
}),
|
||||
"/login",
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue