From a23d21f2227a0b8fb2afb79205ae89a1c6113b27 Mon Sep 17 00:00:00 2001 From: m7amedez5511 Date: Wed, 17 Jun 2026 17:01:53 +0300 Subject: [PATCH] create middleware block and update proxy file also make token stored in cookes and up date user page to can show user details also fixed delete user error and delete car error and broken corse inginx image block now we can uplode image to server and but it in my app --- Components/User/UserDetailModal.tsx | 241 ++++++++++++++++ Components/User/UserTable.tsx | 40 ++- app/api/auth/set-cookie/route.ts | 35 +++ app/api/clear-cookie/route.ts | 17 ++ app/api/proxy/[...path]/route.ts | 101 +++++-- app/components/layout/Topbar.tsx | 4 +- app/users/[userId]/page.tsx | 429 ++++++++++++++++++++++++++++ app/users/page.tsx | 29 +- docs/FINDINGS.md | 2 +- lib/api.ts | 55 +--- middleware/middleware.ts | 118 ++++++++ next.config.ts | 15 +- services/api.ts | 6 +- services/user.service.ts | 48 ++-- types/user.ts | 14 +- 15 files changed, 1017 insertions(+), 137 deletions(-) create mode 100644 Components/User/UserDetailModal.tsx create mode 100644 app/api/auth/set-cookie/route.ts create mode 100644 app/api/clear-cookie/route.ts create mode 100644 app/users/[userId]/page.tsx create mode 100644 middleware/middleware.ts 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