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:
241
Components/User/UserDetailModal.tsx
Normal file
241
Components/User/UserDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Spinner } from "../UI";
|
import { Spinner } from "../UI";
|
||||||
import type { User } from "../../types/user";
|
import type { User } from "../../types/user";
|
||||||
|
|
||||||
// ── role ────────────────────────────────────────────────────────────────
|
// ── role badge ───────────────────────────────────────────────────────────────
|
||||||
function RoleBadge({ name }: { name?: string }) {
|
function RoleBadge({ name }: { name?: string }) {
|
||||||
const isAdmin = name === "مدير النظام";
|
const isAdmin = name === "مدير النظام";
|
||||||
return (
|
return (
|
||||||
@@ -13,7 +13,7 @@ function RoleBadge({ name }: { name?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── status ───────────────────────────────────────────────────────────────
|
// ── status badge ─────────────────────────────────────────────────────────────
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
function StatusBadge({ active }: { active: boolean }) {
|
||||||
return (
|
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" }}>
|
<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 }) {
|
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
|
<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 = {
|
const cardStyle: React.CSSProperties = {
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
||||||
@@ -45,7 +45,7 @@ const thStyle: React.CSSProperties = {
|
|||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Props ──────────────────────────────────────────────────────────────
|
// ── Props ────────────────────────────────────────────────────────────────────
|
||||||
interface UserTableProps {
|
interface UserTableProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -54,16 +54,17 @@ interface UserTableProps {
|
|||||||
pages: number;
|
pages: number;
|
||||||
onEdit: (user: User) => void;
|
onEdit: (user: User) => void;
|
||||||
onDelete: (user: User) => void;
|
onDelete: (user: User) => void;
|
||||||
|
onView: (user: User) => void;
|
||||||
onAddFirst: () => void;
|
onAddFirst: () => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── main table ────────────────────────────────────────────────────────────
|
// ── main table ───────────────────────────────────────────────────────────────
|
||||||
export function UserTable({ users, loading, search, page, pages, onEdit, onDelete, onAddFirst, onPageChange }: UserTableProps) {
|
export function UserTable({ users, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: UserTableProps) {
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle}>
|
<div style={cardStyle}>
|
||||||
{/* column headers */}
|
{/* column headers */}
|
||||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 80px", ...thStyle }}>
|
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
|
||||||
<span>الاسم</span>
|
<span>الاسم</span>
|
||||||
<span>اسم المستخدم</span>
|
<span>اسم المستخدم</span>
|
||||||
<span>الفرع</span>
|
<span>الفرع</span>
|
||||||
@@ -80,7 +81,7 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
|
|||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||||
</div>
|
</div>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
// empty state
|
/* empty state */
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
|
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
|
||||||
@@ -93,11 +94,11 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// data rows
|
/* data rows */
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
{users.map((u, i) => (
|
{users.map((u, i) => (
|
||||||
<li key={u.id} style={{
|
<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",
|
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
|
||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
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)" }}>
|
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||||
</span>
|
</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)}>
|
<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">
|
<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="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" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
|
{/* delete */}
|
||||||
<IconBtn title={`حذف ${u.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(u)}>
|
<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">
|
<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" />
|
<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" />
|
||||||
|
|||||||
35
app/api/auth/set-cookie/route.ts
Normal file
35
app/api/auth/set-cookie/route.ts
Normal 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;
|
||||||
|
}
|
||||||
17
app/api/clear-cookie/route.ts
Normal file
17
app/api/clear-cookie/route.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,65 +1,116 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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();
|
const headers = new Headers();
|
||||||
|
|
||||||
|
// Forward all safe request headers.
|
||||||
request.headers.forEach((value, key) => {
|
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(key, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
headers.set("x-forwarded-host", request.headers.get("host") || "localhost");
|
// Inject the Authorization header using the server-side cookie value.
|
||||||
headers.delete("origin");
|
// The client never had access to this token value.
|
||||||
|
if (token) {
|
||||||
return headers;
|
headers.set("Authorization", `Bearer ${decodeURIComponent(token)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function proxy(request: NextRequest, params: { path: string[] }) {
|
if (!headers.has("Content-Type")) {
|
||||||
const path = params.path?.join("/") ?? "";
|
headers.set("Content-Type", "application/json");
|
||||||
const targetUrl = `${BACKEND_BASE_URL}/${path}${request.nextUrl.search}`;
|
}
|
||||||
|
|
||||||
const isBodyless = request.method === "GET" || request.method === "HEAD";
|
const isBodyless = request.method === "GET" || request.method === "HEAD";
|
||||||
const body = isBodyless ? undefined : await request.text();
|
const body = isBodyless ? undefined : await request.text();
|
||||||
|
|
||||||
const upstream = await fetch(targetUrl, {
|
const upstream = await fetch(targetUrl, {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
headers: buildHeaders(request),
|
headers,
|
||||||
body,
|
body,
|
||||||
cache: "no-store",
|
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")
|
const responseBody = contentType.includes("application/json")
|
||||||
? await upstream.json().catch(() => null)
|
? await upstream.json().catch(() => null)
|
||||||
: await upstream.text();
|
: await upstream.text();
|
||||||
|
|
||||||
return new NextResponse(typeof responseBody === "string" ? responseBody : JSON.stringify(responseBody), {
|
return new NextResponse(
|
||||||
|
typeof responseBody === "string"
|
||||||
|
? responseBody
|
||||||
|
: JSON.stringify(responseBody),
|
||||||
|
{
|
||||||
status: upstream.status,
|
status: upstream.status,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": contentType,
|
"content-type": contentType,
|
||||||
"cache-control": "no-store",
|
"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);
|
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);
|
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);
|
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);
|
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);
|
return proxy(request, await params);
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,8 @@ import { clearAuth, getStoredUser } from "../../../lib/auth";
|
|||||||
export function Topbar() {
|
export function Topbar() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
clearAuth();
|
await clearAuth();
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
429
app/users/[userId]/page.tsx
Normal file
429
app/users/[userId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { Alert } from "../../Components/UI";
|
import { Alert } from "../../Components/UI";
|
||||||
import { UserTable } from "../../Components/User/UserTable";
|
import { UserTable } from "../../Components/User/UserTable";
|
||||||
import { UserFormModal } from "../../Components/User/UserFormModal";
|
import { UserFormModal } from "../../Components/User/UserFormModal";
|
||||||
|
import { UserDetailModal } from "../../Components/User/UserDetailModal";
|
||||||
import { DeleteConfirmModal } from "../../Components/User/DeleteConfirmModal";
|
import { DeleteConfirmModal } from "../../Components/User/DeleteConfirmModal";
|
||||||
import { Toast } from "../../Components/User/Toast";
|
import { Toast } from "../../Components/User/Toast";
|
||||||
import { useUsers } from "../../hooks/useUser";
|
import { useUsers } from "../../hooks/useUser";
|
||||||
@@ -14,6 +15,8 @@ export default function UsersPage() {
|
|||||||
// false = closed | null = create mode | User = edit mode
|
// false = closed | null = create mode | User = edit mode
|
||||||
const [formTarget, setFormTarget] = useState<User | null | false>(false);
|
const [formTarget, setFormTarget] = useState<User | null | false>(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
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
|
// Local submitting flag shown in DeleteConfirmModal spinner
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
@@ -35,24 +38,14 @@ export default function UsersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Delete handler ──────────────────────────────────────────────────────────
|
// ── 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 () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!deleteTarget || deleting) return;
|
if (!deleteTarget || deleting) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
|
console.log(setDeleting(true))
|
||||||
const ok = await deleteUser(deleteTarget.id);
|
const ok = await deleteUser(deleteTarget.id);
|
||||||
console.log("Delete result:", ok);
|
console.log(ok)
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
if (ok) {
|
if (ok) setDeleteTarget(null);
|
||||||
// Close modal only on success; on failure the user sees the error toast
|
|
||||||
// and can retry or cancel manually.
|
|
||||||
setDeleteTarget(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────────────
|
// ── Render ──────────────────────────────────────────────────────────────────
|
||||||
@@ -61,6 +54,14 @@ export default function UsersPage() {
|
|||||||
{/* Global success / error toast — fires on create, update, AND delete */}
|
{/* Global success / error toast — fires on create, update, AND delete */}
|
||||||
<Toast notification={notification} />
|
<Toast notification={notification} />
|
||||||
|
|
||||||
|
{/* User detail modal */}
|
||||||
|
{viewUserId && (
|
||||||
|
<UserDetailModal
|
||||||
|
userId={viewUserId}
|
||||||
|
onClose={() => setViewUserId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create / Edit modal */}
|
{/* Create / Edit modal */}
|
||||||
{formTarget !== false && (
|
{formTarget !== false && (
|
||||||
<UserFormModal
|
<UserFormModal
|
||||||
@@ -78,7 +79,6 @@ export default function UsersPage() {
|
|||||||
user={deleteTarget}
|
user={deleteTarget}
|
||||||
deleting={deleting}
|
deleting={deleting}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
// Only allow cancel when not mid-flight
|
|
||||||
if (!deleting) setDeleteTarget(null);
|
if (!deleting) setDeleteTarget(null);
|
||||||
}}
|
}}
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
@@ -172,6 +172,7 @@ export default function UsersPage() {
|
|||||||
search={search}
|
search={search}
|
||||||
page={page}
|
page={page}
|
||||||
pages={pages}
|
pages={pages}
|
||||||
|
onView={user => setViewUserId(user.id)}
|
||||||
onEdit={user => setFormTarget(user)}
|
onEdit={user => setFormTarget(user)}
|
||||||
onDelete={user => setDeleteTarget(user)}
|
onDelete={user => setDeleteTarget(user)}
|
||||||
onAddFirst={() => setFormTarget(null)}
|
onAddFirst={() => setFormTarget(null)}
|
||||||
|
|||||||
@@ -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
|
```ts
|
||||||
export async function requestJson<T>(
|
export async function requestJson<T>(
|
||||||
|
|||||||
55
lib/api.ts
55
lib/api.ts
@@ -3,17 +3,15 @@ const DEFAULT_BASE_URL = "/api/proxy";
|
|||||||
export const API_BASE_URL =
|
export const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL || DEFAULT_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}`;
|
return path.startsWith("/") ? path : `/${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── API REQUESTS ─────────────────────────────
|
|
||||||
export async function requestJson<T>(
|
export async function requestJson<T>(
|
||||||
path: string, init: RequestInit = {}, token: string | null, p0: number,
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
token: string | null = null,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const token =
|
|
||||||
typeof window !== "undefined" ? localStorage.getItem("auth_token") : null;
|
|
||||||
|
|
||||||
const headers = new Headers(init.headers || {});
|
const headers = new Headers(init.headers || {});
|
||||||
|
|
||||||
if (!headers.has("Content-Type") && !(init.body instanceof FormData)) {
|
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) {
|
if (!response.ok) {
|
||||||
// try to parse error message from response, fallback to status text
|
|
||||||
const json = await response.json().catch(() => null);
|
const json = await response.json().catch(() => null);
|
||||||
const message =
|
const message =
|
||||||
json?.message ||
|
json?.message ||
|
||||||
@@ -52,51 +49,9 @@ path: string, init: RequestInit = {}, token: string | null, p0: number,
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
if (attempt >= maxAttempts) throw error;
|
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.");
|
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
118
middleware/middleware.ts
Normal 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*",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
|
// next.config.ts
|
||||||
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
async rewrites() {
|
// No rewrites needed. All proxying is handled by
|
||||||
return [
|
// app/api/proxy/[...path]/route.ts, which reads the HttpOnly auth cookie
|
||||||
{
|
// server-side and forwards it as a Bearer token to the backend.
|
||||||
// كل طلب لـ /api/proxy/... يتحول لـ logiapi.slash.sa/api/v1/...
|
|
||||||
source: "/api/proxy/:path*",
|
|
||||||
destination: "https://logiapi.slash.sa/api/v1/:path*",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
@@ -51,7 +51,7 @@ export async function request<T>(
|
|||||||
|
|
||||||
//return res.json() as Promise<T>;
|
//return res.json() as Promise<T>;
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
return (text ? JSON.parse(text) : null) as Promise<T>;
|
return (text && text.trim().length > 0 ? JSON.parse(text) : null) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Convenience shorthands ────────────────────────────
|
// ── 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) =>
|
export const del = <T>(path: string, token?: string | null) =>
|
||||||
request<T>(path, { method: "DELETE" }, token);
|
request<T>(path, { method: "DELETE" }, token);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,28 +20,44 @@ export const userService = {
|
|||||||
get<ApiListResponse<User>>(`users${buildUsersQuery(page, search)}`, token),
|
get<ApiListResponse<User>>(`users${buildUsersQuery(page, search)}`, token),
|
||||||
|
|
||||||
/** إنشاء مستخدم جديد */
|
/** إنشاء مستخدم جديد */
|
||||||
create: (data: Omit<UserFormData, "password"> & { password: string }, token: string | null) =>
|
create: (
|
||||||
post<UserResponse>("users", buildPayload(data, true), token),
|
data: Omit<UserFormData, "password"> & { password: string },
|
||||||
|
token: string | null,
|
||||||
|
) => post<UserResponse>("users", buildPayload(data, true), token),
|
||||||
|
|
||||||
/** تعديل بيانات مستخدم موجود */
|
/** تعديل بيانات مستخدم موجود */
|
||||||
update: (id: string, data: UserFormData, token: string | null) =>
|
update: (id: string, data: UserFormData, token: string | null) =>
|
||||||
put<UserResponse>(`users/${id}`, buildPayload(data, false), token),
|
put<UserResponse>(`users/${id}`, buildPayload(data, false), token),
|
||||||
|
|
||||||
/** حذف مستخدم */
|
/** حذف مستخدم */
|
||||||
delete: (id: string, token: string | null) =>
|
delete: (id: string, token: string | null) => del<void>(`users/${id}`, token),
|
||||||
del<void>(`users/${id}`, token),
|
//get role
|
||||||
|
|
||||||
/** جلب قائمة الأدوار لاستخدامها في نموذج المستخدم */
|
|
||||||
getRoles: (token: string | null) =>
|
getRoles: (token: string | null) =>
|
||||||
get<{ data: { data: Role[] } }>("role?limit=100", token),
|
get<{ data: { data: Role[] } }>("role?limit=100", token),
|
||||||
|
//get Branches for user form
|
||||||
/** جلب قائمة الفروع لاستخدامها في نموذج المستخدم */
|
|
||||||
getBranches: (token: string | null) =>
|
getBranches: (token: string | null) =>
|
||||||
get<{ data: { data: Branch[] } }>("branches?limit=100", token),
|
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 */
|
/** تحويل بيانات النموذج إلى 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> = {
|
const payload: Record<string, string> = {
|
||||||
name: data.name.trim(),
|
name: data.name.trim(),
|
||||||
phone: data.phone.trim(),
|
phone: data.phone.trim(),
|
||||||
|
|||||||
@@ -52,3 +52,15 @@ export type TableAction =
|
|||||||
| { type: "UPDATE"; user: User }
|
| { type: "UPDATE"; user: User }
|
||||||
| { type: "DELETE"; id: string }
|
| { 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user