- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
436 lines
18 KiB
TypeScript
436 lines
18 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useNavigate } from 'react-router';
|
|
import {
|
|
MagnifyingGlassIcon, PlusIcon,
|
|
EyeIcon, TrashIcon, ArrowPathIcon,
|
|
CheckCircleIcon, XCircleIcon, ShieldCheckIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
|
import Portal from '../components/ui/Portal';
|
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
|
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
import Pagination from '../components/ui/Pagination';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────
|
|
|
|
interface AdminUser {
|
|
uuid: string;
|
|
id: number;
|
|
mobile_number: string;
|
|
name: string | null;
|
|
email: string | null;
|
|
roles: string[];
|
|
is_active: boolean;
|
|
status: number;
|
|
created_at: string;
|
|
}
|
|
|
|
interface UserStats {
|
|
total: number;
|
|
active: number;
|
|
inactive: number;
|
|
admins: number;
|
|
doctors: number;
|
|
patients: number;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
const HUES_LIST = [256, 205, 162, 295, 272];
|
|
|
|
const ROLE_META: Record<string, { label: string; badgeCls: string }> = {
|
|
admin: { label: 'ادمین', badgeCls: 'violet' },
|
|
doctor: { label: 'پزشک', badgeCls: 'blue' },
|
|
secretary: { label: 'منشی', badgeCls: 'amber' },
|
|
clinic: { label: 'کلینیک', badgeCls: 'green' },
|
|
representation: { label: 'نماینده', badgeCls: 'red' },
|
|
patient: { label: 'بیمار', badgeCls: 'gray' },
|
|
};
|
|
|
|
// نقشهایی که از این صفحه قابل تغییرِ مستقیماند (backend `roleMap`). «نماینده» از اینجا ست نمیشود
|
|
// چون ساخت نماینده رکورد Representation هم میخواهد؛ فقط برای نمایش badge/فیلتر تعریف شده است.
|
|
const ASSIGNABLE_ROLES = ['admin', 'doctor', 'secretary', 'clinic', 'patient'];
|
|
|
|
function getPrimaryRole(roles: string[]): string {
|
|
if (roles.includes('ROLE_ADMIN')) return 'admin';
|
|
if (roles.includes('ROLE_DOCTOR')) return 'doctor';
|
|
if (roles.includes('ROLE_SECRETARY')) return 'secretary';
|
|
if (roles.includes('ROLE_CLINIC')) return 'clinic';
|
|
if (roles.includes('ROLE_REPRESENTATION')) return 'representation';
|
|
return 'patient';
|
|
}
|
|
|
|
function UserAvatar({ name, id, size = 'sm' }: { name: string | null; id: number; size?: 'sm' | 'lg' }) {
|
|
const initials = name
|
|
? name.split(' ').filter(Boolean).map((w) => w[0]).join('').toUpperCase().slice(0, 2)
|
|
: '؟';
|
|
const hue = HUES_LIST[id % HUES_LIST.length];
|
|
return (
|
|
<div
|
|
className={`avatar ${size}`}
|
|
style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))` }}
|
|
>
|
|
{initials}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RoleBadge({ roles }: { roles: string[] }) {
|
|
const key = getPrimaryRole(roles);
|
|
const meta = ROLE_META[key];
|
|
return (
|
|
<span className={`badge ${meta.badgeCls}`}>
|
|
<span className="bdot" />
|
|
{meta.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ActiveBadge({ active }: { active: boolean }) {
|
|
return (
|
|
<span className={`badge ${active ? 'green' : 'gray'}`}>
|
|
<span className="bdot" />
|
|
{active ? 'فعال' : 'غیرفعال'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ── Role Change Modal ─────────────────────────────────────────────────────
|
|
|
|
function ChangeRoleModal({ user, onClose, onSave, loading }: {
|
|
user: AdminUser; onClose: () => void;
|
|
onSave: (role: string) => void; loading: boolean;
|
|
}) {
|
|
const [selected, setSelected] = useState(getPrimaryRole(user.roles));
|
|
return (
|
|
<Portal>
|
|
<div className="overlay" onClick={onClose}>
|
|
<div className="modal" style={{ maxWidth: 400 }} onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-head">
|
|
<h2 style={{ fontSize: 16 }}>تغییر نقش</h2>
|
|
<button className="mini-btn" onClick={onClose}>
|
|
<XMarkIcon style={{ width: 18, height: 18 }} />
|
|
</button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<p className="muted" style={{ fontSize: 13, marginBottom: 16 }}>{user.name || user.mobile_number}</p>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{Object.entries(ROLE_META).filter(([key]) => ASSIGNABLE_ROLES.includes(key)).map(([key, meta]) => (
|
|
<label
|
|
key={key}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 12, padding: '11px 14px',
|
|
borderRadius: 'var(--r)', cursor: 'pointer',
|
|
border: `2px solid ${selected === key ? 'var(--primary)' : 'var(--border)'}`,
|
|
background: selected === key ? 'var(--primary-soft)' : 'var(--surface-2)',
|
|
transition: '.15s',
|
|
}}
|
|
>
|
|
<input type="radio" name="role" value={key} checked={selected === key}
|
|
onChange={() => setSelected(key)} style={{ accentColor: 'var(--primary)', width: 16, height: 16 }} />
|
|
<span className={`badge ${meta.badgeCls}`} style={{ pointerEvents: 'none' }}>
|
|
<span className="bdot" />{meta.label}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="modal-foot" style={{ justifyContent: 'flex-end' }}>
|
|
<button className="btn ghost sm" onClick={onClose} disabled={loading}>انصراف</button>
|
|
<button className="btn primary sm" onClick={() => onSave(selected)} disabled={loading}>
|
|
{loading ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
// ── Constants ─────────────────────────────────────────────────────────────
|
|
|
|
const ROLE_TABS = [
|
|
{ key: '', label: 'همه نقشها' },
|
|
{ key: 'admin', label: 'ادمین' },
|
|
{ key: 'doctor', label: 'پزشک' },
|
|
{ key: 'secretary', label: 'منشی' },
|
|
{ key: 'clinic', label: 'کلینیک' },
|
|
{ key: 'representation', label: 'نماینده' },
|
|
{ key: 'patient', label: 'بیمار' },
|
|
];
|
|
|
|
// ── Main Page ─────────────────────────────────────────────────────────────
|
|
|
|
export default function UsersPage() {
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
|
|
// وضعیت لیست در URL میماند تا «بازگشت» از پروفایل کاربر، همین فیلترها و صفحه را برگرداند.
|
|
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', role: '', status: '' });
|
|
const page = pageOf(urlState.page);
|
|
const search = urlState.search;
|
|
const role = urlState.role;
|
|
const status = urlState.status;
|
|
const setPage = (p: number) => setUrlState({ page: String(p) });
|
|
const setRole = (v: string) => setUrlState({ role: v, page: '1' });
|
|
const setStatus = (v: string) => setUrlState({ status: v, page: '1' });
|
|
|
|
const [limit] = useState(25);
|
|
// فیلد جستجو local میماند (تایپ روان)؛ مقدارِ debounceشده به URL میرود.
|
|
const [searchInput, setSearchInput] = useState(urlState.search);
|
|
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
|
|
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setUrlState({ search: searchInput, page: '1' }), 350);
|
|
return () => clearTimeout(t);
|
|
}, [searchInput]);
|
|
|
|
// ── Queries ──
|
|
|
|
const statsQ = useQuery({
|
|
queryKey: ['users-stats'],
|
|
queryFn: () => api.get<ApiResponse<UserStats>>('/api/v1/admin/users/stats'),
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const usersQ = useQuery({
|
|
queryKey: ['users', page, limit, search, role, status],
|
|
queryFn: () => {
|
|
const p = new URLSearchParams({ page: String(page), limit: String(limit) });
|
|
if (search) p.set('search', search);
|
|
if (role) p.set('role', role);
|
|
if (status) p.set('status', status);
|
|
return api.get<PaginatedResponse<AdminUser>>(`/api/v1/admin/users?${p}`);
|
|
},
|
|
});
|
|
|
|
const stats = useMemo<UserStats | undefined>(
|
|
() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]
|
|
);
|
|
const items = usersQ.data?.data ?? [];
|
|
const total = usersQ.data?.meta?.totalRecords ?? 0;
|
|
|
|
// ── Mutations ──
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/admin/users/${uuid}`),
|
|
onSuccess: () => {
|
|
toast.success('کاربر حذف شد');
|
|
setDeleteTarget(null);
|
|
qc.invalidateQueries({ queryKey: ['users'] });
|
|
qc.invalidateQueries({ queryKey: ['users-stats'] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const toggleStatusMut = useMutation({
|
|
mutationFn: (uuid: string) => api.post<ApiResponse<{ is_active: boolean }>>(`/api/v1/admin/users/${uuid}/status`, {}),
|
|
onSuccess: () => {
|
|
toast.success('وضعیت کاربر تغییر کرد');
|
|
qc.invalidateQueries({ queryKey: ['users'] });
|
|
qc.invalidateQueries({ queryKey: ['users-stats'] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const updateRoleMut = useMutation({
|
|
mutationFn: ({ uuid, role: r }: { uuid: string; role: string }) =>
|
|
api.put<ApiResponse<null>>(`/api/v1/admin/users/${uuid}/role`, { role: r }),
|
|
onSuccess: () => {
|
|
toast.success('نقش کاربر بروزرسانی شد');
|
|
setRoleTarget(null);
|
|
qc.invalidateQueries({ queryKey: ['users'] });
|
|
qc.invalidateQueries({ queryKey: ['users-stats'] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
// ── KPI data ──
|
|
|
|
const kpiCards = [
|
|
{ label: 'کل کاربران', value: stats?.total, bg: 'var(--surface-3)', color: 'var(--text-2)' },
|
|
{ label: 'فعال', value: stats?.active, bg: 'var(--success-bg)', color: 'var(--success)' },
|
|
{ label: 'غیرفعال', value: stats?.inactive, bg: 'var(--surface-3)', color: 'var(--text-3)' },
|
|
{ label: 'ادمینها', value: stats?.admins, bg: 'var(--violet-bg)', color: 'var(--violet)' },
|
|
{ label: 'پزشکان', value: stats?.doctors, bg: 'var(--info-bg)', color: 'var(--info)' },
|
|
{ label: 'بیماران', value: stats?.patients, bg: 'var(--warning-bg)', color: 'var(--warning)' },
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
{/* Header */}
|
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
|
<div>
|
|
<h1 className="section-title">کاربران</h1>
|
|
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت کاربران، نقشها و سطح دسترسی</div>
|
|
</div>
|
|
<button className="btn primary sm">
|
|
<PlusIcon style={{ width: 15, height: 15 }} />
|
|
افزودن کاربر
|
|
</button>
|
|
</div>
|
|
|
|
{/* KPI cards */}
|
|
<div className="stat-grid">
|
|
{kpiCards.map((c) => (
|
|
<div key={c.label} className="stat">
|
|
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
|
<svg style={{ width: 20, height: 20 }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</div>
|
|
<div className="lbl">{c.label}</div>
|
|
<div className="val">
|
|
{c.value === undefined
|
|
? <span className="skeleton" style={{ display: 'inline-block', width: 48, height: 26, borderRadius: 4 }} />
|
|
: formatNumber(c.value)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Main card */}
|
|
<div className="card">
|
|
{/* Toolbar */}
|
|
<div className="card-pad" style={{ paddingBottom: 0 }}>
|
|
<div className="toolbar">
|
|
<div className="field" style={{ minWidth: 240 }}>
|
|
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
|
|
<input
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
placeholder="شماره کاربری، نام، موبایل یا ایمیل..."
|
|
/>
|
|
</div>
|
|
<div style={{ minWidth: 160 }}>
|
|
<SearchableSelect
|
|
options={ROLE_TABS.map(t => ({ value: t.key, label: t.label }))}
|
|
value={role || ''}
|
|
onChange={(v) => setRole(v ? String(v) : '')}
|
|
height={36}
|
|
/>
|
|
</div>
|
|
<div className="seg">
|
|
<button className={!status ? 'on' : ''} onClick={() => setStatus('')}>همه</button>
|
|
<button className={status === '1' ? 'on' : ''} onClick={() => setStatus('1')}>فعال</button>
|
|
<button className={status === '0' ? 'on' : ''} onClick={() => setStatus('0')}>غیرفعال</button>
|
|
</div>
|
|
<div className="spacer" />
|
|
<button className="btn ghost sm" onClick={() => usersQ.refetch()} disabled={usersQ.isFetching} title="بهروزرسانی">
|
|
<ArrowPathIcon style={{ width: 15, height: 15, animation: usersQ.isFetching ? 'spin 1s linear infinite' : undefined }} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="table-wrap">
|
|
<table className="t">
|
|
<thead>
|
|
<tr>
|
|
<th>کاربر</th>
|
|
<th>شماره موبایل</th>
|
|
<th>نقش</th>
|
|
<th>تاریخ عضویت</th>
|
|
<th>وضعیت</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{usersQ.isLoading && Array.from({ length: 7 }).map((_, i) => (
|
|
<tr key={i}>
|
|
{Array.from({ length: 6 }).map((_, j) => (
|
|
<td key={j}>
|
|
<div className="skeleton" style={{ height: 14, borderRadius: 6, width: j === 1 ? '70%' : '55%' }} />
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
|
|
{!usersQ.isLoading && items.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6}>
|
|
<div className="empty">هیچ کاربری یافت نشد</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{!usersQ.isLoading && items.map((user) => (
|
|
<tr key={user.uuid}>
|
|
<td>
|
|
<div className="cell-user">
|
|
<UserAvatar name={user.name} id={user.id} />
|
|
<div>
|
|
<b style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/users/${user.uuid}`)}>
|
|
{user.name || '—'}
|
|
</b>
|
|
<br /><small>#{user.id}</small>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td style={{ fontFamily: 'monospace', direction: 'ltr', textAlign: 'right' }}>
|
|
{user.mobile_number}
|
|
</td>
|
|
<td><RoleBadge roles={user.roles} /></td>
|
|
<td className="muted">{formatDate(user.created_at)}</td>
|
|
<td><ActiveBadge active={user.is_active} /></td>
|
|
<td>
|
|
<div className="row-actions">
|
|
<button className="mini-btn" title="مشاهده"
|
|
onClick={() => navigate(`/admin/users/${user.uuid}`)}>
|
|
<EyeIcon style={{ width: 16, height: 16 }} />
|
|
</button>
|
|
<button className="mini-btn" title="تغییر نقش"
|
|
onClick={() => setRoleTarget(user)}>
|
|
<ShieldCheckIcon style={{ width: 16, height: 16 }} />
|
|
</button>
|
|
<button className="mini-btn" title={user.is_active ? 'غیرفعال کردن' : 'فعالسازی'}
|
|
onClick={() => toggleStatusMut.mutate(user.uuid)} disabled={toggleStatusMut.isPending}>
|
|
{user.is_active
|
|
? <XCircleIcon style={{ width: 16, height: 16 }} />
|
|
: <CheckCircleIcon style={{ width: 16, height: 16 }} />}
|
|
</button>
|
|
<button className="mini-btn danger" title="حذف" onClick={() => setDeleteTarget(user)}>
|
|
<TrashIcon style={{ width: 16, height: 16 }} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
|
</div>
|
|
|
|
{/* Dialogs */}
|
|
<ConfirmDialog
|
|
open={!!deleteTarget}
|
|
title="حذف کاربر"
|
|
message={`آیا از حذف کاربر "${deleteTarget?.name ?? deleteTarget?.mobile_number}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
|
confirmLabel="حذف"
|
|
danger
|
|
loading={deleteMut.isPending}
|
|
onConfirm={() => deleteTarget && deleteMut.mutate(deleteTarget.uuid)}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
/>
|
|
|
|
{roleTarget && (
|
|
<ChangeRoleModal
|
|
user={roleTarget}
|
|
loading={updateRoleMut.isPending}
|
|
onClose={() => setRoleTarget(null)}
|
|
onSave={(r) => updateRoleMut.mutate({ uuid: roleTarget.uuid, role: r })}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|