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:
@@ -147,9 +147,7 @@ const GRAD = "linear-gradient(175deg, #1E3A8A 0%, #1D4ED8 60%, #1565C0 100%)";
|
|||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const user = getStoredUser();
|
const user = getStoredUser();
|
||||||
const permissions =
|
const permissions = user?.permissions ?? [];
|
||||||
user?.permissions ??
|
|
||||||
navSections.flatMap((s) => s.items.map((i) => i.permission));
|
|
||||||
const { open, setOpen } = useSidebarDrawer();
|
const { open, setOpen } = useSidebarDrawer();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function DashboardPage() {
|
|||||||
{ label: "العملاء", value: stats?.clients ?? 0, accent: "#06B6D4" },
|
{ label: "العملاء", value: stats?.clients ?? 0, accent: "#06B6D4" },
|
||||||
{ label: "الطلبات", value: stats?.orders ?? 0, accent: "#A78BFA" },
|
{ label: "الطلبات", value: stats?.orders ?? 0, accent: "#A78BFA" },
|
||||||
{ label: "الرحلات", value: stats?.trips ?? 0, accent: "#34D399" },
|
{ 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],
|
[stats],
|
||||||
);
|
);
|
||||||
@@ -60,24 +60,7 @@ export default function DashboardPage() {
|
|||||||
عرض واضح لطلبات الأسطول وتنبيهات السلامة وتقدم الرحلات.
|
عرض واضح لطلبات الأسطول وتنبيهات السلامة وتقدم الرحلات.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -134,7 +117,7 @@ export default function DashboardPage() {
|
|||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9", textAlign: "start" }}>الرحلات النشطة</p>
|
<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>
|
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>تقدم الرحلات</h2>
|
||||||
</div>
|
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Navbar from "@/app/components/layout/Navbar";
|
|
||||||
import Logo from "@/src/utils/logo";
|
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "الطلبات النشطة", value: "48", color: "text-blue-600" },
|
{ label: "الطلبات النشطة", value: "48", color: "text-blue-600" },
|
||||||
|
|||||||
58
docs/problem.md
Normal file
58
docs/problem.md
Normal 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`.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
|
// src/Components/Branch/index.ts
|
||||||
|
// CHANGE: removed DeleteConfirmModal export (Issue 1).
|
||||||
export { BranchTable } from "./BranchTable";
|
export { BranchTable } from "./BranchTable";
|
||||||
export { BranchFormModal } from "./BranchFormModal";
|
export { BranchFormModal } from "./BranchFormModal";
|
||||||
export { BranchDetailModal } from "./BranchDetailModal";
|
export { BranchDetailModal } from "./BranchDetailModal";
|
||||||
export { DeleteConfirmModal } from "./DeleteConfirmModal";
|
|
||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Spinner } from "../UI";
|
import { Spinner, ConfirmDialog } from "../UI";
|
||||||
import { CarMaintenanceFormModal } from "./CarMaintananceFormModal";
|
import { CarMaintenanceFormModal } from "./CarMaintananceFormModal";
|
||||||
import { CarMaintenanceDeleteModal } from "./CarMaintananceDeleteModal";
|
|
||||||
import {
|
import {
|
||||||
useCarMaintenanceList,
|
useCarMaintenanceList,
|
||||||
useCarMaintenanceMutations,
|
useCarMaintenanceMutations,
|
||||||
@@ -269,14 +272,23 @@ export function CarMaintenanceDetailPanel({ carId, carLabel, onClose, onCarStatu
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deleteTarget && (
|
{/* CHANGE: CarMaintenanceDeleteModal -> ConfirmDialog (Issue 1).
|
||||||
<CarMaintenanceDeleteModal
|
`open` is now explicit since ConfirmDialog is not conditionally
|
||||||
record={deleteTarget}
|
mounted the way the old modal was. Description text ported
|
||||||
deleting={deleting}
|
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)}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
onConfirm={() => handleDeleteConfirm(deleteTarget)}
|
onConfirm={() => deleteTarget && handleDeleteConfirm(deleteTarget)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
|
// src/Components/Client/index.ts
|
||||||
|
// CHANGE: removed DeleteConfirmModal export (Issue 1).
|
||||||
export { ClientFormModal } from "./Clientformmodal";
|
export { ClientFormModal } from "./Clientformmodal";
|
||||||
export { ClientTable } from "./Clienttable";
|
export { ClientTable } from "./Clienttable";
|
||||||
export { AddressFormModal } from "../Client_Adress/Addressformmodal";
|
export { AddressFormModal } from "../Client_Adress/Addressformmodal";
|
||||||
export { DeleteConfirmModal } from "./Deleteconfirmmodal";
|
|
||||||
export { Toast } from "./Toast";
|
export { Toast } from "./Toast";
|
||||||
@@ -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 { DriverFormModal } from "./DriverFormModal";
|
||||||
export { DriverDeleteModal } from "./DriverDeleteModal";
|
|
||||||
export { DriverDetailPanel } from "./DriverDetailPanel";
|
export { DriverDetailPanel } from "./DriverDetailPanel";
|
||||||
export { PhotoCard } from "./DriverPhotos";
|
export { PhotoCard } from "./DriverPhotos";
|
||||||
@@ -328,7 +328,6 @@ export function OrderFormModal({
|
|||||||
|
|
||||||
// ── Submit ────────────────────────────────────────────────────────────────
|
// ── Submit ────────────────────────────────────────────────────────────────
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
console.log("her");
|
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -1152,7 +1151,7 @@ export function OrderFormModal({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => console.log("BUTTON CLICKED DIRECTLY")}
|
|
||||||
style={{
|
style={{
|
||||||
height: 40,
|
height: 40,
|
||||||
padding: "0 1.5rem",
|
padding: "0 1.5rem",
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
|
// src/Components/Trip/index.ts
|
||||||
|
// CHANGE: removed TripDeleteModal export (Issue 1).
|
||||||
export { TripFormModal } from "./Tripformmodal";
|
export { TripFormModal } from "./Tripformmodal";
|
||||||
export { TripDeleteModal } from "./Tripdeletemodal";
|
|
||||||
@@ -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 { Button } from "./Button";
|
||||||
import { Modal } from "./ModalProps";
|
import { Modal } from "./ModalProps";
|
||||||
|
|
||||||
@@ -12,6 +15,9 @@ interface ConfirmDialogProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** Subtitle shown in modal header */
|
/** Subtitle shown in modal header */
|
||||||
subtitle?: string;
|
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({
|
export function ConfirmDialog({
|
||||||
@@ -24,6 +30,7 @@ export function ConfirmDialog({
|
|||||||
onCancel,
|
onCancel,
|
||||||
loading = false,
|
loading = false,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
role = "alertdialog",
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -32,6 +39,7 @@ export function ConfirmDialog({
|
|||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
role={role}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={onCancel} disabled={loading}>
|
<Button variant="secondary" onClick={onCancel} disabled={loading}>
|
||||||
|
|||||||
@@ -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 { useEffect } from "react";
|
||||||
import { cn } from "@/src/lib/utils";
|
import { cn } from "@/src/lib/utils";
|
||||||
|
|
||||||
@@ -11,6 +14,8 @@ interface ModalProps {
|
|||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
/** Subtitle or badge shown below the title */
|
/** Subtitle or badge shown below the title */
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
/** ARIA role for the dialog panel — "alertdialog" for destructive/confirm flows */
|
||||||
|
role?: "dialog" | "alertdialog";
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODAL_SIZES: Record<NonNullable<ModalProps["size"]>, string> = {
|
const MODAL_SIZES: Record<NonNullable<ModalProps["size"]>, string> = {
|
||||||
@@ -27,6 +32,7 @@ export function Modal({
|
|||||||
footer,
|
footer,
|
||||||
size = "md",
|
size = "md",
|
||||||
subtitle,
|
subtitle,
|
||||||
|
role = "dialog",
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
// Escape key to close
|
// Escape key to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,7 +57,7 @@ export function Modal({
|
|||||||
|
|
||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role={role}
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-title"
|
aria-labelledby="modal-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
|
// src/Components/User/index.ts
|
||||||
|
// CHANGE: removed DeleteConfirmModal export (Issue 1).
|
||||||
export { UserTable } from "./UserTable";
|
export { UserTable } from "./UserTable";
|
||||||
export { UserFormModal } from "./UserFormModal";
|
export { UserFormModal } from "./UserFormModal";
|
||||||
export { UserDetailModal } from "./UserDetailModal";
|
export { UserDetailModal } from "./UserDetailModal";
|
||||||
export { DeleteConfirmModal } from "./DeleteConfirmModal";
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
|
// src/Components/car/index.ts
|
||||||
|
// CHANGE: removed CarDeleteModal export (Issue 1).
|
||||||
export { CarFormModal } from "./CarFormModal";
|
export { CarFormModal } from "./CarFormModal";
|
||||||
export { CarDetailPanel } from "./CarDetailPanel";
|
export { CarDetailPanel } from "./CarDetailPanel";
|
||||||
export { CarDeleteModal } from "./CarDeleteModal";
|
|
||||||
export { CarImageGallery } from "./CarImageGallery";
|
export { CarImageGallery } from "./CarImageGallery";
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
|
// src/Components/role/index.ts
|
||||||
|
// CHANGE: removed DeleteRoleModal export (Issue 1).
|
||||||
export { RoleTable } from "./RoleTable";
|
export { RoleTable } from "./RoleTable";
|
||||||
export { RoleFormModal } from "./RoleFormModal";
|
export { RoleFormModal } from "./RoleFormModal";
|
||||||
export { RoleDetailModal } from "./RoleDetailModal";
|
export { RoleDetailModal } from "./RoleDetailModal";
|
||||||
export { DeleteRoleModal } from "./DeleteRoleModal";
|
|
||||||
|
|||||||
@@ -2,13 +2,9 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { saveAuth } from "@/src/lib/auth";
|
import { loginUser } from "@/src/lib/auth";
|
||||||
import { authService } from "@/src/service/auth.service";
|
|
||||||
import type { AuthUser, LoginRequest, User } from "@/src/types/auth";
|
|
||||||
import { detectIdentityField } from "@/src/validations/auth.validator";
|
import { detectIdentityField } from "@/src/validations/auth.validator";
|
||||||
|
|
||||||
type RawRole = { name?: string; permissions?: Array<{ permission?: { slug?: string } }> } | string;
|
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -18,50 +14,48 @@ export function useAuth() {
|
|||||||
|
|
||||||
const login = useCallback(async (identity: string, password: string) => {
|
const login = useCallback(async (identity: string, password: string) => {
|
||||||
setError(null);
|
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);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const field = detectIdentityField(identity);
|
// ── FIX: delegate to loginUser() instead of duplicating the
|
||||||
const payload: LoginRequest = { password };
|
// 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");
|
router.replace("/dashboard");
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} 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();
|
const normalized = message.toLowerCase();
|
||||||
|
|
||||||
if (normalized.includes("network") || normalized.includes("fetch") || normalized.includes("timeout")) {
|
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")) {
|
} else if (normalized.includes("401") || normalized.includes("unauthorized")) {
|
||||||
setError("اسم المستخدم أو كلمة المرور غير صحيحة.");
|
setError("اسم المستخدم أو كلمة المرور غير صحيحة.");
|
||||||
} else {
|
} else {
|
||||||
|
// Surfaces loginUser()'s own thrown messages as-is, including:
|
||||||
|
// - "اسم المستخدم أو كلمة المرور غير صحيحة." (missing token/user)
|
||||||
|
// - "غير مصرح لك بالوصول إلى هذه اللوحة." (blocked role)
|
||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// hooks/useDashboardSummary.ts
|
// src/hooks/useDashboardSummary.ts
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -14,7 +14,10 @@ interface State {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the admin dashboard summary.
|
* 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
|
* @example
|
||||||
* const { data, loading, error } = useDashboardSummary();
|
* const { data, loading, error } = useDashboardSummary();
|
||||||
@@ -26,15 +29,10 @@ export function useDashboardSummary(): State {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const token = getStoredToken();
|
const token = getStoredToken(); // always null now — kept for the service signature
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
queueMicrotask(() => setState({ data: null, error: "Unauthenticated", loading: false }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dashboardService
|
dashboardService
|
||||||
.getSummary(token)
|
.getSummary(token as unknown as string)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (!cancelled) setState({ data: res.data, error: null, loading: false });
|
if (!cancelled) setState({ data: res.data, error: null, loading: false });
|
||||||
})
|
})
|
||||||
|
|||||||
29
src/hooks/useStoredUser.ts
Normal file
29
src/hooks/useStoredUser.ts
Normal 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 };
|
||||||
|
}
|
||||||
212
src/lib/api.ts
212
src/lib/api.ts
@@ -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 =
|
const AUTH_USER_KEY = "auth_user";
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL || DEFAULT_BASE_URL;
|
|
||||||
|
|
||||||
function normalizePath(path: string): string {
|
function normalizeUser(user: AuthUser | null | undefined): AuthUser | null {
|
||||||
return path.startsWith("/") ? path : `/${path}`;
|
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>(
|
// ── Storage ────────────────────────────────────────────────────────────────
|
||||||
path: string,
|
// Option A: the HttpOnly `auth_token` cookie (set server-side via
|
||||||
init: RequestInit = {},
|
// /api/auth/set-cookie, read by middleware.ts and the API proxy route) is
|
||||||
token: string | null = null,
|
// now the single source of truth for the auth token. It is never written
|
||||||
): Promise<T> {
|
// to localStorage or to a JS-readable cookie from here anymore.
|
||||||
const headers = new Headers(init.headers || {});
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers.set("Authorization", `Bearer ${token}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = `${API_BASE_URL}${normalizePath(path)}`;
|
|
||||||
|
|
||||||
let attempt = 0;
|
|
||||||
const maxAttempts = 3;
|
|
||||||
|
|
||||||
while (attempt < maxAttempts) {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch("/api/clear-cookie", { method: "POST", cache: "no-store" });
|
||||||
...init,
|
} catch {
|
||||||
headers,
|
// Best-effort — if the network call fails the cookie will expire on its own
|
||||||
cache: "no-store",
|
|
||||||
method: init.method || "GET",
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return (await response.json()) as T;
|
|
||||||
} catch (error) {
|
// ── Blocked roles ──────────────────────────────────────────────────────────
|
||||||
attempt += 1;
|
const BLOCKED_ROLES = ["driver", "سائق"] as const;
|
||||||
if (attempt >= maxAttempts) throw error;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 300));
|
// ── Login ──────────────────────────────────────────────────────────────────
|
||||||
}
|
type RawRole = { name?: string; permissions?: Array<{ permission?: { slug?: string } }> } | string;
|
||||||
}
|
|
||||||
|
export async function loginUser(identity: string, password: string) {
|
||||||
throw new Error("Request failed after retries.");
|
// 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 }),
|
||||||
|
});
|
||||||
|
} 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullUser: AuthUser = { ...user, role: roleName, permissions };
|
||||||
|
saveAuth(token, fullUser);
|
||||||
|
return { token, user: fullUser };
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
|
// src/lib/auth.ts
|
||||||
import { authService } from "@/src/services/auth.service";
|
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_USER_KEY = "auth_user";
|
||||||
const AUTH_TOKEN_COOKIE = "auth_token";
|
|
||||||
|
|
||||||
function normalizeUser(user: AuthUser | null | undefined): AuthUser | null {
|
function normalizeUser(user: AuthUser | null | undefined): AuthUser | null {
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
@@ -41,22 +41,19 @@ function normalizeUser(user: AuthUser | null | undefined): AuthUser | null {
|
|||||||
return { ...user, role: roleName, permissions };
|
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 ────────────────────────────────────────────────────────────────
|
// ── 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 {
|
export function getStoredToken(): string | null {
|
||||||
if (typeof window === "undefined") return null;
|
return null;
|
||||||
return localStorage.getItem(AUTH_TOKEN_KEY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStoredUser(): AuthUser | 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; }
|
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) {
|
export function saveAuth(token: string, user: AuthUser) {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const normalizedUser = normalizeUser(user);
|
const normalizedUser = normalizeUser(user);
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, token);
|
|
||||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser));
|
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser));
|
||||||
setTokenCookie(token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear auth state: removes localStorage keys, calls /api/clear-cookie to
|
* Clear auth state: removes the locally-stored user, and calls
|
||||||
* remove the HttpOnly cookie (only the server can delete an HttpOnly cookie).
|
* /api/clear-cookie to remove the HttpOnly cookie (only the server can
|
||||||
|
* delete an HttpOnly cookie).
|
||||||
*/
|
*/
|
||||||
export async function clearAuth(): Promise<void> {
|
export async function clearAuth(): Promise<void> {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(AUTH_USER_KEY);
|
localStorage.removeItem(AUTH_USER_KEY);
|
||||||
deleteTokenCookie();
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await fetch("/api/clear-cookie", { method: "POST", cache: "no-store" });
|
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;
|
type RawRole = { name?: string; permissions?: Array<{ permission?: { slug?: string } }> } | string;
|
||||||
|
|
||||||
export async function loginUser(identity: string, password: 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) {
|
if (!token || !user) {
|
||||||
throw new Error("اسم المستخدم أو كلمة المرور غير صحيحة.");
|
throw new Error("اسم المستخدم أو كلمة المرور غير صحيحة.");
|
||||||
}
|
}
|
||||||
@@ -127,7 +147,8 @@ export async function loginUser(identity: string, password: string) {
|
|||||||
.filter((s): s is string => Boolean(s))
|
.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 {
|
try {
|
||||||
await fetch("/api/auth/set-cookie", {
|
await fetch("/api/auth/set-cookie", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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>(
|
export async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
@@ -35,9 +72,14 @@ export async function request<T>(
|
|||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
// eslint-disable-next-line no-console
|
// 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, {
|
const res = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import { post } from "./api";
|
import { post } from "./api";
|
||||||
import type { LoginPayload, LoginResponse } from "@/src/types/auth";
|
import type { LoginRequest, LoginResponse } from "@/src/types/auth";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
/**
|
/**
|
||||||
* Authenticate a user with email / phone / username + password.
|
* POST /auth/login
|
||||||
* Throws `ApiError` on network or credential failure.
|
* الـ 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),
|
post<LoginResponse>("auth/login", payload),
|
||||||
|
|
||||||
/**
|
|
||||||
* Request a password reset email.
|
|
||||||
*/
|
|
||||||
forgotPassword: (email: string) =>
|
forgotPassword: (email: string) =>
|
||||||
post<{ message: string }>("auth/forgot-password", { email }),
|
post<{ message: string }>("auth/forgot-password", { email }),
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm a password reset with a token.
|
|
||||||
*/
|
|
||||||
resetPassword: (token: string, newPassword: string) =>
|
resetPassword: (token: string, newPassword: string) =>
|
||||||
post<{ message: string }>("auth/reset-password", { token, newPassword }),
|
post<{ message: string }>("auth/reset-password", { token, newPassword }),
|
||||||
};
|
};
|
||||||
@@ -18,12 +18,23 @@ export const carMaintenanceService = {
|
|||||||
/**
|
/**
|
||||||
* GET /cars/:carId/maintenance
|
* GET /cars/:carId/maintenance
|
||||||
* Fetch every maintenance record that belongs to one car.
|
* 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:
|
* Example response:
|
||||||
* { "data": { "data": [ { "id": "...", "reason": "تغيير زيت", "cost": 150, ... } ] } }
|
* { "success": true, "message": "...", "responseAt": "...",
|
||||||
|
* "data": [ { "id": "...", "reason": "تشحيم", "cost": "30", ... } ] }
|
||||||
*/
|
*/
|
||||||
getAll: (carId: string, token: string | null) =>
|
getAll: async (carId: string, token: string | null): Promise<MaintenanceListResponse> => {
|
||||||
get<MaintenanceListResponse>(`cars/${carId}/maintenance`, token),
|
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
|
* GET /cars/:carId/maintenance/:maintenanceId
|
||||||
|
|||||||
Reference in New Issue
Block a user