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:
@@ -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";
|
||||
export { BranchDetailModal } from "./BranchDetailModal";
|
||||
@@ -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}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => handleDeleteConfirm(deleteTarget)}
|
||||
/>
|
||||
)}
|
||||
{/* 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={() => deleteTarget && handleDeleteConfirm(deleteTarget)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { TripFormModal } from "./Tripformmodal";
|
||||
export { TripDeleteModal } from "./Tripdeletemodal";
|
||||
// src/Components/Trip/index.ts
|
||||
// CHANGE: removed TripDeleteModal export (Issue 1).
|
||||
export { TripFormModal } from "./Tripformmodal";
|
||||
@@ -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}>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
export { UserDetailModal } from "./UserDetailModal";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
export { RoleDetailModal } from "./RoleDetailModal";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -79,4 +76,4 @@ export function useAuth() {
|
||||
}, [router]);
|
||||
|
||||
return { login, loading, error, clearError };
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
})
|
||||
|
||||
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 };
|
||||
}
|
||||
204
src/lib/api.ts
204
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 =
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
try {
|
||||
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(
|
||||
"يجب إدخال بريد إلكتروني صحيح أو رقم هاتف سعودي صحيح أو اسم مستخدم لا يقل عن حرفين.",
|
||||
);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
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 endpoint = `${API_BASE_URL}${normalizePath(path)}`;
|
||||
const rawRole = (user as unknown as { role?: RawRole }).role;
|
||||
const roleName = typeof rawRole === "string"
|
||||
? rawRole
|
||||
: typeof rawRole === "object" && rawRole !== null
|
||||
? rawRole.name
|
||||
: undefined;
|
||||
|
||||
let attempt = 0;
|
||||
const maxAttempts = 3;
|
||||
|
||||
while (attempt < maxAttempts) {
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
...init,
|
||||
headers,
|
||||
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) {
|
||||
attempt += 1;
|
||||
if (attempt >= maxAttempts) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 300));
|
||||
}
|
||||
// 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("غير مصرح لك بالوصول إلى هذه اللوحة.");
|
||||
}
|
||||
|
||||
throw new Error("Request failed after retries.");
|
||||
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 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";
|
||||
const AUTH_USER_KEY = "auth_user";
|
||||
|
||||
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",
|
||||
|
||||
@@ -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>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
@@ -35,8 +72,13 @@ 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,
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user