refactor: centralize platform resources and improve role-based access control with standardized parsing and updated permission hooks
This commit is contained in:
parent
ae72eebcea
commit
edd8fe8089
@ -17,7 +17,7 @@ interface HeaderProps {
|
|||||||
export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps): ReactElement => {
|
export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps): ReactElement => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { user, isLoading,roles } = useAppSelector((state) => state.auth);
|
const { user, isLoading, roles, tenantId } = useAppSelector((state) => state.auth);
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -61,6 +61,8 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
|||||||
};
|
};
|
||||||
}, [isDropdownOpen]);
|
}, [isDropdownOpen]);
|
||||||
|
|
||||||
|
const isPlatformUser = roles.includes('super_admin') || tenantId === '00000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
// Handle logout
|
// Handle logout
|
||||||
const handleLogout = async (e: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
|
const handleLogout = async (e: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -72,10 +74,9 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
|||||||
// Check if user is on a tenant route to determine redirect path
|
// Check if user is on a tenant route to determine redirect path
|
||||||
// Note: use /tenant/ instead of /tenant to avoid matching /tenants
|
// Note: use /tenant/ instead of /tenant to avoid matching /tenants
|
||||||
const isTenantRoute = window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant';
|
const isTenantRoute = window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant';
|
||||||
const isSuperAdmin = roles.includes('super_admin');
|
|
||||||
|
|
||||||
// Super admins always go to root login, tenant users go to /tenant/login if on a tenant route
|
// Platform users always go to root login, tenant users go to /tenant/login if on a tenant route
|
||||||
const redirectPath = isSuperAdmin ? '/' : (isTenantRoute ? '/tenant/login' : '/');
|
const redirectPath = isPlatformUser ? '/' : (isTenantRoute ? '/tenant/login' : '/');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call logout API with Bearer token
|
// Call logout API with Bearer token
|
||||||
@ -118,7 +119,7 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
|||||||
{breadcrumbs[0].label !== 'QAssure' && (
|
{breadcrumbs[0].label !== 'QAssure' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(roles.includes('super_admin') ? '/dashboard' : '/tenant')}
|
onClick={() => navigate(isPlatformUser ? '/dashboard' : '/tenant')}
|
||||||
className="text-xs md:text-[13px] font-normal text-[#6b7280] hover:text-[#0f1724] transition-colors cursor-pointer"
|
className="text-xs md:text-[13px] font-normal text-[#6b7280] hover:text-[#0f1724] transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
QAssure
|
QAssure
|
||||||
@ -149,7 +150,7 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(roles.includes('super_admin') ? '/dashboard' : '/tenant')}
|
onClick={() => navigate(isPlatformUser ? '/dashboard' : '/tenant')}
|
||||||
className="text-xs md:text-[13px] font-normal text-[#6b7280] hover:text-[#0f1724] transition-colors cursor-pointer"
|
className="text-xs md:text-[13px] font-normal text-[#6b7280] hover:text-[#0f1724] transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
QAssure
|
QAssure
|
||||||
|
|||||||
@ -52,10 +52,29 @@ interface SidebarProps {
|
|||||||
// Super Admin menu items
|
// Super Admin menu items
|
||||||
const superAdminPlatformMenu: MenuItem[] = [
|
const superAdminPlatformMenu: MenuItem[] = [
|
||||||
{ icon: LayoutDashboard, label: "Dashboard", path: "/dashboard" },
|
{ icon: LayoutDashboard, label: "Dashboard", path: "/dashboard" },
|
||||||
{ icon: Building2, label: "Tenants", path: "/tenants" },
|
{
|
||||||
// { icon: Users, label: 'User Management', path: '/users' },
|
icon: Building2,
|
||||||
// { icon: Shield, label: 'Roles', path: '/roles' },
|
label: "Tenants",
|
||||||
{ icon: Package, label: "Modules", path: "/modules" },
|
path: "/tenants",
|
||||||
|
requiredPermission: { resource: "tenants", action: "read" },
|
||||||
|
},
|
||||||
|
// Platform Users & Roles — no requiredPermission: super_admin always sees it;
|
||||||
|
// platform users do NOT see it (cannot manage other platform users)
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
label: "Platform Users",
|
||||||
|
isGroup: true,
|
||||||
|
children: [
|
||||||
|
{ label: "Users", path: "/platform-users" },
|
||||||
|
{ label: "Roles & Permissions", path: "/platform-roles" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Package,
|
||||||
|
label: "Modules",
|
||||||
|
path: "/modules",
|
||||||
|
requiredPermission: { resource: "modules", action: "read" },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const superAdminSystemMenu: MenuItem[] = [
|
const superAdminSystemMenu: MenuItem[] = [
|
||||||
@ -68,9 +87,20 @@ const superAdminSystemMenu: MenuItem[] = [
|
|||||||
{ label: "Master Management", path: "/notification-master" },
|
{ label: "Master Management", path: "/notification-master" },
|
||||||
{ label: "Global Templates", path: "/notification-templates" },
|
{ label: "Global Templates", path: "/notification-templates" },
|
||||||
],
|
],
|
||||||
|
requiredPermission: { resource: "notifications", action: "read" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
label: "Audit Logs",
|
||||||
|
path: "/audit-logs",
|
||||||
|
requiredPermission: { resource: "audit_logs", action: "read" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
label: "Audit Resources",
|
||||||
|
path: "/audit-resource-types",
|
||||||
|
requiredPermission: { resource: "audit_resources", action: "read" },
|
||||||
},
|
},
|
||||||
{ icon: FileText, label: "Audit Logs", path: "/audit-logs" },
|
|
||||||
{ icon: Shield, label: "Audit Resources", path: "/audit-resource-types" },
|
|
||||||
{
|
{
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
@ -80,6 +110,7 @@ const superAdminSystemMenu: MenuItem[] = [
|
|||||||
{ label: "Failed Emails", path: "/settings/failed-emails" },
|
{ label: "Failed Emails", path: "/settings/failed-emails" },
|
||||||
{ label: "AI Fallback Monitoring",path: "/settings/ai-fallbacks" },
|
{ label: "AI Fallback Monitoring",path: "/settings/ai-fallbacks" },
|
||||||
],
|
],
|
||||||
|
requiredPermission: { resource: "settings", action: "read" },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -426,7 +457,7 @@ const GroupMenuItem = ({
|
|||||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
const { primaryColor, secondaryColor, accentColor, logoUrl } = useAppTheme();
|
const { primaryColor, secondaryColor, accentColor, logoUrl } = useAppTheme();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { roles, permissions } = useAppSelector((state) => state.auth);
|
const { roles, permissions, tenantId } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
// Fetch theme for tenant admin
|
// Fetch theme for tenant admin
|
||||||
const isSuperAdminCheck = () => {
|
const isSuperAdminCheck = () => {
|
||||||
@ -445,11 +476,50 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
|
|
||||||
const isSuperAdmin = isSuperAdminCheck();
|
const isSuperAdmin = isSuperAdminCheck();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this user a platform user (non-super-admin with a platform-scoped role)?
|
||||||
|
* Platform users see a filtered super-admin sidebar — only the tabs they have
|
||||||
|
* permission for. The sidebar check re-uses the existing hasPermission() +
|
||||||
|
* filterMenuItems() logic that already works for tenant users.
|
||||||
|
*/
|
||||||
|
const isPlatformUser = !isSuperAdmin && (
|
||||||
|
tenantId === "00000000-0000-0000-0000-000000000001" ||
|
||||||
|
(() => {
|
||||||
|
let rolesArray: string[] = [];
|
||||||
|
if (Array.isArray(roles)) {
|
||||||
|
rolesArray = roles;
|
||||||
|
} else if (typeof roles === "string") {
|
||||||
|
try { rolesArray = JSON.parse(roles); } catch { rolesArray = []; }
|
||||||
|
}
|
||||||
|
// A platform user is NOT super_admin and NOT a tenant_admin / viewer / etc.
|
||||||
|
// The server already embeds platform-scoped roles in the JWT.
|
||||||
|
// We detect it by: the user has no tenantId but has roles (or has any role
|
||||||
|
// that is not one of the known tenant roles). Simplest: if not super_admin
|
||||||
|
// and the user ended up on the super-admin layout, treat them as platform user.
|
||||||
|
const knownTenantRoles = ["tenant_admin", "quality_manager", "viewer"];
|
||||||
|
return rolesArray.some(r => !knownTenantRoles.includes(r) && r !== "super_admin");
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
// Get role name for display
|
// Get role name for display
|
||||||
const getRoleName = (): string => {
|
const getRoleName = (): string => {
|
||||||
if (isSuperAdmin) {
|
if (isSuperAdmin) {
|
||||||
return "Super Admin";
|
return "Super Admin";
|
||||||
}
|
}
|
||||||
|
if (isPlatformUser) {
|
||||||
|
// Format the platform role name nicely
|
||||||
|
let rolesArray: string[] = [];
|
||||||
|
if (Array.isArray(roles)) rolesArray = roles;
|
||||||
|
else if (typeof roles === "string") {
|
||||||
|
try { rolesArray = JSON.parse(roles); } catch { rolesArray = []; }
|
||||||
|
}
|
||||||
|
if (rolesArray.length > 0) {
|
||||||
|
return rolesArray[0]
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
let rolesArray: string[] = [];
|
let rolesArray: string[] = [];
|
||||||
if (Array.isArray(roles)) {
|
if (Array.isArray(roles)) {
|
||||||
rolesArray = roles;
|
rolesArray = roles;
|
||||||
@ -546,14 +616,16 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Select and filter menu items based on role and permissions
|
// Select and filter menu items based on role and permissions
|
||||||
|
// Platform users reuse the super-admin menu arrays but filterMenuItems() hides
|
||||||
|
// tabs they don't have permission for (same as tenant sidebar logic).
|
||||||
const platformMenu = filterMenuItems(
|
const platformMenu = filterMenuItems(
|
||||||
isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu,
|
isSuperAdmin || isPlatformUser ? superAdminPlatformMenu : tenantAdminPlatformMenu,
|
||||||
);
|
);
|
||||||
const platformServiceMenu = filterMenuItems(
|
const platformServiceMenu = filterMenuItems(
|
||||||
isSuperAdmin ? [] : tenantAdminPlatformServiceMenu,
|
isSuperAdmin || isPlatformUser ? [] : tenantAdminPlatformServiceMenu,
|
||||||
);
|
);
|
||||||
const systemMenu = filterMenuItems(
|
const systemMenu = filterMenuItems(
|
||||||
isSuperAdmin ? superAdminSystemMenu : tenantAdminSystemMenu,
|
isSuperAdmin || isPlatformUser ? superAdminSystemMenu : tenantAdminSystemMenu,
|
||||||
);
|
);
|
||||||
|
|
||||||
const MenuSection = ({
|
const MenuSection = ({
|
||||||
|
|||||||
@ -13,12 +13,14 @@ import { toast } from "sonner";
|
|||||||
import { Pagination } from "./Pagination";
|
import { Pagination } from "./Pagination";
|
||||||
import { ActionDropdown } from "./ActionDropdown";
|
import { ActionDropdown } from "./ActionDropdown";
|
||||||
import { PrimaryButton } from "./PrimaryButton";
|
import { PrimaryButton } from "./PrimaryButton";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
|
||||||
interface FailedEmailsTableProps {
|
interface FailedEmailsTableProps {
|
||||||
onRegisterResendAll?: (node: React.ReactNode) => void;
|
onRegisterResendAll?: (node: React.ReactNode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FailedEmailsTable: React.FC<FailedEmailsTableProps> = ({ onRegisterResendAll }) => {
|
export const FailedEmailsTable: React.FC<FailedEmailsTableProps> = ({ onRegisterResendAll }) => {
|
||||||
|
const { canUpdate, canDelete } = usePermissions();
|
||||||
const [emails, setEmails] = useState<FailedEmail[]>([]);
|
const [emails, setEmails] = useState<FailedEmail[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@ -86,6 +88,7 @@ export const FailedEmailsTable: React.FC<FailedEmailsTableProps> = ({ onRegister
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onRegisterResendAll) {
|
if (onRegisterResendAll) {
|
||||||
onRegisterResendAll(
|
onRegisterResendAll(
|
||||||
|
canUpdate("settings") ? (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={handleResendAll}
|
onClick={handleResendAll}
|
||||||
disabled={
|
disabled={
|
||||||
@ -102,6 +105,7 @@ export const FailedEmailsTable: React.FC<FailedEmailsTableProps> = ({ onRegister
|
|||||||
"Resend All Failed"
|
"Resend All Failed"
|
||||||
)}
|
)}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
) : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
@ -109,7 +113,7 @@ export const FailedEmailsTable: React.FC<FailedEmailsTableProps> = ({ onRegister
|
|||||||
onRegisterResendAll(null);
|
onRegisterResendAll(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isResendingAll, emails, onRegisterResendAll]);
|
}, [isResendingAll, emails, onRegisterResendAll, canUpdate]);
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
@ -167,7 +171,7 @@ export const FailedEmailsTable: React.FC<FailedEmailsTableProps> = ({ onRegister
|
|||||||
onClick: () => showEmailDetails(record),
|
onClick: () => showEmailDetails(record),
|
||||||
icon: <Eye className="w-3.5 h-3.5 text-gray-500" />,
|
icon: <Eye className="w-3.5 h-3.5 text-gray-500" />,
|
||||||
},
|
},
|
||||||
...(record.status === "failed"
|
...(record.status === "failed" && canUpdate("settings")
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label:
|
label:
|
||||||
@ -184,12 +188,16 @@ export const FailedEmailsTable: React.FC<FailedEmailsTableProps> = ({ onRegister
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(canDelete("settings")
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
label: "Delete Email",
|
label: "Delete Email",
|
||||||
onClick: () => handleDelete(record.id),
|
onClick: () => handleDelete(record.id),
|
||||||
icon: <Trash2 className="w-3.5 h-3.5 text-red-600" />,
|
icon: <Trash2 className="w-3.5 h-3.5 text-red-600" />,
|
||||||
variant: "danger",
|
variant: "danger" as const,
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -200,13 +208,8 @@ export const FailedEmailsTable: React.FC<FailedEmailsTableProps> = ({ onRegister
|
|||||||
return (
|
return (
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
{/* Toolbar / Actions Header */}
|
{/* Toolbar / Actions Header */}
|
||||||
{!onRegisterResendAll && (
|
{!onRegisterResendAll && canUpdate("settings") && (
|
||||||
<div className="pb-2 flex justify-end">
|
<div className="pb-2 flex justify-end">
|
||||||
{/* <div className="flex items-center gap-2 w-full sm:w-auto justify-end"> */}
|
|
||||||
{/* <Button variant="outline" onClick={() => fetchEmails(currentPage)}>
|
|
||||||
<RefreshCw className="mr-2 w-3.5 h-3.5" />
|
|
||||||
Refresh
|
|
||||||
</Button> */}
|
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={handleResendAll}
|
onClick={handleResendAll}
|
||||||
disabled={
|
disabled={
|
||||||
@ -223,7 +226,6 @@ export const FailedEmailsTable: React.FC<FailedEmailsTableProps> = ({ onRegister
|
|||||||
"Resend All Failed"
|
"Resend All Failed"
|
||||||
)}
|
)}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
{/* </div> */}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
1220
src/components/superadmin/PlatformRolesTable.tsx
Normal file
1220
src/components/superadmin/PlatformRolesTable.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1035
src/components/superadmin/PlatformUsersTable.tsx
Normal file
1035
src/components/superadmin/PlatformUsersTable.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,7 @@ import {
|
|||||||
NewRoleModal,
|
NewRoleModal,
|
||||||
ViewRoleModal,
|
ViewRoleModal,
|
||||||
EditRoleModal,
|
EditRoleModal,
|
||||||
DeleteConfirmationModal,
|
// DeleteConfirmationModal,
|
||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
@ -97,11 +97,11 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
// View, Edit, Delete modals
|
// View, Edit, Delete modals
|
||||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||||
const [selectedRoleName, setSelectedRoleName] = useState<string>("");
|
// const [selectedRoleName, setSelectedRoleName] = useState<string>("");
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
|
|
||||||
const fetchRoles = async (
|
const fetchRoles = async (
|
||||||
page: number,
|
page: number,
|
||||||
@ -173,9 +173,11 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Edit role handler
|
// Edit role handler
|
||||||
const handleEditRole = (roleId: string, roleName: string): void => {
|
const handleEditRole = (roleId: string
|
||||||
|
// , roleName: string
|
||||||
|
): void => {
|
||||||
setSelectedRoleId(roleId);
|
setSelectedRoleId(roleId);
|
||||||
setSelectedRoleName(roleName);
|
// setSelectedRoleName(roleName);
|
||||||
setEditModalOpen(true);
|
setEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -194,7 +196,7 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
showToast.success(message, description);
|
showToast.success(message, description);
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
setSelectedRoleId(null);
|
setSelectedRoleId(null);
|
||||||
setSelectedRoleName("");
|
// setSelectedRoleName("");
|
||||||
await fetchRoles(currentPage, limit, orderBy);
|
await fetchRoles(currentPage, limit, orderBy);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw err;
|
throw err;
|
||||||
@ -204,29 +206,29 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Delete role handler
|
// Delete role handler
|
||||||
const handleDeleteRole = (roleId: string, roleName: string): void => {
|
// const handleDeleteRole = (roleId: string, roleName: string): void => {
|
||||||
setSelectedRoleId(roleId);
|
// setSelectedRoleId(roleId);
|
||||||
setSelectedRoleName(roleName);
|
// setSelectedRoleName(roleName);
|
||||||
setDeleteModalOpen(true);
|
// setDeleteModalOpen(true);
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Confirm delete handler
|
// Confirm delete handler
|
||||||
const handleConfirmDelete = async (): Promise<void> => {
|
// const handleConfirmDelete = async (): Promise<void> => {
|
||||||
if (!selectedRoleId) return;
|
// if (!selectedRoleId) return;
|
||||||
|
|
||||||
try {
|
// try {
|
||||||
setIsDeleting(true);
|
// setIsDeleting(true);
|
||||||
await roleService.delete(selectedRoleId);
|
// await roleService.delete(selectedRoleId);
|
||||||
setDeleteModalOpen(false);
|
// setDeleteModalOpen(false);
|
||||||
setSelectedRoleId(null);
|
// setSelectedRoleId(null);
|
||||||
setSelectedRoleName("");
|
// setSelectedRoleName("");
|
||||||
await fetchRoles(currentPage, limit, orderBy);
|
// await fetchRoles(currentPage, limit, orderBy);
|
||||||
} catch (err: any) {
|
// } catch (err: any) {
|
||||||
throw err; // Let the modal handle the error display
|
// throw err; // Let the modal handle the error display
|
||||||
} finally {
|
// } finally {
|
||||||
setIsDeleting(false);
|
// setIsDeleting(false);
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Load role for view/edit
|
// Load role for view/edit
|
||||||
const loadRole = async (id: string): Promise<Role> => {
|
const loadRole = async (id: string): Promise<Role> => {
|
||||||
@ -302,14 +304,16 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
onView={() => handleViewRole(role.id)}
|
onView={() => handleViewRole(role.id)}
|
||||||
onEdit={
|
onEdit={
|
||||||
isTenantAdmin
|
isTenantAdmin
|
||||||
? () => handleEditRole(role.id, role.name)
|
? () => handleEditRole(role.id
|
||||||
: undefined
|
// , role.name
|
||||||
}
|
)
|
||||||
onDelete={
|
|
||||||
isTenantAdmin
|
|
||||||
? () => handleDeleteRole(role.id, role.name)
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
// onDelete={
|
||||||
|
// isTenantAdmin
|
||||||
|
// ? () => handleDeleteRole(role.id, role.name)
|
||||||
|
// : undefined
|
||||||
|
// }
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -332,14 +336,16 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
onView={() => handleViewRole(role.id)}
|
onView={() => handleViewRole(role.id)}
|
||||||
onEdit={
|
onEdit={
|
||||||
isTenantAdmin
|
isTenantAdmin
|
||||||
? () => handleEditRole(role.id, role.name)
|
? () => handleEditRole(role.id
|
||||||
: undefined
|
// , role.name
|
||||||
}
|
)
|
||||||
onDelete={
|
|
||||||
isTenantAdmin
|
|
||||||
? () => handleDeleteRole(role.id, role.name)
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
// onDelete={
|
||||||
|
// isTenantAdmin
|
||||||
|
// ? () => handleDeleteRole(role.id, role.name)
|
||||||
|
// : undefined
|
||||||
|
// }
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
@ -450,7 +456,7 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
setSelectedRoleId(null);
|
setSelectedRoleId(null);
|
||||||
setSelectedRoleName("");
|
// setSelectedRoleName("");
|
||||||
}}
|
}}
|
||||||
roleId={selectedRoleId}
|
roleId={selectedRoleId}
|
||||||
onLoadRole={loadRole}
|
onLoadRole={loadRole}
|
||||||
@ -459,7 +465,7 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
defaultTenantId={tenantId || undefined}
|
defaultTenantId={tenantId || undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteConfirmationModal
|
{/* <DeleteConfirmationModal
|
||||||
isOpen={deleteModalOpen}
|
isOpen={deleteModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
@ -471,7 +477,7 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
message={`Are you sure you want to delete this role`}
|
message={`Are you sure you want to delete this role`}
|
||||||
itemName={selectedRoleName}
|
itemName={selectedRoleName}
|
||||||
isLoading={isDeleting}
|
isLoading={isDeleting}
|
||||||
/>
|
/> */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -593,7 +599,7 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
setSelectedRoleId(null);
|
setSelectedRoleId(null);
|
||||||
setSelectedRoleName("");
|
// setSelectedRoleName("");
|
||||||
}}
|
}}
|
||||||
roleId={selectedRoleId}
|
roleId={selectedRoleId}
|
||||||
onLoadRole={loadRole}
|
onLoadRole={loadRole}
|
||||||
@ -602,7 +608,7 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
defaultTenantId={tenantId || undefined}
|
defaultTenantId={tenantId || undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteConfirmationModal
|
{/* <DeleteConfirmationModal
|
||||||
isOpen={deleteModalOpen}
|
isOpen={deleteModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
@ -614,7 +620,7 @@ export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(
|
|||||||
message={`Are you sure you want to delete this role`}
|
message={`Are you sure you want to delete this role`}
|
||||||
itemName={selectedRoleName}
|
itemName={selectedRoleName}
|
||||||
isLoading={isDeleting}
|
isLoading={isDeleting}
|
||||||
/>
|
/> */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
34
src/constants/platformResources.ts
Normal file
34
src/constants/platformResources.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Platform Resources — QAssure Platform
|
||||||
|
* Canonical list of resources that platform roles can be granted permissions on.
|
||||||
|
* Derived from seed.js permissions list (lines 48–81).
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - Platform Role create/edit permission matrix (checklist)
|
||||||
|
* - Sidebar menu item filtering for platform users
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PlatformResource {
|
||||||
|
resource: string;
|
||||||
|
label: string;
|
||||||
|
actions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLATFORM_RESOURCES: PlatformResource[] = [
|
||||||
|
{ resource: 'tenants', label: 'Tenants', actions: ['read', 'create', 'update', 'delete'] },
|
||||||
|
{ resource: 'users', label: 'Users', actions: ['read', 'create', 'update', 'delete'] },
|
||||||
|
{ resource: 'roles', label: 'Roles', actions: ['read', 'create', 'update', 'delete'] },
|
||||||
|
{ resource: 'permissions', label: 'Permissions', actions: ['read', 'create', 'update', 'delete'] },
|
||||||
|
{ resource: 'modules', label: 'Modules', actions: ['read', 'create', 'update', 'delete'] },
|
||||||
|
{ resource: 'audit_logs', label: 'Audit Logs', actions: ['read'] },
|
||||||
|
{ resource: 'audit_resources', label: 'Audit Resources', actions: ['read', 'create', 'update', 'delete'] },
|
||||||
|
{ resource: 'notifications', label: 'Notifications', actions: ['read', 'create', 'update', 'delete'] },
|
||||||
|
{ resource: 'smtp_config', label: 'SMTP Config', actions: ['read', 'update'] },
|
||||||
|
{ resource: 'api_keys', label: 'API Keys', actions: ['read', 'create', 'update', 'delete'] },
|
||||||
|
{ resource: 'settings', label: 'Settings', actions: ['read', 'update'] },
|
||||||
|
{ resource: 'security', label: 'Security', actions: ['read', 'update'] },
|
||||||
|
{ resource: 'ai_completions', label: 'AI Completions', actions: ['read'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Convenience: all distinct action names */
|
||||||
|
export const ALL_ACTIONS = ['read', 'create', 'update', 'delete'];
|
||||||
@ -8,11 +8,28 @@ import { useAppSelector } from './redux-hooks';
|
|||||||
export const usePermissions = () => {
|
export const usePermissions = () => {
|
||||||
const { roles, permissions } = useAppSelector((state) => state.auth);
|
const { roles, permissions } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
const isSuperAdmin = useMemo(() => {
|
const parsedRoles = useMemo((): string[] => {
|
||||||
const rolesArray = Array.isArray(roles) ? roles : [];
|
if (Array.isArray(roles)) {
|
||||||
return rolesArray.includes('super_admin');
|
return roles;
|
||||||
|
}
|
||||||
|
if (typeof roles === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(roles);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return [roles];
|
||||||
|
} catch {
|
||||||
|
return [roles];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}, [roles]);
|
}, [roles]);
|
||||||
|
|
||||||
|
const isSuperAdmin = useMemo(() => {
|
||||||
|
return parsedRoles.includes('super_admin');
|
||||||
|
}, [parsedRoles]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user has permission for a specific resource and action
|
* Check if user has permission for a specific resource and action
|
||||||
* @param resource - The resource name (e.g., 'roles', 'users', 'audit_logs')
|
* @param resource - The resource name (e.g., 'roles', 'users', 'audit_logs')
|
||||||
@ -81,9 +98,8 @@ export const usePermissions = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isTenantAdmin = useMemo(() => {
|
const isTenantAdmin = useMemo(() => {
|
||||||
const rolesArray = Array.isArray(roles) ? roles : [];
|
return parsedRoles.includes('tenant_admin') || parsedRoles.includes('super_admin');
|
||||||
return rolesArray.includes('tenant_admin') || rolesArray.includes('super_admin');
|
}, [parsedRoles]);
|
||||||
}, [roles]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasPermission,
|
hasPermission,
|
||||||
|
|||||||
@ -29,7 +29,7 @@ type LoginFormData = z.infer<typeof loginSchema>;
|
|||||||
const Login = (): ReactElement => {
|
const Login = (): ReactElement => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { isLoading, error, isAuthenticated, roles } = useAppSelector(
|
const { isLoading, error, isAuthenticated, roles, tenantId } = useAppSelector(
|
||||||
(state) => state.auth,
|
(state) => state.auth,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -49,7 +49,7 @@ const Login = (): ReactElement => {
|
|||||||
// Redirect if already authenticated
|
// Redirect if already authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
// Check if user is super_admin, redirect to super admin dashboard
|
// Check if user is super_admin or a platform user, redirect to super admin dashboard
|
||||||
// Handle both array and JSON string formats
|
// Handle both array and JSON string formats
|
||||||
let rolesArray: string[] = [];
|
let rolesArray: string[] = [];
|
||||||
if (Array.isArray(roles)) {
|
if (Array.isArray(roles)) {
|
||||||
@ -61,14 +61,17 @@ const Login = (): ReactElement => {
|
|||||||
rolesArray = [];
|
rolesArray = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (rolesArray.includes("super_admin")) {
|
const isPlatformUser =
|
||||||
|
rolesArray.includes("super_admin") ||
|
||||||
|
tenantId === "00000000-0000-0000-0000-000000000001";
|
||||||
|
if (isPlatformUser) {
|
||||||
navigate("/dashboard");
|
navigate("/dashboard");
|
||||||
} else {
|
} else {
|
||||||
// Tenant admin - redirect to tenant landing page (workspace selector)
|
// Tenant admin - redirect to tenant landing page (workspace selector)
|
||||||
navigate("/tenant/landing");
|
navigate("/tenant/landing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, roles, navigate]);
|
}, [isAuthenticated, roles, tenantId, navigate]);
|
||||||
|
|
||||||
// Clear errors only on component mount, not on every auth state change
|
// Clear errors only on component mount, not on every auth state change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -104,7 +107,11 @@ const Login = (): ReactElement => {
|
|||||||
|
|
||||||
// Check roles after login to redirect appropriately
|
// Check roles after login to redirect appropriately
|
||||||
const userRoles = result.data.roles || [];
|
const userRoles = result.data.roles || [];
|
||||||
if (userRoles.includes("super_admin")) {
|
const userTenantId = result.data.tenant_id;
|
||||||
|
const isPlatformUser =
|
||||||
|
userRoles.includes("super_admin") ||
|
||||||
|
userTenantId === "00000000-0000-0000-0000-000000000001";
|
||||||
|
if (isPlatformUser) {
|
||||||
navigate("/dashboard");
|
navigate("/dashboard");
|
||||||
} else {
|
} else {
|
||||||
navigate("/tenant/landing");
|
navigate("/tenant/landing");
|
||||||
|
|||||||
@ -7,13 +7,13 @@ interface ProtectedRouteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => {
|
const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => {
|
||||||
const { isAuthenticated, roles } = useAppSelector((state) => state.auth);
|
const { isAuthenticated, roles, tenantId } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has super_admin role
|
// Check if user has super_admin role or is a platform user
|
||||||
// Handle both array and JSON string formats
|
// Handle both array and JSON string formats
|
||||||
let rolesArray: string[] = [];
|
let rolesArray: string[] = [];
|
||||||
if (Array.isArray(roles)) {
|
if (Array.isArray(roles)) {
|
||||||
@ -26,9 +26,10 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hasSuperAdminRole = rolesArray && rolesArray.length > 0 && rolesArray.includes('super_admin');
|
const hasSuperAdminRole = rolesArray && rolesArray.length > 0 && rolesArray.includes('super_admin');
|
||||||
|
const isPlatformUser = hasSuperAdminRole || tenantId === '00000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
if (!hasSuperAdminRole) {
|
if (!isPlatformUser) {
|
||||||
// If not super_admin, redirect to tenant login
|
// If not a platform user, redirect to tenant login
|
||||||
return <Navigate to="/tenant/login" replace />;
|
return <Navigate to="/tenant/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
@ -49,6 +50,7 @@ const BUILT_IN_PROVIDERS = [
|
|||||||
const NONE_OPTION = { value: "__none__", label: "— No fallback —" };
|
const NONE_OPTION = { value: "__none__", label: "— No fallback —" };
|
||||||
|
|
||||||
export const AIFallbackHistory = () => {
|
export const AIFallbackHistory = () => {
|
||||||
|
const { canUpdate } = usePermissions();
|
||||||
const [fallbacks, setFallbacks] = useState<FallbackEvent[]>([]);
|
const [fallbacks, setFallbacks] = useState<FallbackEvent[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [selectedEvent, setSelectedEvent] = useState<FallbackEvent | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<FallbackEvent | null>(null);
|
||||||
@ -432,7 +434,8 @@ export const AIFallbackHistory = () => {
|
|||||||
<select
|
<select
|
||||||
value={currentFallback}
|
value={currentFallback}
|
||||||
onChange={(e) => handleMappingChange(provider.name, e.target.value)}
|
onChange={(e) => handleMappingChange(provider.name, e.target.value)}
|
||||||
className="w-full h-9 px-3 text-sm border border-slate-200 rounded-lg bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-[#112868]/20 focus:border-[#112868] transition-all cursor-pointer"
|
disabled={!canUpdate("settings")}
|
||||||
|
className="w-full h-9 px-3 text-sm border border-slate-200 rounded-lg bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-[#112868]/20 focus:border-[#112868] transition-all cursor-pointer disabled:bg-slate-100 disabled:text-slate-400 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
@ -464,6 +467,7 @@ export const AIFallbackHistory = () => {
|
|||||||
{availableProviders.length} provider{availableProviders.length !== 1 ? "s" : ""} configured.
|
{availableProviders.length} provider{availableProviders.length !== 1 ? "s" : ""} configured.
|
||||||
Add more via the <span className="font-semibold text-slate-500">Dynamic Fallback Models & API Keys</span> tab.
|
Add more via the <span className="font-semibold text-slate-500">Dynamic Fallback Models & API Keys</span> tab.
|
||||||
</p>
|
</p>
|
||||||
|
{canUpdate("settings") && (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={handleSaveMapping}
|
onClick={handleSaveMapping}
|
||||||
disabled={isSavingMapping}
|
disabled={isSavingMapping}
|
||||||
@ -481,6 +485,7 @@ export const AIFallbackHistory = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
import {
|
import {
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
@ -53,6 +54,7 @@ const getStatusVariant = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Modules = (): ReactElement => {
|
const Modules = (): ReactElement => {
|
||||||
|
const { canCreate, canUpdate } = usePermissions();
|
||||||
const [modules, setModules] = useState<Module[]>([]);
|
const [modules, setModules] = useState<Module[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -291,6 +293,8 @@ const Modules = (): ReactElement => {
|
|||||||
label: "View",
|
label: "View",
|
||||||
onClick: () => handleViewModule(module.id),
|
onClick: () => handleViewModule(module.id),
|
||||||
},
|
},
|
||||||
|
...(canUpdate("modules")
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
icon: <Edit className="w-3.5 h-3.5 shrink-0" />,
|
icon: <Edit className="w-3.5 h-3.5 shrink-0" />,
|
||||||
label: "Edit",
|
label: "Edit",
|
||||||
@ -306,6 +310,8 @@ const Modules = (): ReactElement => {
|
|||||||
label: "Webhook Sync",
|
label: "Webhook Sync",
|
||||||
onClick: () => handleOpenWebhookSync(module.id),
|
onClick: () => handleOpenWebhookSync(module.id),
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -482,6 +488,7 @@ const Modules = (): ReactElement => {
|
|||||||
</button> */}
|
</button> */}
|
||||||
|
|
||||||
{/* New Module Button */}
|
{/* New Module Button */}
|
||||||
|
{canCreate("modules") && (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
size="default"
|
size="default"
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
@ -490,6 +497,7 @@ const Modules = (): ReactElement => {
|
|||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
<span className="text-xs">New Module</span>
|
<span className="text-xs">New Module</span>
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useEffect, useRef, type KeyboardEvent } from "react";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
DataTable,
|
DataTable,
|
||||||
@ -132,6 +133,7 @@ const VariableTagInput = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const NotificationMaster = (): ReactElement => {
|
const NotificationMaster = (): ReactElement => {
|
||||||
|
const { canCreate, canUpdate, canDelete } = usePermissions();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [categories, setCategories] = useState<any[]>([]);
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
@ -408,7 +410,9 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
Codes ({c.code_count || 0})
|
Codes ({c.code_count || 0})
|
||||||
</button>
|
</button>
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
onEdit={() => {
|
onEdit={
|
||||||
|
canUpdate("notifications")
|
||||||
|
? () => {
|
||||||
setEditingCategory(c);
|
setEditingCategory(c);
|
||||||
resetCategory({
|
resetCategory({
|
||||||
name: c.name,
|
name: c.name,
|
||||||
@ -417,11 +421,17 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
module_id: c.module_id || "",
|
module_id: c.module_id || "",
|
||||||
});
|
});
|
||||||
setCategoryModalOpen(true);
|
setCategoryModalOpen(true);
|
||||||
}}
|
}
|
||||||
onDelete={() => {
|
: undefined
|
||||||
|
}
|
||||||
|
onDelete={
|
||||||
|
canDelete("notifications")
|
||||||
|
? () => {
|
||||||
setDeleteTarget({ id: c.id, name: c.name, type: "category" });
|
setDeleteTarget({ id: c.id, name: c.name, type: "category" });
|
||||||
setDeleteModalOpen(true);
|
setDeleteModalOpen(true);
|
||||||
}}
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -471,6 +481,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
align: "right",
|
align: "right",
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<div className="flex justify-end gap-3 whitespace-nowrap">
|
<div className="flex justify-end gap-3 whitespace-nowrap">
|
||||||
|
{canUpdate("notifications") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingCode(c);
|
setEditingCode(c);
|
||||||
@ -488,6 +499,8 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{canDelete("notifications") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDeleteTarget({
|
setDeleteTarget({
|
||||||
@ -501,6 +514,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -536,6 +550,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
>
|
>
|
||||||
← Back to Categories
|
← Back to Categories
|
||||||
</button>
|
</button>
|
||||||
|
{canCreate("notifications") && (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingCode(null);
|
setEditingCode(null);
|
||||||
@ -553,6 +568,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" /> New Code
|
<Plus className="w-4 h-4" /> New Code
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : undefined,
|
) : undefined,
|
||||||
}}
|
}}
|
||||||
@ -583,6 +599,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
isSearchable
|
isSearchable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{canCreate("notifications") && (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
@ -598,6 +615,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" /> New Category
|
<Plus className="w-4 h-4" /> New Category
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <div className="flex-1"> */}
|
{/* <div className="flex-1"> */}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
DataTable,
|
DataTable,
|
||||||
@ -91,6 +92,7 @@ const VariableChips = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const NotificationTemplateMaster = (): ReactElement => {
|
const NotificationTemplateMaster = (): ReactElement => {
|
||||||
|
const { canCreate, canUpdate } = usePermissions();
|
||||||
const [templates, setTemplates] = useState<any[]>([]);
|
const [templates, setTemplates] = useState<any[]>([]);
|
||||||
const [modules, setModules] = useState<any[]>([]);
|
const [modules, setModules] = useState<any[]>([]);
|
||||||
const [selectedModule, setSelectedModule] = useState<string | null>(null);
|
const [selectedModule, setSelectedModule] = useState<string | null>(null);
|
||||||
@ -330,12 +332,14 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
label: "Actions",
|
label: "Actions",
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
|
canUpdate("notifications") ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(t)}
|
onClick={() => openEditModal(t)}
|
||||||
className="text-xs text-blue-600 hover:underline font-semibold"
|
className="text-xs text-blue-600 hover:underline font-semibold"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
) : null
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -377,6 +381,7 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{canCreate("notifications") && (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@ -401,6 +406,7 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" /> New Template
|
<Plus className="w-4 h-4" /> New Template
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@ -1,443 +1,19 @@
|
|||||||
// import { useState, useEffect } from 'react';
|
import type { ReactElement } from "react";
|
||||||
// import type { ReactElement } from 'react';
|
import { Layout } from "@/components/layout/Layout";
|
||||||
// import { Layout } from '@/components/layout/Layout';
|
import { PlatformRolesTable } from "@/components/superadmin/PlatformRolesTable";
|
||||||
// import {
|
|
||||||
// PrimaryButton,
|
|
||||||
// StatusBadge,
|
|
||||||
// ActionDropdown,
|
|
||||||
// NewRoleModal,
|
|
||||||
// ViewRoleModal,
|
|
||||||
// EditRoleModal,
|
|
||||||
// DeleteConfirmationModal,
|
|
||||||
// DataTable,
|
|
||||||
// Pagination,
|
|
||||||
// FilterDropdown,
|
|
||||||
// type Column,
|
|
||||||
// } from '@/components/shared';
|
|
||||||
// import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
|
||||||
// import { roleService } from '@/services/role-service';
|
|
||||||
// import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
|
|
||||||
// import { showToast } from '@/utils/toast';
|
|
||||||
|
|
||||||
// // Helper function to format date
|
const Roles = (): ReactElement => {
|
||||||
// const formatDate = (dateString: string): string => {
|
return (
|
||||||
// const date = new Date(dateString);
|
<Layout
|
||||||
// return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
currentPage="Roles"
|
||||||
// };
|
pageHeader={{
|
||||||
|
title: "Platform Roles",
|
||||||
|
description: "Manage system-wide administrative roles and permissions.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlatformRolesTable />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// // Helper function to get scope badge variant
|
export default Roles;
|
||||||
// const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
|
|
||||||
// switch (scope.toLowerCase()) {
|
|
||||||
// case 'platform':
|
|
||||||
// return 'success';
|
|
||||||
// case 'tenant':
|
|
||||||
// return 'process';
|
|
||||||
// case 'module':
|
|
||||||
// return 'failure';
|
|
||||||
// default:
|
|
||||||
// return 'success';
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const Roles = (): ReactElement => {
|
|
||||||
// const [roles, setRoles] = useState<Role[]>([]);
|
|
||||||
// const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
||||||
// const [error, setError] = useState<string | null>(null);
|
|
||||||
// const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
|
||||||
// const [isCreating, setIsCreating] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// // Pagination state
|
|
||||||
// const [currentPage, setCurrentPage] = useState<number>(1);
|
|
||||||
// const [limit, setLimit] = useState<number>(5);
|
|
||||||
// const [pagination, setPagination] = useState<{
|
|
||||||
// page: number;
|
|
||||||
// limit: number;
|
|
||||||
// total: number;
|
|
||||||
// totalPages: number;
|
|
||||||
// hasMore: boolean;
|
|
||||||
// }>({
|
|
||||||
// page: 1,
|
|
||||||
// limit: 5,
|
|
||||||
// total: 0,
|
|
||||||
// totalPages: 1,
|
|
||||||
// hasMore: false,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// // Filter state
|
|
||||||
// const [scopeFilter, setScopeFilter] = useState<string | null>(null);
|
|
||||||
// const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
|
||||||
|
|
||||||
// // View, Edit, Delete modals
|
|
||||||
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
|
||||||
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
|
||||||
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
|
||||||
// const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
|
||||||
// const [selectedRoleName, setSelectedRoleName] = useState<string>('');
|
|
||||||
// const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
|
||||||
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// const fetchRoles = async (
|
|
||||||
// page: number,
|
|
||||||
// itemsPerPage: number,
|
|
||||||
// scope: string | null = null,
|
|
||||||
// sortBy: string[] | null = null
|
|
||||||
// ): Promise<void> => {
|
|
||||||
// try {
|
|
||||||
// setIsLoading(true);
|
|
||||||
// setError(null);
|
|
||||||
// const response = await roleService.getAll(page, itemsPerPage, scope, sortBy);
|
|
||||||
// if (response.success) {
|
|
||||||
// setRoles(response.data);
|
|
||||||
// setPagination(response.pagination);
|
|
||||||
// } else {
|
|
||||||
// setError('Failed to load roles');
|
|
||||||
// }
|
|
||||||
// } catch (err: any) {
|
|
||||||
// setError(err?.response?.data?.error?.message || 'Failed to load roles');
|
|
||||||
// } finally {
|
|
||||||
// setIsLoading(false);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
|
||||||
// }, [currentPage, limit, scopeFilter, orderBy]);
|
|
||||||
|
|
||||||
// const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
|
|
||||||
// try {
|
|
||||||
// setIsCreating(true);
|
|
||||||
// const response = await roleService.create(data);
|
|
||||||
// const message = response.message || `Role created successfully`;
|
|
||||||
// const description = response.message ? undefined : `${data.name} has been added`;
|
|
||||||
// showToast.success(message, description);
|
|
||||||
// setIsModalOpen(false);
|
|
||||||
// await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
|
||||||
// } catch (err: any) {
|
|
||||||
// throw err;
|
|
||||||
// } finally {
|
|
||||||
// setIsCreating(false);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // View role handler
|
|
||||||
// const handleViewRole = (roleId: string): void => {
|
|
||||||
// setSelectedRoleId(roleId);
|
|
||||||
// setViewModalOpen(true);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Edit role handler
|
|
||||||
// const handleEditRole = (roleId: string, roleName: string): void => {
|
|
||||||
// setSelectedRoleId(roleId);
|
|
||||||
// setSelectedRoleName(roleName);
|
|
||||||
// setEditModalOpen(true);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Update role handler
|
|
||||||
// const handleUpdateRole = async (
|
|
||||||
// id: string,
|
|
||||||
// data: UpdateRoleRequest
|
|
||||||
// ): Promise<void> => {
|
|
||||||
// try {
|
|
||||||
// setIsUpdating(true);
|
|
||||||
// const response = await roleService.update(id, data);
|
|
||||||
// const message = response.message || `Role updated successfully`;
|
|
||||||
// const description = response.message ? undefined : `${data.name} has been updated`;
|
|
||||||
// showToast.success(message, description);
|
|
||||||
// setEditModalOpen(false);
|
|
||||||
// setSelectedRoleId(null);
|
|
||||||
// await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
|
||||||
// } catch (err: any) {
|
|
||||||
// throw err;
|
|
||||||
// } finally {
|
|
||||||
// setIsUpdating(false);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Delete role handler
|
|
||||||
// const handleDeleteRole = (roleId: string, roleName: string): void => {
|
|
||||||
// setSelectedRoleId(roleId);
|
|
||||||
// setSelectedRoleName(roleName);
|
|
||||||
// setDeleteModalOpen(true);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Confirm delete handler
|
|
||||||
// const handleConfirmDelete = async (): Promise<void> => {
|
|
||||||
// if (!selectedRoleId) return;
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// setIsDeleting(true);
|
|
||||||
// await roleService.delete(selectedRoleId);
|
|
||||||
// setDeleteModalOpen(false);
|
|
||||||
// setSelectedRoleId(null);
|
|
||||||
// setSelectedRoleName('');
|
|
||||||
// await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
|
||||||
// } catch (err: any) {
|
|
||||||
// throw err;
|
|
||||||
// } finally {
|
|
||||||
// setIsDeleting(false);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Load role for view/edit
|
|
||||||
// const loadRole = async (id: string): Promise<Role> => {
|
|
||||||
// const response = await roleService.getById(id);
|
|
||||||
// return response.data;
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Table columns
|
|
||||||
// const columns: Column<Role>[] = [
|
|
||||||
// {
|
|
||||||
// key: 'name',
|
|
||||||
// label: 'Name',
|
|
||||||
// render: (role) => (
|
|
||||||
// <span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'code',
|
|
||||||
// label: 'Code',
|
|
||||||
// render: (role) => (
|
|
||||||
// <span className="text-sm font-normal text-[#0f1724] font-mono">{role.code}</span>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'scope',
|
|
||||||
// label: 'Scope',
|
|
||||||
// render: (role) => (
|
|
||||||
// <StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'description',
|
|
||||||
// label: 'Description',
|
|
||||||
// render: (role) => (
|
|
||||||
// <span className="text-sm font-normal text-[#6b7280]">
|
|
||||||
// {role.description || 'N/A'}
|
|
||||||
// </span>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'is_system',
|
|
||||||
// label: 'System Role',
|
|
||||||
// render: (role) => (
|
|
||||||
// <span className="text-sm font-normal text-[#0f1724]">
|
|
||||||
// {role.is_system ? 'Yes' : 'No'}
|
|
||||||
// </span>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'created_at',
|
|
||||||
// label: 'Created Date',
|
|
||||||
// render: (role) => (
|
|
||||||
// <span className="text-sm font-normal text-[#6b7280]">{formatDate(role.created_at)}</span>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'actions',
|
|
||||||
// label: 'Actions',
|
|
||||||
// align: 'right',
|
|
||||||
// render: (role) => (
|
|
||||||
// <div className="flex justify-end">
|
|
||||||
// <ActionDropdown
|
|
||||||
// onView={() => handleViewRole(role.id)}
|
|
||||||
// onEdit={() => handleEditRole(role.id, role.name)}
|
|
||||||
// onDelete={() => handleDeleteRole(role.id, role.name)}
|
|
||||||
// />
|
|
||||||
// </div>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// ];
|
|
||||||
|
|
||||||
// // Mobile card renderer
|
|
||||||
// const mobileCardRenderer = (role: Role) => (
|
|
||||||
// <div className="p-4">
|
|
||||||
// <div className="flex items-start justify-between gap-3 mb-3">
|
|
||||||
// <div className="flex-1 min-w-0">
|
|
||||||
// <h3 className="text-sm font-medium text-[#0f1724] truncate">{role.name}</h3>
|
|
||||||
// <p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
|
|
||||||
// </div>
|
|
||||||
// <ActionDropdown
|
|
||||||
// onView={() => handleViewRole(role.id)}
|
|
||||||
// onEdit={() => handleEditRole(role.id, role.name)}
|
|
||||||
// onDelete={() => handleDeleteRole(role.id, role.name)}
|
|
||||||
// />
|
|
||||||
// </div>
|
|
||||||
// <div className="grid grid-cols-2 gap-3 text-xs">
|
|
||||||
// <div>
|
|
||||||
// <span className="text-[#9aa6b2]">Scope:</span>
|
|
||||||
// <div className="mt-1">
|
|
||||||
// <StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// <div>
|
|
||||||
// <span className="text-[#9aa6b2]">Created:</span>
|
|
||||||
// <p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
|
|
||||||
// </div>
|
|
||||||
// {role.description && (
|
|
||||||
// <div className="col-span-2">
|
|
||||||
// <span className="text-[#9aa6b2]">Description:</span>
|
|
||||||
// <p className="text-[#0f1724] font-normal mt-1">{role.description}</p>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <Layout
|
|
||||||
// currentPage="Roles"
|
|
||||||
// pageHeader={{
|
|
||||||
// title: 'Role List',
|
|
||||||
// description: 'Define and manage roles to control user access based on job responsibilities',
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
// {/* Table Container */}
|
|
||||||
// <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
|
||||||
// {/* Table Header with Filters */}
|
|
||||||
// <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
|
||||||
// {/* Filters */}
|
|
||||||
// <div className="flex flex-wrap items-center gap-3">
|
|
||||||
// {/* Scope Filter */}
|
|
||||||
// <FilterDropdown
|
|
||||||
// label="Scope"
|
|
||||||
// options={[
|
|
||||||
// { value: 'platform', label: 'Platform' },
|
|
||||||
// { value: 'tenant', label: 'Tenant' },
|
|
||||||
// { value: 'module', label: 'Module' },
|
|
||||||
// ]}
|
|
||||||
// value={scopeFilter}
|
|
||||||
// onChange={(value) => {
|
|
||||||
// setScopeFilter(value as string | null);
|
|
||||||
// setCurrentPage(1);
|
|
||||||
// }}
|
|
||||||
// placeholder="All"
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* Sort Filter */}
|
|
||||||
// <FilterDropdown
|
|
||||||
// label="Sort by"
|
|
||||||
// options={[
|
|
||||||
// { value: ['name', 'asc'], label: 'Name (A-Z)' },
|
|
||||||
// { value: ['name', 'desc'], label: 'Name (Z-A)' },
|
|
||||||
// { value: ['code', 'asc'], label: 'Code (A-Z)' },
|
|
||||||
// { value: ['code', 'desc'], label: 'Code (Z-A)' },
|
|
||||||
// { value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
|
||||||
// { value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
|
||||||
// { value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
|
||||||
// { value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
|
||||||
// ]}
|
|
||||||
// value={orderBy}
|
|
||||||
// onChange={(value) => {
|
|
||||||
// setOrderBy(value as string[] | null);
|
|
||||||
// setCurrentPage(1);
|
|
||||||
// }}
|
|
||||||
// placeholder="Default"
|
|
||||||
// showIcon
|
|
||||||
// icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
|
||||||
// />
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {/* Actions */}
|
|
||||||
// <div className="flex items-center gap-2">
|
|
||||||
// {/* Export Button */}
|
|
||||||
// <button
|
|
||||||
// type="button"
|
|
||||||
// className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
|
||||||
// >
|
|
||||||
// <Download className="w-3.5 h-3.5" />
|
|
||||||
// <span>Export</span>
|
|
||||||
// </button>
|
|
||||||
|
|
||||||
// {/* New Role Button */}
|
|
||||||
// <PrimaryButton
|
|
||||||
// size="default"
|
|
||||||
// className="flex items-center gap-2"
|
|
||||||
// onClick={() => setIsModalOpen(true)}
|
|
||||||
// >
|
|
||||||
// <Plus className="w-3.5 h-3.5" />
|
|
||||||
// <span className="text-xs">New Role</span>
|
|
||||||
// </PrimaryButton>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {/* Table */}
|
|
||||||
// <DataTable
|
|
||||||
// data={roles}
|
|
||||||
// columns={columns}
|
|
||||||
// keyExtractor={(role) => role.id}
|
|
||||||
// mobileCardRenderer={mobileCardRenderer}
|
|
||||||
// emptyMessage="No roles found"
|
|
||||||
// isLoading={isLoading}
|
|
||||||
// error={error}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* Table Footer with Pagination */}
|
|
||||||
// {pagination.total > 0 && (
|
|
||||||
// <Pagination
|
|
||||||
// currentPage={currentPage}
|
|
||||||
// totalPages={pagination.totalPages}
|
|
||||||
// totalItems={pagination.total}
|
|
||||||
// limit={limit}
|
|
||||||
// onPageChange={(page: number) => {
|
|
||||||
// setCurrentPage(page);
|
|
||||||
// }}
|
|
||||||
// onLimitChange={(newLimit: number) => {
|
|
||||||
// setLimit(newLimit);
|
|
||||||
// setCurrentPage(1);
|
|
||||||
// }}
|
|
||||||
// />
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {/* New Role Modal */}
|
|
||||||
// <NewRoleModal
|
|
||||||
// isOpen={isModalOpen}
|
|
||||||
// onClose={() => setIsModalOpen(false)}
|
|
||||||
// onSubmit={handleCreateRole}
|
|
||||||
// isLoading={isCreating}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* View Role Modal */}
|
|
||||||
// <ViewRoleModal
|
|
||||||
// isOpen={viewModalOpen}
|
|
||||||
// onClose={() => {
|
|
||||||
// setViewModalOpen(false);
|
|
||||||
// setSelectedRoleId(null);
|
|
||||||
// }}
|
|
||||||
// roleId={selectedRoleId}
|
|
||||||
// onLoadRole={loadRole}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* Edit Role Modal */}
|
|
||||||
// <EditRoleModal
|
|
||||||
// isOpen={editModalOpen}
|
|
||||||
// onClose={() => {
|
|
||||||
// setEditModalOpen(false);
|
|
||||||
// setSelectedRoleId(null);
|
|
||||||
// setSelectedRoleName('');
|
|
||||||
// }}
|
|
||||||
// roleId={selectedRoleId}
|
|
||||||
// onLoadRole={loadRole}
|
|
||||||
// onSubmit={handleUpdateRole}
|
|
||||||
// isLoading={isUpdating}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* Delete Confirmation Modal */}
|
|
||||||
// <DeleteConfirmationModal
|
|
||||||
// isOpen={deleteModalOpen}
|
|
||||||
// onClose={() => {
|
|
||||||
// setDeleteModalOpen(false);
|
|
||||||
// setSelectedRoleId(null);
|
|
||||||
// setSelectedRoleName('');
|
|
||||||
// }}
|
|
||||||
// onConfirm={handleConfirmDelete}
|
|
||||||
// title="Delete Role"
|
|
||||||
// message={`Are you sure you want to delete this role`}
|
|
||||||
// itemName={selectedRoleName}
|
|
||||||
// isLoading={isDeleting}
|
|
||||||
// />
|
|
||||||
// </Layout>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
// export default Roles;
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
@ -18,6 +19,7 @@ import { SmtpConfigModal } from "@/components/superadmin/SmtpConfigModal";
|
|||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
|
|
||||||
const SmtpConfigPage = () => {
|
const SmtpConfigPage = () => {
|
||||||
|
const { canCreate, canUpdate, canDelete } = usePermissions();
|
||||||
const [configs, setConfigs] = useState<SmtpConfig[]>([]);
|
const [configs, setConfigs] = useState<SmtpConfig[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@ -139,8 +141,8 @@ const SmtpConfigPage = () => {
|
|||||||
align: "right",
|
align: "right",
|
||||||
render: (config) => (
|
render: (config) => (
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
onEdit={() => handleEdit(config)}
|
onEdit={canUpdate("settings") ? () => handleEdit(config) : undefined}
|
||||||
onDelete={() => handleDelete(config)}
|
onDelete={canDelete("settings") ? () => handleDelete(config) : undefined}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -153,7 +155,7 @@ const SmtpConfigPage = () => {
|
|||||||
title: "SMTP Configurations",
|
title: "SMTP Configurations",
|
||||||
description:
|
description:
|
||||||
"Manage email delivery settings for the entire platform and individual tenants.",
|
"Manage email delivery settings for the entire platform and individual tenants.",
|
||||||
action: (
|
action: canCreate("settings") ? (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedConfig(null);
|
setSelectedConfig(null);
|
||||||
@ -163,7 +165,7 @@ const SmtpConfigPage = () => {
|
|||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Add Configuration
|
Add Configuration
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
),
|
) : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { Plus, ArrowUpDown } from "lucide-react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { tenantService } from "@/services/tenant-service";
|
import { tenantService } from "@/services/tenant-service";
|
||||||
import type { Tenant } from "@/types/tenant";
|
import type { Tenant } from "@/types/tenant";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
// Helper function to get tenant initials
|
// Helper function to get tenant initials
|
||||||
const getTenantInitials = (name: string): string => {
|
const getTenantInitials = (name: string): string => {
|
||||||
const words = name.trim().split(/\s+/);
|
const words = name.trim().split(/\s+/);
|
||||||
@ -60,6 +61,7 @@ const formatSubscriptionTier = (tier: string | null): string => {
|
|||||||
|
|
||||||
const Tenants = (): ReactElement => {
|
const Tenants = (): ReactElement => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { canCreate, canUpdate } = usePermissions();
|
||||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -254,11 +256,7 @@ const Tenants = (): ReactElement => {
|
|||||||
{
|
{
|
||||||
key: "user_count",
|
key: "user_count",
|
||||||
label: "Users",
|
label: "Users",
|
||||||
render: (tenant) => (
|
render: (tenant) => <span className="">{tenant.user_count ?? 0}</span>,
|
||||||
<span className="">
|
|
||||||
{tenant.user_count ?? 0}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "subscription_tier",
|
key: "subscription_tier",
|
||||||
@ -272,19 +270,13 @@ const Tenants = (): ReactElement => {
|
|||||||
{
|
{
|
||||||
key: "module_count",
|
key: "module_count",
|
||||||
label: "Modules",
|
label: "Modules",
|
||||||
render: (tenant) => (
|
render: (tenant) => <span className="">{tenant.module_count ?? 0}</span>,
|
||||||
<span className="">
|
|
||||||
{tenant.module_count ?? 0}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "created_at",
|
key: "created_at",
|
||||||
label: "Joined Date",
|
label: "Joined Date",
|
||||||
render: (tenant) => (
|
render: (tenant) => (
|
||||||
<span className="">
|
<span className="">{formatDate(tenant.created_at)}</span>
|
||||||
{formatDate(tenant.created_at)}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
mobileLabel: "Joined",
|
mobileLabel: "Joined",
|
||||||
},
|
},
|
||||||
@ -296,7 +288,9 @@ const Tenants = (): ReactElement => {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
onView={() => handleViewTenant(tenant.id)}
|
onView={() => handleViewTenant(tenant.id)}
|
||||||
onEdit={() => handleEditTenant(tenant.id)}
|
onEdit={
|
||||||
|
canUpdate("users") ? () => handleEditTenant(tenant.id) : undefined
|
||||||
|
}
|
||||||
// onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
|
// onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -445,6 +439,7 @@ const Tenants = (): ReactElement => {
|
|||||||
</PrimaryButton> */}
|
</PrimaryButton> */}
|
||||||
|
|
||||||
{/* Add Tenant Button (New Wizard) */}
|
{/* Add Tenant Button (New Wizard) */}
|
||||||
|
{canCreate("tenants") && (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
size="default"
|
size="default"
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
@ -453,6 +448,7 @@ const Tenants = (): ReactElement => {
|
|||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
<span className="text-xs">Add Tenant</span>
|
<span className="text-xs">Add Tenant</span>
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,476 +1,19 @@
|
|||||||
// import { useState, useEffect } from 'react';
|
import type { ReactElement } from "react";
|
||||||
// import type { ReactElement } from 'react';
|
import { Layout } from "@/components/layout/Layout";
|
||||||
// import { Layout } from '@/components/layout/Layout';
|
import { PlatformUsersTable } from "@/components/superadmin/PlatformUsersTable";
|
||||||
// import {
|
|
||||||
// PrimaryButton,
|
|
||||||
// StatusBadge,
|
|
||||||
// ActionDropdown,
|
|
||||||
// NewUserModal,
|
|
||||||
// ViewUserModal,
|
|
||||||
// EditUserModal,
|
|
||||||
// DeleteConfirmationModal,
|
|
||||||
// DataTable,
|
|
||||||
// Pagination,
|
|
||||||
// FilterDropdown,
|
|
||||||
// type Column,
|
|
||||||
// } from '@/components/shared';
|
|
||||||
// import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
|
||||||
// import { userService } from '@/services/user-service';
|
|
||||||
// import type { User } from '@/types/user';
|
|
||||||
// import { showToast } from '@/utils/toast';
|
|
||||||
|
|
||||||
// // Helper function to get user initials
|
const Users = (): ReactElement => {
|
||||||
// const getUserInitials = (firstName: string, lastName: string): string => {
|
return (
|
||||||
// return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
<Layout
|
||||||
// };
|
currentPage="Users"
|
||||||
|
pageHeader={{
|
||||||
|
title: "Platform Users",
|
||||||
|
description: "Manage system-wide administrative personnel and platform users.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlatformUsersTable />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// // Helper function to format date
|
export default Users;
|
||||||
// const formatDate = (dateString: string): string => {
|
|
||||||
// const date = new Date(dateString);
|
|
||||||
// return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Helper function to get status badge variant
|
|
||||||
// const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
|
|
||||||
// switch (status.toLowerCase()) {
|
|
||||||
// case 'active':
|
|
||||||
// return 'success';
|
|
||||||
// case 'pending_verification':
|
|
||||||
// return 'process';
|
|
||||||
// case 'inactive':
|
|
||||||
// return 'failure';
|
|
||||||
// case 'deleted':
|
|
||||||
// return 'failure';
|
|
||||||
// case 'suspended':
|
|
||||||
// return 'process';
|
|
||||||
// default:
|
|
||||||
// return 'success';
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const Users = (): ReactElement => {
|
|
||||||
// const [users, setUsers] = useState<User[]>([]);
|
|
||||||
// const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
||||||
// const [error, setError] = useState<string | null>(null);
|
|
||||||
// const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
|
||||||
// const [isCreating, setIsCreating] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// // Pagination state
|
|
||||||
// const [currentPage, setCurrentPage] = useState<number>(1);
|
|
||||||
// const [limit, setLimit] = useState<number>(5);
|
|
||||||
// const [pagination, setPagination] = useState<{
|
|
||||||
// page: number;
|
|
||||||
// limit: number;
|
|
||||||
// total: number;
|
|
||||||
// totalPages: number;
|
|
||||||
// hasMore: boolean;
|
|
||||||
// }>({
|
|
||||||
// page: 1,
|
|
||||||
// limit: 5,
|
|
||||||
// total: 0,
|
|
||||||
// totalPages: 1,
|
|
||||||
// hasMore: false,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// // Filter state
|
|
||||||
// const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
|
||||||
// const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
|
||||||
|
|
||||||
// // View, Edit, Delete modals
|
|
||||||
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
|
||||||
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
|
||||||
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
|
||||||
// const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
|
||||||
// const [selectedUserName, setSelectedUserName] = useState<string>('');
|
|
||||||
// const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
|
||||||
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// const fetchUsers = async (
|
|
||||||
// page: number,
|
|
||||||
// itemsPerPage: number,
|
|
||||||
// status: string | null = null,
|
|
||||||
// sortBy: string[] | null = null
|
|
||||||
// ): Promise<void> => {
|
|
||||||
// try {
|
|
||||||
// setIsLoading(true);
|
|
||||||
// setError(null);
|
|
||||||
// const response = await userService.getAll(page, itemsPerPage, status, sortBy);
|
|
||||||
// if (response.success) {
|
|
||||||
// setUsers(response.data);
|
|
||||||
// setPagination(response.pagination);
|
|
||||||
// } else {
|
|
||||||
// setError('Failed to load users');
|
|
||||||
// }
|
|
||||||
// } catch (err: any) {
|
|
||||||
// setError(err?.response?.data?.error?.message || 'Failed to load users');
|
|
||||||
// } finally {
|
|
||||||
// setIsLoading(false);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// fetchUsers(currentPage, limit, statusFilter, orderBy);
|
|
||||||
// }, [currentPage, limit, statusFilter, orderBy]);
|
|
||||||
|
|
||||||
// const handleCreateUser = async (data: {
|
|
||||||
// email: string;
|
|
||||||
// password: string;
|
|
||||||
// first_name: string;
|
|
||||||
// last_name: string;
|
|
||||||
// status: 'active' | 'suspended' | 'deleted';
|
|
||||||
// auth_provider: 'local';
|
|
||||||
// role_id: string;
|
|
||||||
// }): Promise<void> => {
|
|
||||||
// try {
|
|
||||||
// setIsCreating(true);
|
|
||||||
// const response = await userService.create(data);
|
|
||||||
// const message = response.message || `User created successfully`;
|
|
||||||
// const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been added`;
|
|
||||||
// showToast.success(message, description);
|
|
||||||
// setIsModalOpen(false);
|
|
||||||
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
|
||||||
// } catch (err: any) {
|
|
||||||
// throw err;
|
|
||||||
// } finally {
|
|
||||||
// setIsCreating(false);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // View user handler
|
|
||||||
// const handleViewUser = (userId: string): void => {
|
|
||||||
// setSelectedUserId(userId);
|
|
||||||
// setViewModalOpen(true);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Edit user handler
|
|
||||||
// const handleEditUser = (userId: string, userName: string): void => {
|
|
||||||
// setSelectedUserId(userId);
|
|
||||||
// setSelectedUserName(userName);
|
|
||||||
// setEditModalOpen(true);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Update user handler
|
|
||||||
// const handleUpdateUser = async (
|
|
||||||
// id: string,
|
|
||||||
// data: {
|
|
||||||
// email: string;
|
|
||||||
// first_name: string;
|
|
||||||
// last_name: string;
|
|
||||||
// status: 'active' | 'suspended' | 'deleted';
|
|
||||||
// tenant_id: string;
|
|
||||||
// role_id: string;
|
|
||||||
// }
|
|
||||||
// ): Promise<void> => {
|
|
||||||
// try {
|
|
||||||
// setIsUpdating(true);
|
|
||||||
// const response = await userService.update(id, data);
|
|
||||||
// const message = response.message || `User updated successfully`;
|
|
||||||
// const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been updated`;
|
|
||||||
// showToast.success(message, description);
|
|
||||||
// setEditModalOpen(false);
|
|
||||||
// setSelectedUserId(null);
|
|
||||||
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
|
||||||
// } catch (err: any) {
|
|
||||||
// throw err;
|
|
||||||
// } finally {
|
|
||||||
// setIsUpdating(false);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Delete user handler
|
|
||||||
// const handleDeleteUser = (userId: string, userName: string): void => {
|
|
||||||
// setSelectedUserId(userId);
|
|
||||||
// setSelectedUserName(userName);
|
|
||||||
// setDeleteModalOpen(true);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Confirm delete handler
|
|
||||||
// const handleConfirmDelete = async (): Promise<void> => {
|
|
||||||
// if (!selectedUserId) return;
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// setIsDeleting(true);
|
|
||||||
// await userService.delete(selectedUserId);
|
|
||||||
// setDeleteModalOpen(false);
|
|
||||||
// setSelectedUserId(null);
|
|
||||||
// setSelectedUserName('');
|
|
||||||
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
|
||||||
// } catch (err: any) {
|
|
||||||
// throw err;
|
|
||||||
// } finally {
|
|
||||||
// setIsDeleting(false);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Load user for view/edit
|
|
||||||
// const loadUser = async (id: string): Promise<User> => {
|
|
||||||
// const response = await userService.getById(id);
|
|
||||||
// return response.data;
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Define table columns
|
|
||||||
// const columns: Column<User>[] = [
|
|
||||||
// {
|
|
||||||
// key: 'name',
|
|
||||||
// label: 'User Name',
|
|
||||||
// render: (user) => (
|
|
||||||
// <div className="flex items-center gap-3">
|
|
||||||
// <div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
|
||||||
// <span className="text-xs font-normal text-[#9aa6b2]">
|
|
||||||
// {getUserInitials(user.first_name, user.last_name)}
|
|
||||||
// </span>
|
|
||||||
// </div>
|
|
||||||
// <span className="text-sm font-normal text-[#0f1724]">
|
|
||||||
// {user.first_name} {user.last_name}
|
|
||||||
// </span>
|
|
||||||
// </div>
|
|
||||||
// ),
|
|
||||||
// mobileLabel: 'Name',
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'email',
|
|
||||||
// label: 'Email',
|
|
||||||
// render: (user) => <span className="text-sm font-normal text-[#0f1724]">{user.email}</span>,
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'status',
|
|
||||||
// label: 'Status',
|
|
||||||
// render: (user) => (
|
|
||||||
// <StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'auth_provider',
|
|
||||||
// label: 'Auth Provider',
|
|
||||||
// render: (user) => (
|
|
||||||
// <span className="text-sm font-normal text-[#0f1724]">{user.auth_provider}</span>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'created_at',
|
|
||||||
// label: 'Joined Date',
|
|
||||||
// render: (user) => (
|
|
||||||
// <span className="text-sm font-normal text-[#6b7280]">{formatDate(user.created_at)}</span>
|
|
||||||
// ),
|
|
||||||
// mobileLabel: 'Joined',
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'actions',
|
|
||||||
// label: 'Actions',
|
|
||||||
// align: 'right',
|
|
||||||
// render: (user) => (
|
|
||||||
// <div className="flex justify-end">
|
|
||||||
// <ActionDropdown
|
|
||||||
// onView={() => handleViewUser(user.id)}
|
|
||||||
// onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
|
||||||
// onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
|
||||||
// />
|
|
||||||
// </div>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// ];
|
|
||||||
|
|
||||||
// // Mobile card renderer
|
|
||||||
// const mobileCardRenderer = (user: User) => (
|
|
||||||
// <div className="p-4">
|
|
||||||
// <div className="flex items-start justify-between gap-3 mb-3">
|
|
||||||
// <div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
// <div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
|
||||||
// <span className="text-xs font-normal text-[#9aa6b2]">
|
|
||||||
// {getUserInitials(user.first_name, user.last_name)}
|
|
||||||
// </span>
|
|
||||||
// </div>
|
|
||||||
// <div className="flex-1 min-w-0">
|
|
||||||
// <h3 className="text-sm font-medium text-[#0f1724] truncate">
|
|
||||||
// {user.first_name} {user.last_name}
|
|
||||||
// </h3>
|
|
||||||
// <p className="text-xs text-[#6b7280] mt-0.5 truncate">{user.email}</p>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// <ActionDropdown
|
|
||||||
// onView={() => handleViewUser(user.id)}
|
|
||||||
// onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
|
||||||
// onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
|
||||||
// />
|
|
||||||
// </div>
|
|
||||||
// <div className="grid grid-cols-2 gap-3 text-xs">
|
|
||||||
// <div>
|
|
||||||
// <span className="text-[#9aa6b2]">Status:</span>
|
|
||||||
// <div className="mt-1">
|
|
||||||
// <StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// <div>
|
|
||||||
// <span className="text-[#9aa6b2]">Auth Provider:</span>
|
|
||||||
// <p className="text-[#0f1724] font-normal mt-1">{user.auth_provider}</p>
|
|
||||||
// </div>
|
|
||||||
// <div>
|
|
||||||
// <span className="text-[#9aa6b2]">Joined:</span>
|
|
||||||
// <p className="text-[#6b7280] font-normal mt-1">{formatDate(user.created_at)}</p>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <Layout
|
|
||||||
// currentPage="Users"
|
|
||||||
// pageHeader={{
|
|
||||||
// title: 'User List',
|
|
||||||
// description: 'View and manage all users in your QAssure platform from a single place.',
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
// {/* Table Container */}
|
|
||||||
// <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
|
||||||
// {/* Table Header with Filters */}
|
|
||||||
// <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
|
||||||
// {/* Filters */}
|
|
||||||
// <div className="flex flex-wrap items-center gap-3">
|
|
||||||
// {/* Status Filter */}
|
|
||||||
// <FilterDropdown
|
|
||||||
// label="Status"
|
|
||||||
// options={[
|
|
||||||
// { value: 'active', label: 'Active' },
|
|
||||||
// { value: 'pending_verification', label: 'Pending Verification' },
|
|
||||||
// { value: 'inactive', label: 'Inactive' },
|
|
||||||
// { value: 'suspended', label: 'Suspended' },
|
|
||||||
// { value: 'deleted', label: 'Deleted' },
|
|
||||||
// ]}
|
|
||||||
// value={statusFilter}
|
|
||||||
// onChange={(value) => {
|
|
||||||
// setStatusFilter(value as string | null);
|
|
||||||
// setCurrentPage(1); // Reset to first page when filter changes
|
|
||||||
// }}
|
|
||||||
// placeholder="All"
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* Sort Filter */}
|
|
||||||
// <FilterDropdown
|
|
||||||
// label="Sort by"
|
|
||||||
// options={[
|
|
||||||
// { value: ['first_name', 'asc'], label: 'First Name (A-Z)' },
|
|
||||||
// { value: ['first_name', 'desc'], label: 'First Name (Z-A)' },
|
|
||||||
// { value: ['last_name', 'asc'], label: 'Last Name (A-Z)' },
|
|
||||||
// { value: ['last_name', 'desc'], label: 'Last Name (Z-A)' },
|
|
||||||
// { value: ['email', 'asc'], label: 'Email (A-Z)' },
|
|
||||||
// { value: ['email', 'desc'], label: 'Email (Z-A)' },
|
|
||||||
// { value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
|
||||||
// { value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
|
||||||
// { value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
|
||||||
// { value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
|
||||||
// ]}
|
|
||||||
// value={orderBy}
|
|
||||||
// onChange={(value) => {
|
|
||||||
// setOrderBy(value as string[] | null);
|
|
||||||
// setCurrentPage(1); // Reset to first page when sort changes
|
|
||||||
// }}
|
|
||||||
// placeholder="Default"
|
|
||||||
// showIcon
|
|
||||||
// icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
|
||||||
// />
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {/* Actions */}
|
|
||||||
// <div className="flex items-center gap-2">
|
|
||||||
// {/* Export Button */}
|
|
||||||
// <button
|
|
||||||
// type="button"
|
|
||||||
// className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
|
||||||
// >
|
|
||||||
// <Download className="w-3.5 h-3.5" />
|
|
||||||
// <span>Export</span>
|
|
||||||
// </button>
|
|
||||||
|
|
||||||
// {/* New User Button */}
|
|
||||||
// <PrimaryButton
|
|
||||||
// size="default"
|
|
||||||
// className="flex items-center gap-2"
|
|
||||||
// onClick={() => setIsModalOpen(true)}
|
|
||||||
// >
|
|
||||||
// <Plus className="w-3.5 h-3.5" />
|
|
||||||
// <span className="text-xs">New User</span>
|
|
||||||
// </PrimaryButton>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {/* Data Table */}
|
|
||||||
// <DataTable
|
|
||||||
// data={users}
|
|
||||||
// columns={columns}
|
|
||||||
// keyExtractor={(user) => user.id}
|
|
||||||
// mobileCardRenderer={mobileCardRenderer}
|
|
||||||
// emptyMessage="No users found"
|
|
||||||
// isLoading={isLoading}
|
|
||||||
// error={error}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* Table Footer with Pagination */}
|
|
||||||
// {pagination.total > 0 && (
|
|
||||||
// <Pagination
|
|
||||||
// currentPage={currentPage}
|
|
||||||
// totalPages={pagination.totalPages}
|
|
||||||
// totalItems={pagination.total}
|
|
||||||
// limit={limit}
|
|
||||||
// onPageChange={(page: number) => {
|
|
||||||
// setCurrentPage(page);
|
|
||||||
// }}
|
|
||||||
// onLimitChange={(newLimit: number) => {
|
|
||||||
// setLimit(newLimit);
|
|
||||||
// setCurrentPage(1); // Reset to first page when limit changes
|
|
||||||
// }}
|
|
||||||
// />
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {/* New User Modal */}
|
|
||||||
// <NewUserModal
|
|
||||||
// isOpen={isModalOpen}
|
|
||||||
// onClose={() => setIsModalOpen(false)}
|
|
||||||
// onSubmit={handleCreateUser}
|
|
||||||
// isLoading={isCreating}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* View User Modal */}
|
|
||||||
// <ViewUserModal
|
|
||||||
// isOpen={viewModalOpen}
|
|
||||||
// onClose={() => {
|
|
||||||
// setViewModalOpen(false);
|
|
||||||
// setSelectedUserId(null);
|
|
||||||
// }}
|
|
||||||
// userId={selectedUserId}
|
|
||||||
// onLoadUser={loadUser}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* Edit User Modal */}
|
|
||||||
// <EditUserModal
|
|
||||||
// isOpen={editModalOpen}
|
|
||||||
// onClose={() => {
|
|
||||||
// setEditModalOpen(false);
|
|
||||||
// setSelectedUserId(null);
|
|
||||||
// setSelectedUserName('');
|
|
||||||
// }}
|
|
||||||
// userId={selectedUserId}
|
|
||||||
// onLoadUser={loadUser}
|
|
||||||
// onSubmit={handleUpdateUser}
|
|
||||||
// isLoading={isUpdating}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* Delete Confirmation Modal */}
|
|
||||||
// <DeleteConfirmationModal
|
|
||||||
// isOpen={deleteModalOpen}
|
|
||||||
// onClose={() => {
|
|
||||||
// setDeleteModalOpen(false);
|
|
||||||
// setSelectedUserId(null);
|
|
||||||
// setSelectedUserName('');
|
|
||||||
// }}
|
|
||||||
// onConfirm={handleConfirmDelete}
|
|
||||||
// title="Delete User"
|
|
||||||
// message="Are you sure you want to delete this user"
|
|
||||||
// itemName={selectedUserName}
|
|
||||||
// isLoading={isDeleting}
|
|
||||||
// />
|
|
||||||
// </Layout>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
// export default Users;
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ interface TenantProtectedRouteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TenantProtectedRoute = ({ children }: TenantProtectedRouteProps): ReactElement => {
|
const TenantProtectedRoute = ({ children }: TenantProtectedRouteProps): ReactElement => {
|
||||||
const { isAuthenticated, roles } = useAppSelector((state) => state.auth);
|
const { isAuthenticated, roles, tenantId } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
// Fetch and apply tenant theme
|
// Fetch and apply tenant theme
|
||||||
useTenantTheme();
|
useTenantTheme();
|
||||||
@ -17,7 +17,7 @@ const TenantProtectedRoute = ({ children }: TenantProtectedRouteProps): ReactEle
|
|||||||
return <Navigate to="/tenant/login" replace />;
|
return <Navigate to="/tenant/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has super_admin role - if yes, redirect to super admin dashboard
|
// Check if user has super_admin role or is a platform user - if yes, redirect to super admin dashboard
|
||||||
// Handle both array and JSON string formats
|
// Handle both array and JSON string formats
|
||||||
let rolesArray: string[] = [];
|
let rolesArray: string[] = [];
|
||||||
if (Array.isArray(roles)) {
|
if (Array.isArray(roles)) {
|
||||||
@ -30,9 +30,10 @@ const TenantProtectedRoute = ({ children }: TenantProtectedRouteProps): ReactEle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hasSuperAdminRole = rolesArray.includes('super_admin');
|
const hasSuperAdminRole = rolesArray.includes('super_admin');
|
||||||
|
const isPlatformUser = hasSuperAdminRole || tenantId === '00000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
if (hasSuperAdminRole) {
|
if (isPlatformUser) {
|
||||||
// If super_admin, redirect to super admin dashboard
|
// If platform user or super_admin, redirect to super admin dashboard
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,8 @@ const NotificationTemplateMaster = lazy(() => import("@/pages/superadmin/Notific
|
|||||||
const SmtpConfig = lazy(() => import("@/pages/superadmin/SmtpConfig"));
|
const SmtpConfig = lazy(() => import("@/pages/superadmin/SmtpConfig"));
|
||||||
const FailedEmails = lazy(() => import("@/pages/superadmin/FailedEmails"));
|
const FailedEmails = lazy(() => import("@/pages/superadmin/FailedEmails"));
|
||||||
const AIFallbackHistory = lazy(() => import("@/pages/superadmin/AIFallbackHistory"));
|
const AIFallbackHistory = lazy(() => import("@/pages/superadmin/AIFallbackHistory"));
|
||||||
|
const PlatformUsers = lazy(() => import("@/pages/superadmin/Users"));
|
||||||
|
const PlatformRoles = lazy(() => import("@/pages/superadmin/Roles"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -50,6 +52,14 @@ export const superAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
element: <LazyRoute component={Dashboard} />,
|
element: <LazyRoute component={Dashboard} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/platform-users",
|
||||||
|
element: <LazyRoute component={PlatformUsers} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/platform-roles",
|
||||||
|
element: <LazyRoute component={PlatformRoles} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/tenants",
|
path: "/tenants",
|
||||||
element: <LazyRoute component={Tenants} />,
|
element: <LazyRoute component={Tenants} />,
|
||||||
|
|||||||
@ -14,7 +14,8 @@ export const roleService = {
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
orderBy?: string[] | null,
|
orderBy?: string[] | null,
|
||||||
search?: string | null
|
search?: string | null,
|
||||||
|
scope?: string | null
|
||||||
): Promise<RolesResponse> => {
|
): Promise<RolesResponse> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('page', String(page));
|
params.append('page', String(page));
|
||||||
@ -22,6 +23,9 @@ export const roleService = {
|
|||||||
if (search) {
|
if (search) {
|
||||||
params.append('search', search);
|
params.append('search', search);
|
||||||
}
|
}
|
||||||
|
if (scope) {
|
||||||
|
params.append('scope', scope);
|
||||||
|
}
|
||||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||||
params.append('orderBy[]', orderBy[0]);
|
params.append('orderBy[]', orderBy[0]);
|
||||||
params.append('orderBy[]', orderBy[1]);
|
params.append('orderBy[]', orderBy[1]);
|
||||||
|
|||||||
@ -16,7 +16,8 @@ const getAllUsers = async (
|
|||||||
orderBy?: string[] | null,
|
orderBy?: string[] | null,
|
||||||
tenantId?: string | null,
|
tenantId?: string | null,
|
||||||
search?: string | null,
|
search?: string | null,
|
||||||
roleId?: string | null
|
roleId?: string | null,
|
||||||
|
scope?: string | null
|
||||||
): Promise<UsersResponse> => {
|
): Promise<UsersResponse> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('page', String(page));
|
params.append('page', String(page));
|
||||||
@ -33,6 +34,9 @@ const getAllUsers = async (
|
|||||||
if (roleId) {
|
if (roleId) {
|
||||||
params.append('role_id', roleId);
|
params.append('role_id', roleId);
|
||||||
}
|
}
|
||||||
|
if (scope) {
|
||||||
|
params.append('scope', scope);
|
||||||
|
}
|
||||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||||
// Send array as orderBy[]=field&orderBy[]=direction
|
// Send array as orderBy[]=field&orderBy[]=direction
|
||||||
params.append('orderBy[]', orderBy[0]);
|
params.append('orderBy[]', orderBy[0]);
|
||||||
@ -49,8 +53,9 @@ export const userService = {
|
|||||||
status?: string | null,
|
status?: string | null,
|
||||||
orderBy?: string[] | null,
|
orderBy?: string[] | null,
|
||||||
search?: string | null,
|
search?: string | null,
|
||||||
roleId?: string | null
|
roleId?: string | null,
|
||||||
) => getAllUsers(page, limit, status, orderBy, null, search, roleId),
|
scope?: string | null
|
||||||
|
) => getAllUsers(page, limit, status, orderBy, null, search, roleId, scope),
|
||||||
|
|
||||||
create: async (data: CreateUserRequest): Promise<CreateUserResponse> => {
|
create: async (data: CreateUserRequest): Promise<CreateUserResponse> => {
|
||||||
const response = await apiClient.post<CreateUserResponse>('/users', data);
|
const response = await apiClient.post<CreateUserResponse>('/users', data);
|
||||||
|
|||||||
@ -77,8 +77,8 @@ export interface CreateUserRequest {
|
|||||||
role_id?: string;
|
role_id?: string;
|
||||||
role_ids?: string[];
|
role_ids?: string[];
|
||||||
role_module_combinations?: RoleModuleCombination[];
|
role_module_combinations?: RoleModuleCombination[];
|
||||||
department_id?: string;
|
department_id?: string | null;
|
||||||
designation_id?: string;
|
designation_id?: string | null;
|
||||||
module_ids?: string[];
|
module_ids?: string[];
|
||||||
category?: 'tenant_user' | 'supplier_user';
|
category?: 'tenant_user' | 'supplier_user';
|
||||||
supplier_id?: string | null;
|
supplier_id?: string | null;
|
||||||
@ -105,8 +105,8 @@ export interface UpdateUserRequest {
|
|||||||
role_id?: string;
|
role_id?: string;
|
||||||
role_ids?: string[];
|
role_ids?: string[];
|
||||||
role_module_combinations?: RoleModuleCombination[];
|
role_module_combinations?: RoleModuleCombination[];
|
||||||
department_id?: string;
|
department_id?: string | null;
|
||||||
designation_id?: string;
|
designation_id?: string | null;
|
||||||
module_ids?: string[];
|
module_ids?: string[];
|
||||||
category?: 'tenant_user' | 'supplier_user';
|
category?: 'tenant_user' | 'supplier_user';
|
||||||
supplier_id?: string | null;
|
supplier_id?: string | null;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user