fixed auth cycle and upadet indes fail to all model to update style solve pagination in car maintenanc

-delete link step back from home page and change live color in dashboard page
This commit is contained in:
m7amedez5511
2026-07-15 18:44:16 +03:00
parent 0eebef3ba7
commit bbde177f4a
24 changed files with 473 additions and 206 deletions

View File

@@ -147,9 +147,7 @@ const GRAD = "linear-gradient(175deg, #1E3A8A 0%, #1D4ED8 60%, #1565C0 100%)";
export function Sidebar() {
const pathname = usePathname();
const user = getStoredUser();
const permissions =
user?.permissions ??
navSections.flatMap((s) => s.items.map((i) => i.permission));
const permissions = user?.permissions ?? [];
const { open, setOpen } = useSidebarDrawer();
useEffect(() => {

View File

@@ -29,7 +29,7 @@ export default function DashboardPage() {
{ label: "العملاء", value: stats?.clients ?? 0, accent: "#06B6D4" },
{ label: "الطلبات", value: stats?.orders ?? 0, accent: "#A78BFA" },
{ label: "الرحلات", value: stats?.trips ?? 0, accent: "#34D399" },
{ label: "المركبات", value: (stats?.cars ?? 0) + (stats?.drivers ?? 0), accent: "#FBBF24" },
{ label: "المركبات و السائقين ", value: (stats?.cars ?? 0) + (stats?.drivers ?? 0), accent: "#FBBF24" },
],
[stats],
);
@@ -60,24 +60,7 @@ export default function DashboardPage() {
عرض واضح لطلبات الأسطول وتنبيهات السلامة وتقدم الرحلات.
</p>
</div>
<Link
href="/"
style={{
display: "inline-flex",
alignItems: "center",
borderRadius: "var(--radius-full)",
border: "1px solid rgba(103,232,249,0.30)",
background: "rgba(103,232,249,0.08)",
padding: "0.5rem 1rem",
fontSize: 13,
color: "#CFFAFE",
textDecoration: "none",
whiteSpace: "nowrap",
transition: "var(--transition-base)",
}}
>
العودة إلى بوابة العميل
</Link>
</div>
</header>
@@ -134,7 +117,7 @@ export default function DashboardPage() {
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9", textAlign: "start" }}>الرحلات النشطة</p>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>تقدم الرحلات</h2>
</div>
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#A7F3D0" }}>
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#008000" }}>
مباشر
</span>
</div>

View File

@@ -8,8 +8,7 @@ import {
MapPin,
PackageCheck,
} from "lucide-react";
import Navbar from "@/app/components/layout/Navbar";
import Logo from "@/src/utils/logo";
const stats = [
{ label: "الطلبات النشطة", value: "48", color: "text-blue-600" },

58
docs/problem.md Normal file
View File

@@ -0,0 +1,58 @@
# Frontend Logistics Code Review
Note: This review covers the most significant and impactful issues in the codebase, organized into 5 categories, rather than being a shallow review of all 225 files.
## 1. Logic Errors
| File | Location | Issue | Impact | Suggested Fix |
|---|---|---|---|---|
| `app/components/layout/Sidebar.tsx` | `const permissions = user?.permissions ?? navSections.flatMap(...)` | If the `user` object has no `permissions` (undefined), the fallback grants **all permissions** instead of zero. | A user who should have zero permissions can see and navigate to every module in the sidebar. | The fallback should be `[]`, not the full list: `user?.permissions ?? []`. |
| `src/services/carMaintanance.service.ts` / `UseCarsMaintanance.ts` | `getAll` without `page`/`limit` | Fetches the entire list without pagination. | Will slow down as maintenance records per car grow, with no indicator to the user that this is happening. | Confirm with the backend whether pagination exists; if not, add it or apply a temporary cap. |
| `src/services/api.ts` | `request()` | `console.debug` logs the full request body even outside production, including the password and other sensitive data. | Credentials and PII appear in the console and in any log aggregation tool. | Redact sensitive keys (`password`, `token`) before logging, or disable the debug flag by default. |
## 2. Code Flow Problems
| File | Location | Issue | Impact | Suggested Fix |
|---|---|---|---|---|
| `src/Components/Order/OrderFormModal.tsx` | `handleSubmit` and the submit button | Leftover `console.log` and a debug line, plus a redundant `onClick` on a `submit`-type button. | Console noise in production, and a sign that a broken submit flow was patched temporarily. | Remove both, and if the flow genuinely breaks, add a test instead of a debug statement. |
| Two parallel auth files exist: `src/service/auth.service.ts` (axios) and `src/services/auth.service.ts` (fetch, the one actually used in `useAuth.ts`) | Both files entirely | The same login process is implemented twice in two different ways, and `useAuth.ts` imports from the wrong path (`service` instead of `services`). | A fix applied to one auth path may not apply to the other, and it's easy for someone to edit the wrong file. | Delete one of them, and unify all HTTP calls on `request()` from `services/api.ts`. |
| `src/lib/api.ts` vs `src/services/api.ts` | Both files entirely | A second, largely unused fetch wrapper (`requestJson`) exists parallel to the main one. | Duplicate, dead code that increases the risk of someone "fixing" the wrong file or shipping a new feature the wrong way. | Confirm nothing imports `src/lib/api.ts`, and delete it if so. |
| `src/lib/auth.ts` + `app/api/auth/set-cookie/route.ts` + `middleware.ts` | The entire auth flow | Auth state is stored twice: an httpOnly cookie (secure) plus the token and user object in localStorage (readable by any JS). | Increases the XSS attack surface with no real benefit, since anyone able to run JS on the page can read the token from localStorage. | Pick a single source of truth. If the proxy route sends the token from the cookie automatically, there's no need to also send an Authorization header from localStorage. |
## 3. Design Anti-Patterns
| File | Location | Issue | Impact | Suggested Fix |
|---|---|---|---|---|
| `DriverDeleteModal.tsx`, `OrderDeleteModal.tsx`, `CarDeleteModal.tsx`, `CarMaintenanceDeleteModal.tsx`, `TripDeleteModal.tsx`, `DeleteRoleModal.tsx`, `Branch/DeleteConfirmModal.tsx`, `User/DeleteConfirmModal.tsx`, `Client/Deleteconfirmmodal.tsx` | Entire files | The same delete-confirmation modal (~90 lines) is duplicated more than 9 times, despite a generic, ready-made `src/Components/UI/ConfirmDialog.tsx` already existing. | Any improvement (accessibility, styling, animation) must be applied 9 times, and differences have already started to appear between them (e.g. `role="alertdialog"` exists in some but not all). | Replace all these copies with `<ConfirmDialog>`, passing only the differing props (title and description). |
| Nearly all list/detail pages (`app/dashboard/*/page.tsx`) | Throughout | Heavy use of `as unknown as T` instead of defining types once at the service layer. | Type safety is superficial rather than real; any change in the backend response shape won't be caught at compile time. | Move the unwrapping logic into the service functions, so types are genuinely accurate at the point of use. |
| `app/dashboard/page.tsx` and several list pages | Direct use of `getStoredUser()` inside render in multiple components (`Topbar.tsx`, `ConditionalNavbar.tsx`) | Reading localStorage and running `JSON.parse` on every render of every component that needs user data. | Minor performance cost, and it tightly couples every component to the storage mechanism, making future changes harder. | Create a hook such as `useCurrentAuthUser()` built on context or the existing fetch hook. |
| `CarFormModal.tsx`, `DriverFormModal.tsx`, `TripFormModal.tsx`, `OrderFormModal.tsx` | Entire files | Each form is one massive component (400-900 lines) managing state, validation, and submission all inline, without using `react-hook-form` + `yup` despite them being used elsewhere (e.g. `ClientFormModal.tsx`). | Weak architectural consistency across forms, and any improvement (e.g. onBlur validation) has to be done manually in each form separately. | Standardize all forms on `react-hook-form` + `yupResolver`, following the established pattern in `ClientFormModal.tsx`. |
## 4. Security / Data Integrity Risks
| File | Location | Issue | Impact | Suggested Fix |
|---|---|---|---|---|
| `src/middleware/middleware.ts` | `decodeJwtPayload` | The JWT is decoded without verifying its signature in the middleware; decisions to block the "driver" role from `/dashboard` are based on unverified claims. | If any backend endpoint forgets to check authorization itself, there is no second layer of protection at the middleware level. | Document (and test, if possible) that every route/API call under `/dashboard/*` performs its own independent auth and role check, treating the middleware as a UX-only layer. |
| `getStoredToken()` / `getStoredUser()` (localStorage) | `src/lib/auth.ts` | As noted above, the token and full user data (including permissions) are stored in localStorage. | This is the single highest-value weak point for an XSS attack, especially given the dashboard's delete and dispatch operations. | Standardize on httpOnly cookies only, and use an endpoint like `/me` to fetch user data instead of storing it. |
| `CarMaintenanceFormModal.tsx` and other forms | Frontend-only yup validation | No clear documentation that the backend is expected to validate as well, and some string-to-number conversions can let `NaN` slip through into the payload. | Entering an invalid number (e.g. a garbled longitude) can turn into `NaN` and get sent to the backend. | Add an `Number.isFinite()` check right before submission, and reject with a form error instead of sending `NaN`. |
## 5. Performance Issues
| File | Location | Issue | Impact | Suggested Fix |
|---|---|---|---|---|
| `app/dashboard/cars/page.tsx`, `orders/page.tsx`, `drivers/page.tsx` | `onMouseEnter`/`onMouseLeave` directly mutating style | Hover state is implemented by manually mutating DOM style instead of CSS `:hover`. | Allocates new functions on every render for every row — something CSS does for free — and also breaks keyboard navigation since there's no `:focus` handling. | Use Tailwind `hover:bg-...`, as already used elsewhere in the codebase. |
| `useDrivers`, `useOrders`, `useUsers`, etc. | `notify` function | Every call to `notify()` creates a `setTimeout` without storing or clearing the ref (unlike `useTrip.ts`, which does this correctly). | Rapid successive operations (edit then delete) can cause an old toast to dismiss a newer one prematurely, or leave timers running after unmount. | Copy the `timerRef` pattern already used in `useTrip.ts` to the other hooks. |
| Most table/list components | `.map()` over the full page of results | No `React.memo` at the row level, so any state change in the parent re-renders every row. | Currently low impact given the current pagination size (10-12 items), but will become noticeable if page size grows. | Not urgent right now; if page size increases, extract a row component and wrap it in `React.memo`. |
## 6. Overall Assessment
**Key Challenges:**
1. **Inconsistent auth system** (localStorage + httpOnly cookie used together, and two different auth.service files) — the single biggest risk, both a security and an architectural issue.
2. **Code duplication** in delete modals and forms across more than 10 files — any fix has to be repeated manually, and some copies have already begun to diverge from each other.
3. **Superficial rather than real type safety** — widespread use of `as unknown as T` negates the benefit TypeScript provides in catching backend changes.
**Architectural Health Score: 6.5/10** — The type design, the archive pattern, and the consistency of RTL/logical CSS are genuinely strong, but all the weaknesses lie in cross-cutting concerns (auth, HTTP client, shared UI) that were solved well once (`ConfirmDialog`, `services/api.ts`, react-hook-form forms) but aren't applied consistently everywhere.
**Immediate Actions:**
1. Unify auth on a single storage mechanism and a single HTTP client; delete `src/service/auth.service.ts` and `src/lib/api.ts`.
2. Clean up console.log statements and debug leftovers, and replace the 9 duplicated modals with the existing `ConfirmDialog`.

View File

@@ -1,4 +1,5 @@
// src/Components/Branch/index.ts
// CHANGE: removed DeleteConfirmModal export (Issue 1).
export { BranchTable } from "./BranchTable";
export { BranchFormModal } from "./BranchFormModal";
export { BranchDetailModal } from "./BranchDetailModal";
export { DeleteConfirmModal } from "./DeleteConfirmModal";

View File

@@ -1,9 +1,12 @@
// src/Components/Car_Maintanance/CarMaintananceDetailPanel.tsx
// CHANGE: replaced CarMaintenanceDeleteModal with ConfirmDialog per Issue 1.
// Description string rebuilt inline using fmtCost, matching the original
// modal's wording exactly (see Issue 1 edge-case note on preserving fmtCost formatting).
"use client";
import { useCallback, useEffect, useState } from "react";
import { Spinner } from "../UI";
import { Spinner, ConfirmDialog } from "../UI";
import { CarMaintenanceFormModal } from "./CarMaintananceFormModal";
import { CarMaintenanceDeleteModal } from "./CarMaintananceDeleteModal";
import {
useCarMaintenanceList,
useCarMaintenanceMutations,
@@ -269,14 +272,23 @@ export function CarMaintenanceDetailPanel({ carId, carLabel, onClose, onCarStatu
/>
)}
{deleteTarget && (
<CarMaintenanceDeleteModal
record={deleteTarget}
deleting={deleting}
{/* CHANGE: CarMaintenanceDeleteModal -> ConfirmDialog (Issue 1).
`open` is now explicit since ConfirmDialog is not conditionally
mounted the way the old modal was. Description text ported
verbatim from the deleted CarMaintananceDeleteModal.tsx,
including fmtCost() formatting for the cost figure. */}
<ConfirmDialog
open={!!deleteTarget}
title="حذف سجل الصيانة"
description={
deleteTarget
? `هل أنت متأكد من حذف سجل ${deleteTarget.reason} (${fmtCost(deleteTarget.cost)})؟ لا يمكن التراجع عن هذا الإجراء.`
: ""
}
loading={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => handleDeleteConfirm(deleteTarget)}
onConfirm={() => deleteTarget && handleDeleteConfirm(deleteTarget)}
/>
)}
</>
);
}

View File

@@ -1,7 +1,6 @@
// src/Components/Client/index.ts
// CHANGE: removed DeleteConfirmModal export (Issue 1).
export { ClientFormModal } from "./Clientformmodal";
export { ClientTable } from "./Clienttable";
export { AddressFormModal } from "../Client_Adress/Addressformmodal";
export { DeleteConfirmModal } from "./Deleteconfirmmodal";
export { Toast } from "./Toast";

View File

@@ -1,4 +1,6 @@
// src/Components/Driver/index.ts
// CHANGE: removed DriverDeleteModal export (Issue 1) — page.tsx call sites
// should now import { ConfirmDialog } from "@/src/Components/UI" instead.
export { DriverFormModal } from "./DriverFormModal";
export { DriverDeleteModal } from "./DriverDeleteModal";
export { DriverDetailPanel } from "./DriverDetailPanel";
export { PhotoCard } from "./DriverPhotos";

View File

@@ -328,7 +328,6 @@ export function OrderFormModal({
// ── Submit ────────────────────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
console.log("her");
e.preventDefault();
@@ -1152,7 +1151,7 @@ export function OrderFormModal({
<button
type="submit"
disabled={saving}
onClick={() => console.log("BUTTON CLICKED DIRECTLY")}
style={{
height: 40,
padding: "0 1.5rem",

View File

@@ -1,2 +1,3 @@
// src/Components/Trip/index.ts
// CHANGE: removed TripDeleteModal export (Issue 1).
export { TripFormModal } from "./Tripformmodal";
export { TripDeleteModal } from "./Tripdeletemodal";

View File

@@ -1,3 +1,6 @@
// src/Components/UI/ConfirmDialog.tsx
// CHANGE: added optional `role` prop (default "alertdialog" — matches the 9
// legacy modals being replaced), forwarded into Modal.
import { Button } from "./Button";
import { Modal } from "./ModalProps";
@@ -12,6 +15,9 @@ interface ConfirmDialogProps {
loading?: boolean;
/** Subtitle shown in modal header */
subtitle?: string;
/** ARIA role — defaults to "alertdialog" to match the destructive-action
* semantics of the delete-confirmation modals this component replaces. */
role?: "dialog" | "alertdialog";
}
export function ConfirmDialog({
@@ -24,6 +30,7 @@ export function ConfirmDialog({
onCancel,
loading = false,
subtitle,
role = "alertdialog",
}: ConfirmDialogProps) {
return (
<Modal
@@ -32,6 +39,7 @@ export function ConfirmDialog({
subtitle={subtitle}
onClose={onCancel}
size="sm"
role={role}
footer={
<>
<Button variant="secondary" onClick={onCancel} disabled={loading}>

View File

@@ -1,3 +1,6 @@
// src/Components/UI/ModalProps.tsx
// CHANGE: added optional `role` prop (default "dialog") so callers like
// ConfirmDialog can request role="alertdialog" semantics without a new component.
import { useEffect } from "react";
import { cn } from "@/src/lib/utils";
@@ -11,6 +14,8 @@ interface ModalProps {
size?: "sm" | "md" | "lg";
/** Subtitle or badge shown below the title */
subtitle?: string;
/** ARIA role for the dialog panel — "alertdialog" for destructive/confirm flows */
role?: "dialog" | "alertdialog";
}
const MODAL_SIZES: Record<NonNullable<ModalProps["size"]>, string> = {
@@ -27,6 +32,7 @@ export function Modal({
footer,
size = "md",
subtitle,
role = "dialog",
}: ModalProps) {
// Escape key to close
useEffect(() => {
@@ -51,7 +57,7 @@ export function Modal({
{/* Panel */}
<div
role="dialog"
role={role}
aria-modal="true"
aria-labelledby="modal-title"
className={cn(

View File

@@ -1,4 +1,5 @@
// src/Components/User/index.ts
// CHANGE: removed DeleteConfirmModal export (Issue 1).
export { UserTable } from "./UserTable";
export { UserFormModal } from "./UserFormModal";
export { UserDetailModal } from "./UserDetailModal";
export { DeleteConfirmModal } from "./DeleteConfirmModal";

View File

@@ -1,4 +1,5 @@
// src/Components/car/index.ts
// CHANGE: removed CarDeleteModal export (Issue 1).
export { CarFormModal } from "./CarFormModal";
export { CarDetailPanel } from "./CarDetailPanel";
export { CarDeleteModal } from "./CarDeleteModal";
export { CarImageGallery } from "./CarImageGallery";

View File

@@ -1,4 +1,5 @@
// src/Components/role/index.ts
// CHANGE: removed DeleteRoleModal export (Issue 1).
export { RoleTable } from "./RoleTable";
export { RoleFormModal } from "./RoleFormModal";
export { RoleDetailModal } from "./RoleDetailModal";
export { DeleteRoleModal } from "./DeleteRoleModal";

View File

@@ -2,13 +2,9 @@
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { saveAuth } from "@/src/lib/auth";
import { authService } from "@/src/service/auth.service";
import type { AuthUser, LoginRequest, User } from "@/src/types/auth";
import { loginUser } from "@/src/lib/auth";
import { detectIdentityField } from "@/src/validations/auth.validator";
type RawRole = { name?: string; permissions?: Array<{ permission?: { slug?: string } }> } | string;
export function useAuth() {
const router = useRouter();
const [loading, setLoading] = useState(false);
@@ -18,50 +14,48 @@ export function useAuth() {
const login = useCallback(async (identity: string, password: string) => {
setError(null);
// ── Edge case 1: unidentifiable input rejected locally ──────────────
// detectIdentityField() returns null for empty/garbage identity strings
// (not a valid email, not a Saudi phone, not a username ≥2 chars).
// Previously this fell through silently and sent { password } alone
// to the backend. Now we fail fast with a clear Arabic message and
// never issue the request.
if (!detectIdentityField(identity)) {
setError(
"يجب إدخال بريد إلكتروني صحيح أو رقم هاتف سعودي صحيح أو اسم مستخدم لا يقل عن حرفين.",
);
return false;
}
setLoading(true);
try {
const field = detectIdentityField(identity);
const payload: LoginRequest = { password };
// ── FIX: delegate to loginUser() instead of duplicating the
// unwrap → block-check → saveAuth flow inline here.
// loginUser() is now the single source of truth and already:
// 1. builds the typed LoginRequest via detectIdentityField()
// 2. unwraps both possible response envelope shapes
// 3. rejects BLOCKED_ROLES ("driver" / "سائق") BEFORE any
// storage write — this check was completely missing from
// useAuth.ts's previous inline implementation
// 4. persists the HttpOnly cookie via POST /api/auth/set-cookie;
// a failure there is caught + console.warn'd *inside*
// loginUser(), so login still succeeds via the fallback
// JS-readable cookie set by saveAuth()
// 5. calls saveAuth(token, user), which normalizes
// user.role / user.permissions before persisting
await loginUser(identity, password);
if (field) {
payload[field] = identity;
}
const response = await authService.login(payload);
const envelope = (response as { data?: { data?: { token?: string; user?: User }; token?: string; user?: User } }).data ?? response;
const body = (envelope as { data?: { token?: string; user?: User }; token?: string; user?: User }).data ?? envelope;
const token = body?.token;
const user = body?.user;
if (!token || !user) {
throw new Error("اسم المستخدم أو كلمة المرور غير صحيحة.");
}
const rawRole = (user as unknown as { role?: RawRole }).role;
const roleName = typeof rawRole === "string"
? rawRole
: typeof rawRole === "object" && rawRole !== null
? rawRole.name
: undefined;
const rolePermissions =
typeof rawRole === "object" && rawRole !== null
? (rawRole as Exclude<RawRole, string>).permissions ?? []
: [];
const permissions = Array.isArray(rolePermissions)
? rolePermissions
.map((entry) => entry?.permission?.slug)
.filter((slug): slug is string => Boolean(slug))
: [];
const normalizedUser: AuthUser = { ...user, role: roleName, permissions };
saveAuth(token, normalizedUser);
router.replace("/dashboard");
return true;
} catch (err) {
const message = err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقًا.";
// ── Edge case 2: blocked-role rejection ──────────────────────────
// loginUser() throws "غير مصرح لك بالوصول إلى هذه اللوحة." for
// BLOCKED_ROLES *before* saveAuth() ever runs — that message is
// preserved verbatim below via the `else` branch, since it doesn't
// match the network/401 buckets.
const message =
err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقًا.";
const normalized = message.toLowerCase();
if (normalized.includes("network") || normalized.includes("fetch") || normalized.includes("timeout")) {
@@ -69,6 +63,9 @@ export function useAuth() {
} else if (normalized.includes("401") || normalized.includes("unauthorized")) {
setError("اسم المستخدم أو كلمة المرور غير صحيحة.");
} else {
// Surfaces loginUser()'s own thrown messages as-is, including:
// - "اسم المستخدم أو كلمة المرور غير صحيحة." (missing token/user)
// - "غير مصرح لك بالوصول إلى هذه اللوحة." (blocked role)
setError(message);
}

View File

@@ -1,4 +1,4 @@
// hooks/useDashboardSummary.ts
// src/hooks/useDashboardSummary.ts
"use client";
import { useEffect, useState } from "react";
@@ -14,7 +14,10 @@ interface State {
/**
* Fetches the admin dashboard summary.
* Redirects to /login if no token is present (handled by the caller).
* Auth is now enforced by the HttpOnly cookie (read server-side by the
* proxy route) — there is no client-readable token to gate on anymore,
* so we always attempt the request and let a 401 from the backend
* surface as a normal error instead of short-circuiting here.
*
* @example
* const { data, loading, error } = useDashboardSummary();
@@ -26,15 +29,10 @@ export function useDashboardSummary(): State {
useEffect(() => {
let cancelled = false;
const token = getStoredToken();
if (!token) {
queueMicrotask(() => setState({ data: null, error: "Unauthenticated", loading: false }));
return;
}
const token = getStoredToken(); // always null now — kept for the service signature
dashboardService
.getSummary(token)
.getSummary(token as unknown as string)
.then(res => {
if (!cancelled) setState({ data: res.data, error: null, loading: false });
})

View File

@@ -0,0 +1,29 @@
// src/hooks/useStoredUser.ts
// NEW FILE — Issue 3.
// Cheap, synchronous, locally-cached display value (name/avatar in topbar).
// This is deliberately NOT merged with useCurrentUser.ts, which fetches the
// authoritative server-side user via userService.getMe for pages that need
// fresh data. useStoredUser only reflects what's already in localStorage.
"use client";
import { useEffect, useState } from "react";
import { getStoredUser } from "@/src/lib/auth";
import type { AuthUser } from "@/src/types/auth";
export function useStoredUser() {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Avoid calling getStoredUser() during the initial render pass to
// prevent SSR/CSR hydration mismatches — read only inside the effect.
setUser(getStoredUser());
setLoading(false);
const handler = () => setUser(getStoredUser());
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, []);
return { user, loading };
}

View File

@@ -1,57 +1,173 @@
const DEFAULT_BASE_URL = "/api/proxy";
// src/lib/api.ts
import { authService } from "@/src/services/auth.service";
import type { AuthUser, LoginRequest } from "@/src/types/auth";
import { detectIdentityField } from "@/src/validations/auth.validator";
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL || DEFAULT_BASE_URL;
const AUTH_USER_KEY = "auth_user";
function normalizePath(path: string): string {
return path.startsWith("/") ? path : `/${path}`;
function normalizeUser(user: AuthUser | null | undefined): AuthUser | null {
if (!user) return null;
const rawRole = (user as unknown as { role?: unknown }).role;
const roleName = typeof rawRole === "string"
? rawRole
: typeof rawRole === "object" && rawRole !== null && "name" in rawRole && typeof (rawRole as { name?: unknown }).name === "string"
? (rawRole as { name: string }).name
: undefined;
const rawPermissions = Array.isArray((user as unknown as { permissions?: unknown[] }).permissions)
? (user as unknown as { permissions?: unknown[] }).permissions
: typeof rawRole === "object" && rawRole !== null && "permissions" in rawRole
? ((rawRole as { permissions?: unknown[] }).permissions ?? [])
: [];
const permissions = Array.isArray(rawPermissions)
? rawPermissions
.map((entry) => {
if (typeof entry === "string") return entry;
if (entry && typeof entry === "object") {
if ("slug" in entry && typeof (entry as { slug?: unknown }).slug === "string") {
return (entry as { slug: string }).slug;
}
if ("permission" in entry && entry.permission && typeof (entry.permission as { slug?: unknown }).slug === "string") {
return (entry.permission as { slug: string }).slug;
}
}
return null;
})
.filter((entry): entry is string => Boolean(entry))
: [];
return { ...user, role: roleName, permissions };
}
export async function requestJson<T>(
path: string,
init: RequestInit = {},
token: string | null = null,
): Promise<T> {
const headers = new Headers(init.headers || {});
// ── Storage ────────────────────────────────────────────────────────────────
// Option A: the HttpOnly `auth_token` cookie (set server-side via
// /api/auth/set-cookie, read by middleware.ts and the API proxy route) is
// now the single source of truth for the auth token. It is never written
// to localStorage or to a JS-readable cookie from here anymore.
if (!headers.has("Content-Type") && !(init.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
/**
* No-op kept for call-site compatibility. The token now lives only in the
* HttpOnly cookie, which client-side JS cannot read by design — every
* caller of this function will receive `null` going forward.
*/
export function getStoredToken(): string | null {
return null;
}
if (token) {
headers.set("Authorization", `Bearer ${token}`);
export function getStoredUser(): AuthUser | null {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem(AUTH_USER_KEY);
if (!raw) return null;
try { return normalizeUser(JSON.parse(raw) as AuthUser); } catch { return null; }
}
const endpoint = `${API_BASE_URL}${normalizePath(path)}`;
/**
* Persists only the normalized user for UI display. The token itself is no
* longer stored client-side — it is set as an HttpOnly cookie separately by
* the caller (see loginUser below), which is the sole source of truth now.
*/
export function saveAuth(token: string, user: AuthUser) {
if (typeof window === "undefined") return;
const normalizedUser = normalizeUser(user);
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser));
}
let attempt = 0;
const maxAttempts = 3;
while (attempt < maxAttempts) {
/**
* Clear auth state: removes the locally-stored user, and calls
* /api/clear-cookie to remove the HttpOnly cookie (only the server can
* delete an HttpOnly cookie).
*/
export async function clearAuth(): Promise<void> {
if (typeof window !== "undefined") {
localStorage.removeItem(AUTH_USER_KEY);
}
try {
const response = await fetch(endpoint, {
...init,
headers,
cache: "no-store",
method: init.method || "GET",
await fetch("/api/clear-cookie", { method: "POST", cache: "no-store" });
} catch {
// Best-effort — if the network call fails the cookie will expire on its own
}
}
// ── Blocked roles ──────────────────────────────────────────────────────────
const BLOCKED_ROLES = ["driver", "سائق"] as const;
// ── Login ──────────────────────────────────────────────────────────────────
type RawRole = { name?: string; permissions?: Array<{ permission?: { slug?: string } }> } | string;
export async function loginUser(identity: string, password: string) {
// FIX (Problem 2): build a proper LoginRequest — the backend expects one
// specific typed field (email | phone | userName), not a generic
// `identity` string. This is the same detection useAuth.ts already uses,
// so both call sites now agree on one contract instead of two.
const field = detectIdentityField(identity);
if (!field) {
throw new Error(
"يجب إدخال بريد إلكتروني صحيح أو رقم هاتف سعودي صحيح أو اسم مستخدم لا يقل عن حرفين.",
);
}
const payload: LoginRequest = { password };
payload[field] = identity;
const response = await authService.login(payload);
// Response may arrive already-unwrapped or nested under `data`, depending
// on the fetch wrapper — normalize both shapes, same as useAuth.ts.
const envelope = (response as { data?: { data?: { token?: string; user?: AuthUser }; token?: string; user?: AuthUser } }).data ?? response;
const body = (envelope as { data?: { token?: string; user?: AuthUser }; token?: string; user?: AuthUser }).data ?? envelope;
const token = body?.token;
const user = body?.user;
if (!token || !user) {
throw new Error("اسم المستخدم أو كلمة المرور غير صحيحة.");
}
const rawRole = (user as unknown as { role?: RawRole }).role;
const roleName = typeof rawRole === "string"
? rawRole
: typeof rawRole === "object" && rawRole !== null
? rawRole.name
: undefined;
// FIX (Problem 1): this now actually executes — loginUser() is type-correct
// and reachable, so a blocked role is rejected here, before any token or
// cookie is ever stored, instead of relying solely on the middleware's
// after-the-fact redirect to /forbidden.
if (roleName && (BLOCKED_ROLES as readonly string[]).includes(roleName)) {
throw new Error("غير مصرح لك بالوصول إلى هذه اللوحة.");
}
const rolePermissions =
typeof rawRole === "object" && rawRole !== null
? (rawRole as Exclude<RawRole, string>).permissions ?? []
: [];
const permissions = Array.isArray(rolePermissions)
? rolePermissions
.map(e => e?.permission?.slug)
.filter((s): s is string => Boolean(s))
: [];
// Store HttpOnly cookie via server endpoint (only the server can set
// HttpOnly). This is now the only place the raw token is persisted
// anywhere — there is no JS-readable fallback cookie left.
try {
await fetch("/api/auth/set-cookie", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (!response.ok) {
const json = await response.json().catch(() => null);
const message =
json?.message ||
json?.error ||
`Request failed with status ${response.status}`;
throw new Error(message);
} catch {
// Non-fatal for the JS-visible session (saveAuth() below still persists
// the user for UI purposes), but the HttpOnly auth cookie won't be set
// — log it so it's visible, since middleware/proxy calls will fail
// silently as "unauthenticated" until the person logs in again.
console.warn("loginUser: failed to persist HttpOnly auth cookie.");
}
return (await response.json()) as T;
} catch (error) {
attempt += 1;
if (attempt >= maxAttempts) throw error;
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 300));
}
}
throw new Error("Request failed after retries.");
const fullUser: AuthUser = { ...user, role: roleName, permissions };
saveAuth(token, fullUser);
return { token, user: fullUser };
}

View File

@@ -1,9 +1,9 @@
// src/lib/auth.ts
import { authService } from "@/src/services/auth.service";
import type { AuthUser } from "@/src/types/auth";
import type { AuthUser, LoginRequest } from "@/src/types/auth";
import { detectIdentityField } from "@/src/validations/auth.validator";
const AUTH_TOKEN_KEY = "auth_token";
const AUTH_USER_KEY = "auth_user";
const AUTH_TOKEN_COOKIE = "auth_token";
function normalizeUser(user: AuthUser | null | undefined): AuthUser | null {
if (!user) return null;
@@ -41,22 +41,19 @@ function normalizeUser(user: AuthUser | null | undefined): AuthUser | null {
return { ...user, role: roleName, permissions };
}
// ── Cookie helpers ─────────────────────────────────────────────────────────
function setTokenCookie(token: string) {
if (typeof document === "undefined") return;
const exp = new Date(Date.now() + 7 * 864e5).toUTCString();
document.cookie = `${AUTH_TOKEN_COOKIE}=${encodeURIComponent(token)}; expires=${exp}; path=/; SameSite=Lax`;
}
function deleteTokenCookie() {
if (typeof document === "undefined") return;
document.cookie = `${AUTH_TOKEN_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
}
// ── Storage ────────────────────────────────────────────────────────────────
// Option A: the HttpOnly `auth_token` cookie (set server-side via
// /api/auth/set-cookie, read by middleware.ts and the API proxy route) is
// now the single source of truth for the auth token. It is never written
// to localStorage or to a JS-readable cookie from here anymore.
/**
* No-op kept for call-site compatibility. The token now lives only in the
* HttpOnly cookie, which client-side JS cannot read by design — every
* caller of this function will receive `null` going forward.
*/
export function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(AUTH_TOKEN_KEY);
return null;
}
export function getStoredUser(): AuthUser | null {
@@ -66,23 +63,25 @@ export function getStoredUser(): AuthUser | null {
try { return normalizeUser(JSON.parse(raw) as AuthUser); } catch { return null; }
}
/**
* Persists only the normalized user for UI display. The token itself is no
* longer stored client-side — it is set as an HttpOnly cookie separately by
* the caller (see loginUser below), which is the sole source of truth now.
*/
export function saveAuth(token: string, user: AuthUser) {
if (typeof window === "undefined") return;
const normalizedUser = normalizeUser(user);
localStorage.setItem(AUTH_TOKEN_KEY, token);
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser));
setTokenCookie(token);
}
/**
* Clear auth state: removes localStorage keys, calls /api/clear-cookie to
* remove the HttpOnly cookie (only the server can delete an HttpOnly cookie).
* Clear auth state: removes the locally-stored user, and calls
* /api/clear-cookie to remove the HttpOnly cookie (only the server can
* delete an HttpOnly cookie).
*/
export async function clearAuth(): Promise<void> {
if (typeof window !== "undefined") {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_USER_KEY);
deleteTokenCookie();
}
try {
await fetch("/api/clear-cookie", { method: "POST", cache: "no-store" });
@@ -98,9 +97,30 @@ const BLOCKED_ROLES = ["driver", "سائق"] as const;
type RawRole = { name?: string; permissions?: Array<{ permission?: { slug?: string } }> } | string;
export async function loginUser(identity: string, password: string) {
const payload = await authService.login({ identity, password });
// FIX: LoginRequest has no generic `identity` field — it expects one
// specific typed field (email | phone | userName) plus password. Detect
// which one `identity` actually is, same as src/lib/api.ts's loginUser
// and useAuth.ts already do, so all three call sites agree on one
// contract instead of sending a shape the type (and backend) don't accept.
const field = detectIdentityField(identity);
if (!field) {
throw new Error(
"يجب إدخال بريد إلكتروني صحيح أو رقم هاتف سعودي صحيح أو اسم مستخدم لا يقل عن حرفين.",
);
}
const payload: LoginRequest = { password };
payload[field] = identity;
const response = await authService.login(payload);
// Response may arrive already-unwrapped or nested under `data`, depending
// on the fetch wrapper — normalize both shapes, same as useAuth.ts / api.ts.
const envelope = (response as { data?: { data?: { token?: string; user?: AuthUser }; token?: string; user?: AuthUser } }).data ?? response;
const body = (envelope as { data?: { token?: string; user?: AuthUser }; token?: string; user?: AuthUser }).data ?? envelope;
const token = body?.token;
const user = body?.user;
const { token, user } = payload.data || {};
if (!token || !user) {
throw new Error("اسم المستخدم أو كلمة المرور غير صحيحة.");
}
@@ -127,7 +147,8 @@ export async function loginUser(identity: string, password: string) {
.filter((s): s is string => Boolean(s))
: [];
// Store HttpOnly cookie via server endpoint
// Store HttpOnly cookie via server endpoint — this is now the only place
// the raw token is persisted anywhere.
try {
await fetch("/api/auth/set-cookie", {
method: "POST",

View File

@@ -1,14 +0,0 @@
import axios from "axios";
import type { LoginRequest, LoginResponse } from "@/src/types/auth";
const authApi = axios.create({
baseURL: "/api/proxy",
timeout: 20000,
});
export const authService = {
login: async (payload: LoginRequest): Promise<LoginResponse> => {
const response = await authApi.post<LoginResponse>("/auth/login", payload);
return response.data;
},
};

View File

@@ -16,7 +16,44 @@ export class ApiError extends Error {
}
}
// ── Core request helper ────────────────────────────────
// ── Sensitive-field redaction ─────────────────────────────────────────
// Keys matched case-insensitively; extend this list as new sensitive
// fields are introduced (e.g. new auth flows, payment fields, etc).
const SENSITIVE_KEYS = ["password", "token", "secret", "authorization", "refreshtoken"];
function redactSensitiveFields(value: unknown): unknown {
if (value instanceof FormData) return "[FormData]";
let parsed: unknown = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return value; // not JSON — nothing structured to redact
}
}
if (Array.isArray(parsed)) {
return parsed.map((item) => redactSensitiveFields(JSON.stringify(item)));
}
if (parsed && typeof parsed === "object") {
const redacted: Record<string, unknown> = {};
for (const [key, val] of Object.entries(parsed as Record<string, unknown>)) {
if (SENSITIVE_KEYS.includes(key.toLowerCase())) {
redacted[key] = "[REDACTED]";
} else if (val && typeof val === "object") {
redacted[key] = redactSensitiveFields(JSON.stringify(val));
} else {
redacted[key] = val;
}
}
return redacted;
}
return parsed;
}
export async function request<T>(
path: string,
init: RequestInit = {},
@@ -35,9 +72,14 @@ export async function request<T>(
if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console
console.debug("API request:", { url, method: init.method ?? "GET", body: init.body });
console.debug("API request:", {
url,
method: init.method ?? "GET",
body: redactSensitiveFields(init.body),
});
}
const res = await fetch(url, {
...init,
headers,

View File

@@ -1,25 +1,23 @@
import { post } from "./api";
import type { LoginPayload, LoginResponse } from "@/src/types/auth";
import type { LoginRequest, LoginResponse } from "@/src/types/auth";
export const authService = {
/**
* Authenticate a user with email / phone / username + password.
* Throws `ApiError` on network or credential failure.
* POST /auth/login
* الـ payload لازم يحتوي على حقل واحد محدد النوع (email | phone | userName)
* بالإضافة لـ password — مش حقل عام اسمه identity. useAuth.ts هو المسؤول
* عن تحديد الحقل الصحيح عن طريق detectIdentityField() قبل استدعاء login().
* (كان هذا الـ contract سابقًا في src/service/auth.service.ts عبر axios؛
* تم نقله هنا بنفس الـ endpoint تمامًا — resolveBase() هنا بترجع "/api/proxy"
* من ناحية الـ client، وهو نفس baseURL اللي كان axios شغال بيه، فالـ URL
* النهائي "/api/proxy/auth/login" لم يتغير.)
*/
login: (payload: LoginPayload) =>
login: (payload: LoginRequest) =>
post<LoginResponse>("auth/login", payload),
/**
* Request a password reset email.
*/
forgotPassword: (email: string) =>
post<{ message: string }>("auth/forgot-password", { email }),
/**
* Confirm a password reset with a token.
*/
resetPassword: (token: string, newPassword: string) =>
post<{ message: string }>("auth/reset-password", { token, newPassword }),
};

View File

@@ -18,12 +18,23 @@ export const carMaintenanceService = {
/**
* GET /cars/:carId/maintenance
* Fetch every maintenance record that belongs to one car.
* NOTE: backend does NOT support pagination on this endpoint — confirmed
* against a live response, which returns the full array under `data`
* with no `pagination`/`meta` block. Capped client-side to avoid
* rendering/sorting an unbounded list in the UI.
*
* Example response:
* { "data": { "data": [ { "id": "...", "reason": "تغيير زيت", "cost": 150, ... } ] } }
* { "success": true, "message": "...", "responseAt": "...",
* "data": [ { "id": "...", "reason": "تشحيم", "cost": "30", ... } ] }
*/
getAll: (carId: string, token: string | null) =>
get<MaintenanceListResponse>(`cars/${carId}/maintenance`, token),
getAll: async (carId: string, token: string | null): Promise<MaintenanceListResponse> => {
const MAX_RECORDS = 100;
const res = await get<MaintenanceListResponse>(`cars/${carId}/maintenance`, token);
if (res.data.length > MAX_RECORDS) {
return { ...res, data: res.data.slice(0, MAX_RECORDS) };
}
return res;
},
/**
* GET /cars/:carId/maintenance/:maintenanceId