diff --git a/Components/User/UserDetailModal.tsx b/Components/User/UserDetailModal.tsx new file mode 100644 index 0000000..b832c1b --- /dev/null +++ b/Components/User/UserDetailModal.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Spinner } from "../UI"; +import { getStoredToken } from "../../lib/auth"; +import { userService } from "../../services/user.service"; +import type { UserDetail } from "../../types/user"; + +interface UserDetailModalProps { + userId: string; + onClose: () => void; +} + +// ── small helper components ─────────────────────────────────────────────────── +function DetailRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+ + {label} + + + {value || "—"} + +
+ ); +} + +function StatusBadge({ active }: { active: boolean }) { + return ( + + + {active ? "نشط" : "معطل"} + + ); +} + +function Avatar({ name }: { name: string }) { + const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase(); + return ( +
+ {initials} +
+ ); +} + +// ── main component ──────────────────────────────────────────────────────────── +export function UserDetailModal({ userId, onClose }: UserDetailModalProps) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + // fetch user details on mount + useEffect(() => { + let cancelled = false; + (async () => { + try { + const token = getStoredToken(); + const res = await userService.getById(userId, token); + if (!cancelled) setUser(res.data); + } catch { + if (!cancelled) setError("تعذّر تحميل بيانات المستخدم. يرجى المحاولة لاحقاً."); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [userId]); + + // ── helpers ─────────────────────────────────────────────────────────────── + const fmt = (iso?: string | null) => + iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null; + + // ── render ──────────────────────────────────────────────────────────────── + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 55, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 480, + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", + display: "flex", flexDirection: "column", + }} + > + {/* ── header ── */} +
+
+

+ بيانات المستخدم +

+

+ {user?.name ?? "عرض المستخدم"} +

