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:
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 { 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);
|
||||
}
|
||||
}
|
||||
@@ -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
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 { 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)}
|
||||
|
||||
Reference in New Issue
Block a user