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

This commit is contained in:
m7amedez5511
2026-06-17 17:01:53 +03:00
parent 6ca2cc08d1
commit a23d21f222
15 changed files with 1017 additions and 137 deletions

View File

@@ -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 (
<div style={{
display: "flex", flexDirection: "column", gap: 4,
padding: "0.75rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 13, fontWeight: 500, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
{value || "—"}
</span>
</div>
);
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function Avatar({ name }: { name: string }) {
const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase();
return (
<div style={{
width: 64, height: 64, borderRadius: "50%",
background: "linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 22, fontWeight: 700, color: "#FFF",
flexShrink: 0,
boxShadow: "0 4px 12px rgba(37,99,235,.3)",
}}>
{initials}
</div>
);
}
// ── main component ────────────────────────────────────────────────────────────
export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
const [user, setUser] = useState<UserDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div
role="dialog" aria-modal="true" aria-labelledby="detail-title"
onClick={e => { 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",
}}
>
<div
onClick={e => 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 ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
بيانات المستخدم
</p>
<h2 id="detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{user?.name ?? "عرض المستخدم"}
</h2>
</div>
<button
type="button" onClick={onClose} aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer", fontSize: 18,
color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── body ── */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
{/* loading */}
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */}
{!loading && error && (
<div style={{
padding: "1rem 1.25rem",
borderRadius: "var(--radius-lg)",
background: "#FEF2F2", border: "1px solid #FECACA",
fontSize: 13, color: "#991B1B", fontWeight: 500,
textAlign: "center",
}}>
{error}
</div>
)}
{/* content */}
{!loading && user && (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* avatar + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<Avatar name={user.name} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{user.name}</p>
{user.userName && (
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
@{user.userName}
</p>
)}
<div style={{ marginTop: 8 }}>
<StatusBadge active={user.isActive} />
</div>
</div>
</div>
{/* detail rows */}
<DetailRow label="رقم الهاتف" value={user.phone} />
<DetailRow label="البريد الإلكتروني" value={user.email} />
<DetailRow label="الدور" value={user.role?.name} />
<DetailRow label="وصف الدور" value={user.role?.description} />
<DetailRow label="الفرع" value={user.branch?.name} />
<DetailRow label="تاريخ الإنشاء" value={fmt(user.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(user.updatedAt)} />
{user.passwordChangedAt && (
<DetailRow label="آخر تغيير لكلمة المرور" value={fmt(user.passwordChangedAt)} />
)}
</div>
)}
</div>
{/* ── footer ── */}
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
@@ -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 (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
@@ -33,7 +33,7 @@ function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick
);
}
// ── title style ───────────────────────────────────────────────────
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
@@ -45,7 +45,7 @@ const thStyle: React.CSSProperties = {
borderBottom: "1px solid var(--color-border)",
};
// ── Props ──────────────────────────────────────────────────────────────
// ── Props ────────────────────────────────────────────────────────────────────
interface UserTableProps {
users: User[];
loading: boolean;
@@ -54,16 +54,17 @@ interface UserTableProps {
pages: number;
onEdit: (user: User) => void;
onDelete: (user: User) => void;
onView: (user: User) => void;
onAddFirst: () => void;
onPageChange: (p: number) => void;
}
// ── main table ────────────────────────────────────────────────────────────
export function UserTable({ users, loading, search, page, pages, onEdit, onDelete, onAddFirst, onPageChange }: UserTableProps) {
// ── main table ───────────────────────────────────────────────────────────────
export function UserTable({ users, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: UserTableProps) {
return (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 80px", ...thStyle }}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
<span>الاسم</span>
<span>اسم المستخدم</span>
<span>الفرع</span>
@@ -73,14 +74,14 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : users.length === 0 ? (
// empty state
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
@@ -93,11 +94,11 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
)}
</div>
) : (
// data rows
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{users.map((u, i) => (
<li key={u.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 80px",
display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
@@ -114,13 +115,22 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 6 }}>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view */}
<IconBtn title={`عرض ${u.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(u)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
{/* edit */}
<IconBtn title={`تعديل ${u.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(u)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</IconBtn>
{/* delete */}
<IconBtn title={`حذف ${u.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(u)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
@@ -133,7 +143,7 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
</ul>
)}
{/* pagination */}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>

View File

@@ -0,0 +1,35 @@
// app/api/auth/set-cookie/route.ts
// Receives the JWT from the client immediately after login and stores it
// in an HttpOnly, Secure, SameSite=Strict cookie.
// The client never stores the raw token — it passes through this endpoint once.
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
const token: unknown = body?.token;
if (typeof token !== "string" || !token) {
return NextResponse.json({ error: "Invalid token" }, { status: 400 });
}
const isProduction = process.env.NODE_ENV === "production";
// Build the Set-Cookie header manually so we have full control over flags.
// - HttpOnly: JavaScript cannot read this cookie.
// - Secure: Only sent over HTTPS (omitted in development for localhost).
// - SameSite=Strict: Not sent on cross-site requests, mitigating CSRF.
// - Path=/: Available for all routes (needed for the middleware to read it).
const cookieParts = [
`auth_token=${encodeURIComponent(token)}`,
"HttpOnly",
isProduction ? "Secure" : "", // Omit Secure on localhost (no HTTPS)
"SameSite=Strict",
"Path=/",
"Max-Age=604800", // 7 days in seconds
].filter(Boolean);
const response = NextResponse.json({ ok: true });
response.headers.set("Set-Cookie", cookieParts.join("; "));
return response;
}

View File

@@ -0,0 +1,17 @@
// app/api/auth/clear-cookie/route.ts
// Clears the HttpOnly auth cookie on logout.
// Only the server can delete an HttpOnly cookie.
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({ ok: true });
// Overwrite the cookie with an empty value and a past expiry date.
response.headers.set(
"Set-Cookie",
"auth_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0",
);
return response;
}

View File

@@ -1,65 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const BACKEND_BASE_URL = "https://logiapi.slash.sa/api";
const BACKEND_BASE_URL = "https://logiapi.slash.sa/api/v1";
async function proxy(
request: NextRequest,
params: { path: string[] },
): Promise<NextResponse> {
const path = params.path?.join("/") ?? "";
const targetUrl = `${BACKEND_BASE_URL}/${path}${request.nextUrl.search}`;
// Read the HttpOnly cookie server-side — this is the ONLY place the raw
// token value is accessible after the Issue 3 refactor.
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
function buildHeaders(request: NextRequest) {
const headers = new Headers();
// Forward all safe request headers.
request.headers.forEach((value, key) => {
if (key === "host" || key === "content-length") return;
if (key === "host" || key === "content-length" || key === "cookie") return;
headers.set(key, value);
});
headers.set("x-forwarded-host", request.headers.get("host") || "localhost");
headers.delete("origin");
// Inject the Authorization header using the server-side cookie value.
// The client never had access to this token value.
if (token) {
headers.set("Authorization", `Bearer ${decodeURIComponent(token)}`);
}
return headers;
}
async function proxy(request: NextRequest, params: { path: string[] }) {
const path = params.path?.join("/") ?? "";
const targetUrl = `${BACKEND_BASE_URL}/${path}${request.nextUrl.search}`;
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const isBodyless = request.method === "GET" || request.method === "HEAD";
const body = isBodyless ? undefined : await request.text();
const upstream = await fetch(targetUrl, {
method: request.method,
headers: buildHeaders(request),
headers,
body,
cache: "no-store",
});
const contentType = upstream.headers.get("content-type") || "application/json";
const contentType =
upstream.headers.get("content-type") || "application/json";
// Responses with these statuses MUST NOT carry a body (per the Fetch/HTTP
// spec). Constructing a NextResponse with a body alongside one of these
// statuses throws at runtime, which Next.js then surfaces as a 500 to the
// client — even though the upstream call itself succeeded. This is exactly
// what happens on DELETE endpoints that correctly return 204 No Content.
const isNoBodyStatus = [204, 205, 304].includes(upstream.status);
if (isNoBodyStatus) {
// Drain the upstream body (should be empty anyway) without parsing it.
await upstream.text().catch(() => null);
return new NextResponse(null, {
status: upstream.status,
headers: { "cache-control": "no-store" },
});
}
const responseBody = contentType.includes("application/json")
? await upstream.json().catch(() => null)
: await upstream.text();
return new NextResponse(typeof responseBody === "string" ? responseBody : JSON.stringify(responseBody), {
status: upstream.status,
headers: {
"content-type": contentType,
"cache-control": "no-store",
return new NextResponse(
typeof responseBody === "string"
? responseBody
: JSON.stringify(responseBody),
{
status: upstream.status,
headers: {
"content-type": contentType,
"cache-control": "no-store",
},
},
});
);
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxy(request, await params);
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxy(request, await params);
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxy(request, await params);
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxy(request, await params);
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxy(request, await params);
}
}

View File

@@ -6,8 +6,8 @@ import { clearAuth, getStoredUser } from "../../../lib/auth";
export function Topbar() {
const router = useRouter();
function handleLogout() {
clearAuth();
async function handleLogout() {
await clearAuth();
router.replace("/login");
}

429
app/users/[userId]/page.tsx Normal file
View File

@@ -0,0 +1,429 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { PageLoader } from "../../../Components/UI/Spinner";
import { Alert } from "../../../Components/UI/Alert";
import { fmtDate } from "../../../utils/helperFun";
import { getStoredToken } from "../../../lib/auth";
import { userService } from "../../../services/user.service";
import type { UserDetail } from "../../../types/user";
// ── helpers ───────────────────────────────────────────────────────────────────
function safeFmtDate(iso: string | null | undefined): string {
if (!iso) return "—";
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return fmtDate(iso);
} catch {
return "—";
}
}
// ── sub-components ────────────────────────────────────────────────────────────
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-xl)",
boxShadow: "var(--shadow-card)",
overflow: "hidden",
}}>
<div style={{
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<p style={{
margin: 0,
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.2em",
color: "var(--color-text-muted)",
}}>
{title}
</p>
</div>
<div style={{ padding: "1.25rem 1.5rem" }}>
{children}
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{
fontSize: 11,
fontWeight: 600,
color: "var(--color-text-muted)",
textTransform: "uppercase",
letterSpacing: "0.15em",
}}>
{label}
</span>
<span style={{
fontSize: 13,
color: "var(--color-text-primary)",
fontFamily: "var(--font-sans)",
}}>
{children}
</span>
</div>
);
}
function ActiveBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.2rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function DeletedBadge({ deleted }: { deleted: boolean }) {
if (!deleted) return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)",
border: "1px solid #BBF7D0",
background: "#DCFCE7",
padding: "0.2rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: "#166534",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#16A34A" }} />
موجود
</span>
);
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)",
border: "1px solid #FECACA",
background: "#FEF2F2",
padding: "0.2rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#DC2626" }} />
محذوف
</span>
);
}
// eye icon
function EyeIcon({ open }: { open: boolean }) {
if (open) return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
);
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
// silhouette fallback avatar
function AvatarFallback() {
return (
<div style={{
width: 80, height: 80, borderRadius: "50%",
background: "var(--color-brand-100)",
border: "2px solid var(--color-brand-200)",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0,
}}>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#2563EB" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
);
}
// ── main page ─────────────────────────────────────────────────────────────────
export default function UserDetailPage() {
const params = useParams();
const router = useRouter();
const userId = typeof params?.userId === "string" ? params.userId : null;
const [user, setUser] = useState<UserDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showToken, setShowToken] = useState(false);
useEffect(() => {
if (!userId) {
router.replace("/users");
return;
}
let cancelled = false;
async function fetchUser() {
try {
const token = getStoredToken();
const res = await userService.getById(userId as string, token);
if (!cancelled) {
setUser((res as { data: UserDetail }).data ?? null);
}
} catch {
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم. يرجى المحاولة لاحقاً.");
} finally {
if (!cancelled) setLoading(false);
}
}
fetchUser();
return () => { cancelled = true; };
}, [userId, router]);
// reset token visibility on unmount
useEffect(() => () => { setShowToken(false); }, []);
if (loading) return <PageLoader message="جارٍ تحميل بيانات المستخدم…" />;
if (error || !user) {
return (
<div dir="rtl" style={{ padding: "2rem", maxWidth: 560 }}>
<Alert
type="error"
title="خطأ في التحميل"
message={error ?? "المستخدم غير موجود."}
/>
<a
href="/users"
style={{
display: "inline-block", marginTop: "1rem",
fontSize: 13, fontWeight: 600,
color: "var(--color-brand-600)",
textDecoration: "underline",
}}
>
العودة إلى قائمة المستخدمين
</a>
</div>
);
}
return (
<div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* soft-deleted warning banner */}
{user.isDeleted && (
<div style={{
display: "flex", alignItems: "center", gap: 10,
background: "#FEF2F2", border: "1px solid #FECACA",
borderRadius: "var(--radius-lg)", padding: "0.875rem 1.25rem",
fontSize: 13, fontWeight: 600, color: "#991B1B",
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
هذا المستخدم محذوف تم حذفه بتاريخ {safeFmtDate(user.deletedAt)}.
</div>
)}
{/* page header */}
<header style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-xl)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem 2rem",
}}>
<a
href="/users"
style={{
display: "inline-flex", alignItems: "center", gap: 6,
fontSize: 12, fontWeight: 600,
color: "var(--color-brand-600)",
textDecoration: "none", marginBottom: "1rem",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
العودة إلى المستخدمين
</a>
<div style={{ display: "flex", alignItems: "center", gap: "1.25rem", flexWrap: "wrap" }}>
{/* avatar */}
{user.photo
? <img src={user.photo} alt={user.name} style={{ width: 80, height: 80, borderRadius: "50%", objectFit: "cover", border: "2px solid var(--color-brand-200)", flexShrink: 0 }} />
: <AvatarFallback />
}
<div>
<p style={{ margin: 0, fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
ملف المستخدم
</p>
<h1 style={{ margin: "4px 0 6px", fontSize: "1.4rem", fontWeight: 700, color: "var(--color-text-primary)" }}>
{user.name}
</h1>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
{user.userName && (
<span style={{
fontFamily: "var(--font-mono)", fontSize: 12,
color: "#2563EB", fontWeight: 600,
background: "#EFF6FF", border: "1px solid #BFDBFE",
borderRadius: "var(--radius-sm)", padding: "2px 8px",
}}>
@{user.userName}
</span>
)}
<ActiveBadge active={user.isActive} />
<DeletedBadge deleted={user.isDeleted} />
</div>
</div>
</div>
</header>
{/* grid layout */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.25rem",
}}>
{/* Basic Info */}
<SectionCard title="المعلومات الأساسية">
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<Field label="الاسم الكامل">{user.name}</Field>
<Field label="البريد الإلكتروني">
{user.email
? <span style={{ direction: "ltr", display: "inline-block" }}>{user.email}</span>
: "—"
}
</Field>
<Field label="رقم الهاتف">
<span style={{ direction: "ltr", display: "inline-block", fontFamily: "var(--font-mono)" }}>
{user.phone || "—"}
</span>
</Field>
<Field label="المعرف">
<span style={{
fontFamily: "var(--font-mono)", fontSize: 11,
color: "var(--color-text-muted)", wordBreak: "break-all",
}}>
{user.id}
</span>
</Field>
</div>
</SectionCard>
{/* Role & Branch */}
<SectionCard title="الدور والفرع">
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<Field label="الدور">
<span style={{
display: "inline-flex", alignItems: "center",
borderRadius: "var(--radius-full)",
border: "1px solid #BFDBFE",
background: "#EFF6FF",
padding: "0.2rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: "#1D4ED8",
}}>
{user.role?.name ?? "—"}
</span>
</Field>
{user.role?.description && (
<Field label="وصف الدور">
<span style={{ color: "var(--color-text-secondary)" }}>
{user.role.description}
</span>
</Field>
)}
<Field label="الفرع">{user.branch?.name ?? "—"}</Field>
</div>
</SectionCard>
{/* Account Status */}
<SectionCard title="حالة الحساب">
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<Field label="الحالة"><ActiveBadge active={user.isActive} /></Field>
<Field label="حالة الحذف"><DeletedBadge deleted={user.isDeleted} /></Field>
<Field label="تاريخ الإنشاء">{safeFmtDate(user.createdAt)}</Field>
<Field label="آخر تحديث">{safeFmtDate(user.updatedAt)}</Field>
<Field label="تاريخ الحذف">{safeFmtDate(user.deletedAt)}</Field>
<Field label="آخر تغيير لكلمة المرور">{safeFmtDate(user.passwordChangedAt)}</Field>
</div>
</SectionCard>
{/* Refresh Token */}
<SectionCard title="رمز التحديث">
{user.refreshToken ? (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
background: "var(--color-surface-muted)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-md)",
padding: "0.625rem 0.875rem",
gap: "0.75rem",
}}>
<span style={{
fontFamily: "var(--font-mono)", fontSize: 12,
color: "var(--color-text-secondary)",
wordBreak: "break-all", flex: 1,
letterSpacing: showToken ? "normal" : "0.15em",
}}>
{showToken ? user.refreshToken : "••••••••••••••••••••••••"}
</span>
<button
type="button"
onClick={() => setShowToken(p => !p)}
title={showToken ? "إخفاء الرمز" : "إظهار الرمز"}
aria-label={showToken ? "إخفاء رمز التحديث" : "إظهار رمز التحديث"}
style={{
flexShrink: 0,
width: 30, height: 30,
borderRadius: "var(--radius-sm)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
color: "var(--color-text-muted)",
cursor: "pointer",
display: "inline-flex", alignItems: "center", justifyContent: "center",
transition: "opacity 150ms",
}}
>
<EyeIcon open={showToken} />
</button>
</div>
<p style={{ margin: 0, fontSize: 11, color: "var(--color-text-hint)" }}>
هذا الرمز حساس لا تشاركه مع أي أحد.
</p>
</div>
) : (
<p style={{
margin: 0, fontSize: 13,
color: "var(--color-text-muted)",
fontStyle: "italic",
}}>
غير متاح
</p>
)}
</SectionCard>
</div>
</div>
);
}

View File

@@ -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<User | null | false>(false);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
// ID of user whose detail modal is open; null = closed
const [viewUserId, setViewUserId] = useState<string | null>(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 */}
<Toast notification={notification} />
{/* User detail modal */}
{viewUserId && (
<UserDetailModal
userId={viewUserId}
onClose={() => setViewUserId(null)}
/>
)}
{/* Create / Edit modal */}
{formTarget !== false && (
<UserFormModal
@@ -78,7 +79,6 @@ export default function UsersPage() {
user={deleteTarget}
deleting={deleting}
onCancel={() => {
// 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)}

View File

@@ -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<T>(

View File

@@ -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<T>(
path: string, init: RequestInit = {}, token: string | null, p0: number,
path: string,
init: RequestInit = {},
token: string | null = null,
): Promise<T> {
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<Record<string, unknown>>;
expiringDrivers: Array<Record<string, unknown>>;
upcomingMaint: Array<Record<string, unknown>>;
};
activeTrips: Array<{
id: string;
tripNumber: string;
title: string;
progress: number;
}>;
accountSecurity: {
lastLogin: string | null;
requestMeta: Record<string, unknown> | null;
};
};
};
// ─── API calls ───────────────────────────────
export async function getDashboardSummary() {
return requestJson<DashboardSummaryResponse>("/dashboard/summary");
}
export async function getHealth() {
return requestJson<{ status: string; message: string }>("/health");
}

118
middleware/middleware.ts Normal file
View File

@@ -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<string, unknown> | 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<string, unknown>;
} 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*",
],
};

View File

@@ -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;

View File

@@ -50,8 +50,8 @@ export async function request<T>(
}
//return res.json() as Promise<T>;
const text = await res.text();
return (text ? JSON.parse(text) : null) as Promise<T>;
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 = <T>(path: string, body: unknown, token?: string | null) =>
export const del = <T>(path: string, token?: string | null) =>
request<T>(path, { method: "DELETE" }, token);

View File

@@ -20,36 +20,52 @@ export const userService = {
get<ApiListResponse<User>>(`users${buildUsersQuery(page, search)}`, token),
/** إنشاء مستخدم جديد */
create: (data: Omit<UserFormData, "password"> & { password: string }, token: string | null) =>
post<UserResponse>("users", buildPayload(data, true), token),
create: (
data: Omit<UserFormData, "password"> & { password: string },
token: string | null,
) => post<UserResponse>("users", buildPayload(data, true), token),
/** تعديل بيانات مستخدم موجود */
update: (id: string, data: UserFormData, token: string | null) =>
put<UserResponse>(`users/${id}`, buildPayload(data, false), token),
/** حذف مستخدم */
delete: (id: string, token: string | null) =>
del<void>(`users/${id}`, token),
/** جلب قائمة الأدوار لاستخدامها في نموذج المستخدم */
delete: (id: string, token: string | null) => del<void>(`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<string, string> {
function buildPayload(
data: UserFormData,
isNew: boolean,
): Record<string, string> {
const payload: Record<string, string> = {
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;
}
}

View File

@@ -51,4 +51,16 @@ export type TableAction =
| { type: "ADD"; user: User }
| { type: "UPDATE"; user: User }
| { type: "DELETE"; id: string }
| { type: "CLEAR_ERR" };
| { 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 };
}