+
+ +
+ + {/* ── body ── */} +
+ + {/* loading */} + {loading && ( +
+ + جارٍ التحميل… +
+ )} + + {/* error */} + {!loading && error && ( +
+ {error} +
+ )} + + {/* content */} + {!loading && user && ( +
+ + {/* avatar + name + status row */} +
+ +
+

{user.name}

+ {user.userName && ( +

+ @{user.userName} +

+ )} +
+ +
+
+
+ + {/* detail rows */} + + + + + + + + {user.passwordChangedAt && ( + + )} +
+ )} +
+ + {/* ── footer ── */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/Components/User/UserTable.tsx b/Components/User/UserTable.tsx index e0dc7f2..c24e425 100644 --- a/Components/User/UserTable.tsx +++ b/Components/User/UserTable.tsx @@ -3,7 +3,7 @@ import { Spinner } from "../UI"; import type { User } from "../../types/user"; -// ── role ──────────────────────────────────────────────────────────────── +// ── role badge ─────────────────────────────────────────────────────────────── function RoleBadge({ name }: { name?: string }) { const isAdmin = name === "مدير النظام"; return ( @@ -13,7 +13,7 @@ function RoleBadge({ name }: { name?: string }) { ); } -// ── status ─────────────────────────────────────────────────────────────── +// ── status badge ───────────────────────────────────────────────────────────── function StatusBadge({ active }: { active: boolean }) { return ( @@ -23,7 +23,7 @@ function StatusBadge({ active }: { active: boolean }) { ); } -// icon buton ─────────────────────────────────────────────────────────────── +// ── icon button ────────────────────────────────────────────────────────────── function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { return ( + +

+ هذا الرمز حساس — لا تشاركه مع أي أحد. +

+ + ) : ( +

+ غير متاح +

+ )} + + + + + ); +} \ No newline at end of file diff --git a/app/users/page.tsx b/app/users/page.tsx index 53290d0..e3fa22d 100644 --- a/app/users/page.tsx +++ b/app/users/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { Alert } from "../../Components/UI"; import { UserTable } from "../../Components/User/UserTable"; import { UserFormModal } from "../../Components/User/UserFormModal"; +import { UserDetailModal } from "../../Components/User/UserDetailModal"; import { DeleteConfirmModal } from "../../Components/User/DeleteConfirmModal"; import { Toast } from "../../Components/User/Toast"; import { useUsers } from "../../hooks/useUser"; @@ -14,6 +15,8 @@ export default function UsersPage() { // false = closed | null = create mode | User = edit mode const [formTarget, setFormTarget] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); + // ID of user whose detail modal is open; null = closed + const [viewUserId, setViewUserId] = useState(null); // Local submitting flag shown in DeleteConfirmModal spinner const [deleting, setDeleting] = useState(false); @@ -35,24 +38,14 @@ export default function UsersPage() { }; // ── Delete handler ────────────────────────────────────────────────────────── - // FIX: We guard against double-click with local `deleting` flag, - // then call deleteUser which: - // 1. Calls the API - // 2. Dispatches { type: "DELETE", id } → reducer removes user from array - // 3. Calls notify() → Toast appears immediately - // Result: the row disappears from the table and a success toast shows — all - // without a page refresh. const handleDeleteConfirm = async () => { if (!deleteTarget || deleting) return; setDeleting(true); + console.log(setDeleting(true)) const ok = await deleteUser(deleteTarget.id); - console.log("Delete result:", ok); + console.log(ok) setDeleting(false); - if (ok) { - // Close modal only on success; on failure the user sees the error toast - // and can retry or cancel manually. - setDeleteTarget(null); - } + if (ok) setDeleteTarget(null); }; // ── Render ────────────────────────────────────────────────────────────────── @@ -61,6 +54,14 @@ export default function UsersPage() { {/* Global success / error toast — fires on create, update, AND delete */} + {/* User detail modal */} + {viewUserId && ( + setViewUserId(null)} + /> + )} + {/* Create / Edit modal */} {formTarget !== false && ( { - // Only allow cancel when not mid-flight if (!deleting) setDeleteTarget(null); }} onConfirm={handleDeleteConfirm} @@ -172,6 +172,7 @@ export default function UsersPage() { search={search} page={page} pages={pages} + onView={user => setViewUserId(user.id)} onEdit={user => setFormTarget(user)} onDelete={user => setDeleteTarget(user)} onAddFirst={() => setFormTarget(null)} diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index 830ce71..1436d29 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -66,7 +66,7 @@ Both target `/api/proxy/:path*`. One is dead code, and which one Next.js resolve --- -### 3. `lib/api.ts` `requestJson` does not compile [Confirmed] +### 3. ```ts export async function requestJson( diff --git a/lib/api.ts b/lib/api.ts index e86563a..ea16f12 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -3,17 +3,15 @@ const DEFAULT_BASE_URL = "/api/proxy"; export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || DEFAULT_BASE_URL; -function normalizePath(path: string) { +function normalizePath(path: string): string { return path.startsWith("/") ? path : `/${path}`; } -// ─── API REQUESTS ───────────────────────────── export async function requestJson( -path: string, init: RequestInit = {}, token: string | null, p0: number, + path: string, + init: RequestInit = {}, + token: string | null = null, ): Promise { - const token = - typeof window !== "undefined" ? localStorage.getItem("auth_token") : null; - const headers = new Headers(init.headers || {}); if (!headers.has("Content-Type") && !(init.body instanceof FormData)) { @@ -39,7 +37,6 @@ path: string, init: RequestInit = {}, token: string | null, p0: number, }); if (!response.ok) { - // try to parse error message from response, fallback to status text const json = await response.json().catch(() => null); const message = json?.message || @@ -52,51 +49,9 @@ path: string, init: RequestInit = {}, token: string | null, p0: number, } catch (error) { attempt += 1; if (attempt >= maxAttempts) throw error; - // exponential back-off: 600ms, 1200ms - await new Promise((resolve) => - setTimeout(resolve, 2 ** attempt * 300), - ); + await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 300)); } } throw new Error("Request failed after retries."); -} - -// ─── Types ─────────────────────────────────── -export type DashboardSummaryResponse = { - success: boolean; - message: string; - data: { - stats: { - clients: number; - orders: number; - trips: number; - cars: number; - drivers: number; - }; - alerts: { - expiringCars: Array>; - expiringDrivers: Array>; - upcomingMaint: Array>; - }; - activeTrips: Array<{ - id: string; - tripNumber: string; - title: string; - progress: number; - }>; - accountSecurity: { - lastLogin: string | null; - requestMeta: Record | null; - }; - }; -}; - -// ─── API calls ─────────────────────────────── -export async function getDashboardSummary() { - return requestJson("/dashboard/summary"); -} - -export async function getHealth() { - return requestJson<{ status: string; message: string }>("/health"); } \ No newline at end of file diff --git a/middleware/middleware.ts b/middleware/middleware.ts new file mode 100644 index 0000000..161be06 --- /dev/null +++ b/middleware/middleware.ts @@ -0,0 +1,118 @@ +// middleware.ts +// Runs on the Edge runtime before any page component is rendered. +// Handles two guards for all /dashboard/* routes (and their sub-routes): +// 1. Authentication — redirects to /login if no auth cookie is present. +// 2. Role-based access control — blocks the "driver" role from admin routes. + +import { NextRequest, NextResponse } from "next/server"; + +// The cookie name must match what the server-side login action sets. +// Previously lib/auth.ts wrote this as a JS-accessible cookie — after the +// auth.ts refactor (Issue 3) this same name is now an HttpOnly cookie. +const AUTH_COOKIE_NAME = "auth_token"; + +// Routes that require authentication (and block the driver role). +// The matcher below handles the routing; this constant is for documentation. +const PROTECTED_PREFIX = "/dashboard"; + +/** + * Lightweight JWT payload decoder. + * We only need the `role` claim — we do NOT verify the signature here + * because the backend already validates the token on every API request. + * Signature verification in middleware would require the secret to be + * bundled into the Edge runtime, which is its own security concern. + * The authoritative check is always the backend; middleware is a UX guard. + */ +function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + + // Base64url → Base64 → JSON + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + // atob is available in the Edge runtime + const json = atob(base64); + return JSON.parse(json) as Record; + } catch { + return null; + } +} + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // ── Guard: only apply to protected routes ───────────────────────────────── + // (The `matcher` config below already limits execution, but this is an + // explicit check for clarity and defensive depth.) + if (!pathname.startsWith(PROTECTED_PREFIX)) { + return NextResponse.next(); + } + + // ── Check 1: Authentication ──────────────────────────────────────────────── + // Read the HttpOnly auth cookie set by the server after login. + const authCookie = request.cookies.get(AUTH_COOKIE_NAME); + + if (!authCookie?.value) { + // No token → send to login, preserving the original URL so we can + // redirect back after successful login if needed. + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("next", pathname); + return NextResponse.redirect(loginUrl); + } + + // ── Check 2: Role-based access control ──────────────────────────────────── + // Decode the JWT payload (not verified — see note on decodeJwtPayload above). + const payload = decodeJwtPayload(authCookie.value); + + if (!payload) { + // Malformed token — treat as unauthenticated. + const loginUrl = new URL("/login", request.url); + return NextResponse.redirect(loginUrl); + } + + // The role field matches what lib/auth.ts stores: a plain string like + // "driver", "admin", "user" (extracted from the backend JWT during login). + const role = + typeof payload.role === "string" + ? payload.role.toLowerCase() + : null; + + const BLOCKED_ROLES = ["driver", "سائق"]; + + if (role && BLOCKED_ROLES.includes(role)) { + // Driver accounts must never access the admin dashboard. + return NextResponse.redirect(new URL("/forbidden", request.url)); + } + + // ── All checks passed — allow the request through ───────────────────────── + return NextResponse.next(); +} + +export const config = { + // Apply this middleware to the dashboard root and all sub-routes. + // Explicitly list the known sub-routes so the matcher is predictable; + // any new top-level pages added under /dashboard are automatically covered + // by the "/dashboard/:path*" pattern. + matcher: [ + "/dashboard", + "/dashboard/:path*", + "/users", + "/users/:path*", + "/cars", + "/cars/:path*", + "/orders", + "/orders/:path*", + "/clients", + "/clients/:path*", + "/drivers", + "/drivers/:path*", + "/roles", + "/roles/:path*", + "/audit", + "/audit/:path*", + "/trips", + "/trips/:path*", + "/branches", + "/branches/:path*", + ], +}; \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 2bf7e48..0f637d8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,17 +1,10 @@ - - +// next.config.ts import type { NextConfig } from "next"; const nextConfig: NextConfig = { - async rewrites() { - return [ - { - // كل طلب لـ /api/proxy/... يتحول لـ logiapi.slash.sa/api/v1/... - source: "/api/proxy/:path*", - destination: "https://logiapi.slash.sa/api/v1/:path*", - }, - ]; - }, + // No rewrites needed. All proxying is handled by + // app/api/proxy/[...path]/route.ts, which reads the HttpOnly auth cookie + // server-side and forwards it as a Bearer token to the backend. }; export default nextConfig; \ No newline at end of file diff --git a/services/api.ts b/services/api.ts index a87a55e..683df8c 100644 --- a/services/api.ts +++ b/services/api.ts @@ -50,8 +50,8 @@ export async function request( } //return res.json() as Promise; - const text = await res.text(); - return (text ? JSON.parse(text) : null) as Promise; +const text = await res.text(); +return (text && text.trim().length > 0 ? JSON.parse(text) : null) as T; } // ── Convenience shorthands ──────────────────────────── @@ -68,3 +68,5 @@ export const put = (path: string, body: unknown, token?: string | null) => export const del = (path: string, token?: string | null) => request(path, { method: "DELETE" }, token); + + diff --git a/services/user.service.ts b/services/user.service.ts index b6804be..4a9724f 100644 --- a/services/user.service.ts +++ b/services/user.service.ts @@ -20,36 +20,52 @@ export const userService = { get>(`users${buildUsersQuery(page, search)}`, token), /** إنشاء مستخدم جديد */ - create: (data: Omit & { password: string }, token: string | null) => - post("users", buildPayload(data, true), token), + create: ( + data: Omit & { password: string }, + token: string | null, + ) => post("users", buildPayload(data, true), token), /** تعديل بيانات مستخدم موجود */ update: (id: string, data: UserFormData, token: string | null) => put(`users/${id}`, buildPayload(data, false), token), /** حذف مستخدم */ - delete: (id: string, token: string | null) => - del(`users/${id}`, token), - - /** جلب قائمة الأدوار لاستخدامها في نموذج المستخدم */ + delete: (id: string, token: string | null) => del(`users/${id}`, token), + //get role getRoles: (token: string | null) => get<{ data: { data: Role[] } }>("role?limit=100", token), - - /** جلب قائمة الفروع لاستخدامها في نموذج المستخدم */ +//get Branches for user form getBranches: (token: string | null) => get<{ data: { data: Branch[] } }>("branches?limit=100", token), + //get user by id + getById: (id: string, token: string | null) => + get<{ + data: User & { + photo: string | null; + refreshToken: string | null; + isDeleted: boolean; + deletedAt: string | null; + updatedAt: string; + passwordChangedAt: string | null; + role: { name: string; description: string }; + branch: { name: string }; + }; + }>(`users/${id}`, token), }; /** تحويل بيانات النموذج إلى payload مناسب للـ API */ -function buildPayload(data: UserFormData, isNew: boolean): Record { +function buildPayload( + data: UserFormData, + isNew: boolean, +): Record { const payload: Record = { - name: data.name.trim(), - phone: data.phone.trim(), - roleId: data.roleId, + name: data.name.trim(), + phone: data.phone.trim(), + roleId: data.roleId, branchId: data.branchId, }; - if (data.email) payload.email = data.email.trim(); - if (isNew && data.password) payload.password = data.password; - else if (!isNew && data.password) payload.password = data.password; + if (data.email) payload.email = data.email.trim(); + if (isNew && data.password) payload.password = data.password; + else if (!isNew && data.password) payload.password = data.password; return payload; -} \ No newline at end of file +} diff --git a/types/user.ts b/types/user.ts index 14ff108..ba13cef 100644 --- a/types/user.ts +++ b/types/user.ts @@ -51,4 +51,16 @@ export type TableAction = | { type: "ADD"; user: User } | { type: "UPDATE"; user: User } | { type: "DELETE"; id: string } - | { type: "CLEAR_ERR" }; \ No newline at end of file + | { type: "CLEAR_ERR" }; + + + export interface UserDetail extends User { + photo: string | null; + refreshToken: string | null; + isDeleted: boolean; + deletedAt: string | null; + updatedAt: string; + passwordChangedAt: string | null; + role: { id?: string; name: string; description?: string }; + branch: { id?: string; name: string }; +} \ No newline at end of file