Refactor routing and role management in various components to streamline tenant and super admin navigation. Update Sidebar and Header to conditionally render menu items based on user roles. Enhance theme integration for tenant admins and adjust navigation paths in Login and Roles components. Remove TenantLogin and TenantProtectedRoute components for cleaner structure.
This commit is contained in:
parent
c6ee8c7032
commit
bb5a086110
17
src/components/NavigationInitializer.tsx
Normal file
17
src/components/NavigationInitializer.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { setNavigate } from '@/utils/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to initialize navigation utility for use in services/interceptors
|
||||||
|
* This should be rendered once at the app level
|
||||||
|
*/
|
||||||
|
export const NavigationInitializer = (): null => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNavigate(navigate);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@ -69,14 +69,18 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
|||||||
// Close dropdown immediately
|
// Close dropdown immediately
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
|
|
||||||
|
// Check if user is on a tenant route to determine redirect path
|
||||||
|
const isTenantRoute = window.location.pathname.startsWith('/tenant');
|
||||||
|
const redirectPath = isTenantRoute ? '/tenant/login' : '/';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call logout API with Bearer token
|
// Call logout API with Bearer token
|
||||||
const result = await dispatch(logoutAsync()).unwrap();
|
const result = await dispatch(logoutAsync()).unwrap();
|
||||||
const message = result.message || 'Logged out successfully';
|
const message = result.message || 'Logged out successfully';
|
||||||
const description = result.message ? undefined : 'You have been logged out';
|
const description = result.message ? undefined : 'You have been logged out';
|
||||||
showToast.success(message, description);
|
showToast.success(message, description);
|
||||||
// Clear state and redirect
|
// Clear state and redirect to appropriate login page
|
||||||
navigate('/', { replace: true });
|
navigate(redirectPath, { replace: true });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Even if API call fails, clear local state and redirect to login
|
// Even if API call fails, clear local state and redirect to login
|
||||||
console.error('Logout error:', error);
|
console.error('Logout error:', error);
|
||||||
@ -86,7 +90,7 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
|||||||
// Dispatch logout action to clear local state
|
// Dispatch logout action to clear local state
|
||||||
dispatch({ type: 'auth/logout' });
|
dispatch({ type: 'auth/logout' });
|
||||||
showToast.success(message, description);
|
showToast.success(message, description);
|
||||||
navigate('/', { replace: true });
|
navigate(redirectPath, { replace: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -29,10 +29,10 @@ export const Layout = ({ children, currentPage, breadcrumbs, pageHeader }: Layou
|
|||||||
return (
|
return (
|
||||||
<div className="relative w-full h-screen overflow-hidden bg-[#f6f9ff]">
|
<div className="relative w-full h-screen overflow-hidden bg-[#f6f9ff]">
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<div className="absolute top-0 left-[-80px] w-full md:left-[-80px] md:w-[1440px] h-full md:h-[1006px] md:min-h-[812px] bg-[#f6f9ff] z-0" />
|
<div className="absolute top-0 left-[-80px] w-full md:left-[-80px] md:w-[1440px] h-full bg-[#f6f9ff] z-0" />
|
||||||
|
|
||||||
{/* Content Wrapper */}
|
{/* Content Wrapper */}
|
||||||
<div className="absolute inset-0 flex gap-0 md:gap-3 p-0 md:p-3 max-w-full md:max-w-[1280px] lg:max-w-none min-h-screen md:min-h-[812px] mx-auto lg:mx-0 z-10">
|
<div className="absolute inset-0 flex gap-0 md:gap-3 p-0 md:p-3 max-w-full md:max-w-[1280px] lg:max-w-none h-full mx-auto lg:mx-0 z-10">
|
||||||
{/* Mobile Overlay */}
|
{/* Mobile Overlay */}
|
||||||
{isSidebarOpen && (
|
{isSidebarOpen && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import {
|
|||||||
Shield
|
Shield
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||||
|
import { useTenantTheme } from '@/hooks/useTenantTheme';
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
@ -23,7 +25,8 @@ interface SidebarProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformMenu: MenuItem[] = [
|
// Super Admin menu items
|
||||||
|
const superAdminPlatformMenu: MenuItem[] = [
|
||||||
{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' },
|
{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' },
|
||||||
{ icon: Building2, label: 'Tenants', path: '/tenants' },
|
{ icon: Building2, label: 'Tenants', path: '/tenants' },
|
||||||
{ icon: Users, label: 'User Management', path: '/users' },
|
{ icon: Users, label: 'User Management', path: '/users' },
|
||||||
@ -31,13 +34,54 @@ const platformMenu: MenuItem[] = [
|
|||||||
{ icon: Package, label: 'Modules', path: '/modules' },
|
{ icon: Package, label: 'Modules', path: '/modules' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const systemMenu: MenuItem[] = [
|
const superAdminSystemMenu: MenuItem[] = [
|
||||||
{ icon: FileText, label: 'Audit Logs', path: '/audit-logs' },
|
{ icon: FileText, label: 'Audit Logs', path: '/audit-logs' },
|
||||||
{ icon: Settings, label: 'Settings', path: '/settings' },
|
{ icon: Settings, label: 'Settings', path: '/settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Tenant Admin menu items
|
||||||
|
const tenantAdminPlatformMenu: MenuItem[] = [
|
||||||
|
{ icon: LayoutDashboard, label: 'Dashboard', path: '/tenant' },
|
||||||
|
{ icon: Shield, label: 'Roles', path: '/tenant/roles' },
|
||||||
|
{ icon: Users, label: 'Users', path: '/tenant/users' },
|
||||||
|
{ icon: Package, label: 'Modules', path: '/tenant/modules' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tenantAdminSystemMenu: MenuItem[] = [
|
||||||
|
{ icon: FileText, label: 'Audit Logs', path: '/tenant/audit-logs' },
|
||||||
|
{ icon: Settings, label: 'Settings', path: '/tenant/settings' },
|
||||||
|
];
|
||||||
|
|
||||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { roles } = useAppSelector((state) => state.auth);
|
||||||
|
const { theme, logoUrl } = useAppSelector((state) => state.theme);
|
||||||
|
|
||||||
|
// Fetch theme for tenant admin
|
||||||
|
const isSuperAdminCheck = () => {
|
||||||
|
let rolesArray: string[] = [];
|
||||||
|
if (Array.isArray(roles)) {
|
||||||
|
rolesArray = roles;
|
||||||
|
} else if (typeof roles === 'string') {
|
||||||
|
try {
|
||||||
|
rolesArray = JSON.parse(roles);
|
||||||
|
} catch {
|
||||||
|
rolesArray = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rolesArray.includes('super_admin');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSuperAdmin = isSuperAdminCheck();
|
||||||
|
|
||||||
|
// Fetch theme if tenant admin
|
||||||
|
if (!isSuperAdmin) {
|
||||||
|
useTenantTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select menu items based on role
|
||||||
|
const platformMenu = isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu;
|
||||||
|
const systemMenu = isSuperAdmin ? superAdminSystemMenu : tenantAdminSystemMenu;
|
||||||
|
|
||||||
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
|
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
|
||||||
<div className="w-full md:w-[206px]">
|
<div className="w-full md:w-[206px]">
|
||||||
@ -64,9 +108,17 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2.5 px-3 py-2 rounded-md transition-colors min-h-[44px]',
|
'flex items-center gap-2.5 px-3 py-2 rounded-md transition-colors min-h-[44px]',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-[#112868] text-[#23dce1] shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]'
|
? 'shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]'
|
||||||
: 'text-[#0f1724] hover:bg-gray-50'
|
: 'text-[#0f1724] hover:bg-gray-50'
|
||||||
)}
|
)}
|
||||||
|
style={
|
||||||
|
isActive
|
||||||
|
? {
|
||||||
|
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868',
|
||||||
|
color: !isSuperAdmin && theme?.secondary_color ? theme.secondary_color : '#23dce1',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 shrink-0" />
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
<span className="text-[13px] font-medium whitespace-nowrap">{item.label}</span>
|
<span className="text-[13px] font-medium whitespace-nowrap">{item.label}</span>
|
||||||
@ -91,11 +143,29 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
{/* Mobile Header with Close Button */}
|
{/* Mobile Header with Close Button */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<div className="w-9 h-9 bg-[#112868] rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0">
|
{!isSuperAdmin && logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt="Logo"
|
||||||
|
className="h-9 w-auto max-w-[180px] object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
|
||||||
|
if (fallback) fallback.style.display = 'flex';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0"
|
||||||
|
style={{
|
||||||
|
display: (!isSuperAdmin && logoUrl) ? 'none' : 'flex',
|
||||||
|
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
|
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
|
||||||
QAssure
|
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -123,15 +193,33 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Desktop Sidebar */}
|
{/* Desktop Sidebar */}
|
||||||
<aside className="hidden md:flex bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-[17px] w-[240px] h-full flex-col gap-6 shrink-0">
|
<aside className="hidden md:flex bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-[17px] w-[240px] h-full max-h-screen flex-col gap-6 shrink-0 overflow-hidden">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="w-[206px]">
|
<div className="w-[206px] shrink-0">
|
||||||
<div className="flex gap-3 items-center px-2">
|
<div className="flex gap-3 items-center px-2">
|
||||||
<div className="w-9 h-9 bg-[#112868] rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0">
|
{!isSuperAdmin && logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt="Logo"
|
||||||
|
className="h-9 w-auto max-w-[180px] object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
|
||||||
|
if (fallback) fallback.style.display = 'flex';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0"
|
||||||
|
style={{
|
||||||
|
display: (!isSuperAdmin && logoUrl) ? 'none' : 'flex',
|
||||||
|
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
|
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
|
||||||
QAssure
|
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -774,7 +774,7 @@ export const EditTenantModal = ({
|
|||||||
setIsUploadingLogo(true);
|
setIsUploadingLogo(true);
|
||||||
try {
|
try {
|
||||||
const response = await fileService.uploadSimple(file);
|
const response = await fileService.uploadSimple(file);
|
||||||
setLogoFilePath(response.data.file_path);
|
setLogoFilePath(response.data.file_url);
|
||||||
setLogoFileUrl(response.data.file_url);
|
setLogoFileUrl(response.data.file_url);
|
||||||
showToast.success('Logo uploaded successfully');
|
showToast.success('Logo uploaded successfully');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -880,7 +880,7 @@ export const EditTenantModal = ({
|
|||||||
setIsUploadingFavicon(true);
|
setIsUploadingFavicon(true);
|
||||||
try {
|
try {
|
||||||
const response = await fileService.uploadSimple(file);
|
const response = await fileService.uploadSimple(file);
|
||||||
setFaviconFilePath(response.data.file_path);
|
setFaviconFilePath(response.data.file_url);
|
||||||
setFaviconFileUrl(response.data.file_url);
|
setFaviconFileUrl(response.data.file_url);
|
||||||
showToast.success('Favicon uploaded successfully');
|
showToast.success('Favicon uploaded successfully');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
} from '@/components/shared';
|
} from '@/components/shared';
|
||||||
import { tenantService } from '@/services/tenant-service';
|
|
||||||
import { roleService } from '@/services/role-service';
|
import { roleService } from '@/services/role-service';
|
||||||
|
|
||||||
// Validation schema
|
// Validation schema
|
||||||
@ -28,7 +27,6 @@ const newUserSchema = z
|
|||||||
auth_provider: z.enum(['local'], {
|
auth_provider: z.enum(['local'], {
|
||||||
message: 'Auth provider is required',
|
message: 'Auth provider is required',
|
||||||
}),
|
}),
|
||||||
tenant_id: z.string().min(1, 'Tenant is required'),
|
|
||||||
role_id: z.string().min(1, 'Role is required'),
|
role_id: z.string().min(1, 'Role is required'),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
@ -43,7 +41,7 @@ interface NewUserModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: Omit<NewUserFormData, 'confirmPassword'>) => Promise<void>;
|
onSubmit: (data: Omit<NewUserFormData, 'confirmPassword'>) => Promise<void>;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
defaultTenantId?: string; // If provided, automatically set tenant_id and hide tenant field
|
defaultTenantId?: string; // If provided, filter roles by tenant_id
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
@ -73,22 +71,13 @@ export const NewUserModal = ({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
auth_provider: 'local',
|
auth_provider: 'local',
|
||||||
tenant_id: '',
|
|
||||||
role_id: '',
|
role_id: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusValue = watch('status');
|
const statusValue = watch('status');
|
||||||
const tenantIdValue = watch('tenant_id');
|
|
||||||
const roleIdValue = watch('role_id');
|
const roleIdValue = watch('role_id');
|
||||||
|
|
||||||
// Set default tenant_id when modal opens or defaultTenantId changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && defaultTenantId) {
|
|
||||||
setValue('tenant_id', defaultTenantId, { shouldValidate: true });
|
|
||||||
}
|
|
||||||
}, [isOpen, defaultTenantId, setValue]);
|
|
||||||
|
|
||||||
// Reset form when modal closes
|
// Reset form when modal closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@ -100,24 +89,11 @@ export const NewUserModal = ({
|
|||||||
last_name: '',
|
last_name: '',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
auth_provider: 'local',
|
auth_provider: 'local',
|
||||||
tenant_id: defaultTenantId || '',
|
|
||||||
role_id: '',
|
role_id: '',
|
||||||
});
|
});
|
||||||
clearErrors();
|
clearErrors();
|
||||||
}
|
}
|
||||||
}, [isOpen, reset, clearErrors, defaultTenantId]);
|
}, [isOpen, reset, clearErrors]);
|
||||||
|
|
||||||
// Load tenants for dropdown
|
|
||||||
const loadTenants = async (page: number, limit: number) => {
|
|
||||||
const response = await tenantService.getAll(page, limit);
|
|
||||||
return {
|
|
||||||
options: response.data.map((tenant) => ({
|
|
||||||
value: tenant.id,
|
|
||||||
label: tenant.name,
|
|
||||||
})),
|
|
||||||
pagination: response.pagination,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load roles for dropdown
|
// Load roles for dropdown
|
||||||
const loadRoles = async (page: number, limit: number) => {
|
const loadRoles = async (page: number, limit: number) => {
|
||||||
@ -138,10 +114,6 @@ export const NewUserModal = ({
|
|||||||
clearErrors();
|
clearErrors();
|
||||||
try {
|
try {
|
||||||
const { confirmPassword, ...submitData } = data;
|
const { confirmPassword, ...submitData } = data;
|
||||||
// Ensure tenant_id is set from defaultTenantId if provided
|
|
||||||
if (defaultTenantId) {
|
|
||||||
submitData.tenant_id = defaultTenantId;
|
|
||||||
}
|
|
||||||
await onSubmit(submitData);
|
await onSubmit(submitData);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Handle validation errors from API
|
// Handle validation errors from API
|
||||||
@ -155,7 +127,6 @@ export const NewUserModal = ({
|
|||||||
detail.path === 'last_name' ||
|
detail.path === 'last_name' ||
|
||||||
detail.path === 'status' ||
|
detail.path === 'status' ||
|
||||||
detail.path === 'auth_provider' ||
|
detail.path === 'auth_provider' ||
|
||||||
detail.path === 'tenant_id' ||
|
|
||||||
detail.path === 'role_id'
|
detail.path === 'role_id'
|
||||||
) {
|
) {
|
||||||
setError(detail.path as keyof NewUserFormData, {
|
setError(detail.path as keyof NewUserFormData, {
|
||||||
@ -270,20 +241,8 @@ export const NewUserModal = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tenant and Role Row */}
|
{/* Role */}
|
||||||
<div className={`grid ${defaultTenantId ? 'grid-cols-1' : 'grid-cols-2'} gap-5 pb-4`}>
|
<div className="pb-4">
|
||||||
{!defaultTenantId && (
|
|
||||||
<PaginatedSelect
|
|
||||||
label="Assign Tenant"
|
|
||||||
required
|
|
||||||
placeholder="Select Tenant"
|
|
||||||
value={tenantIdValue}
|
|
||||||
onValueChange={(value) => setValue('tenant_id', value)}
|
|
||||||
onLoadOptions={loadTenants}
|
|
||||||
error={errors.tenant_id?.message}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PaginatedSelect
|
<PaginatedSelect
|
||||||
label="Assign Role"
|
label="Assign Role"
|
||||||
required
|
required
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useLocation } from 'react-router-dom';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||||
|
|
||||||
export interface TabItem {
|
export interface TabItem {
|
||||||
label: string;
|
label: string;
|
||||||
@ -29,6 +30,20 @@ export const PageHeader = ({
|
|||||||
tabs = defaultTabs,
|
tabs = defaultTabs,
|
||||||
}: PageHeaderProps): ReactElement => {
|
}: PageHeaderProps): ReactElement => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { roles } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
|
// Check if user is super_admin
|
||||||
|
let rolesArray: string[] = [];
|
||||||
|
if (Array.isArray(roles)) {
|
||||||
|
rolesArray = roles;
|
||||||
|
} else if (typeof roles === 'string') {
|
||||||
|
try {
|
||||||
|
rolesArray = JSON.parse(roles);
|
||||||
|
} catch {
|
||||||
|
rolesArray = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isSuperAdmin = rolesArray.includes('super_admin');
|
||||||
|
|
||||||
const isActiveTab = (path: string): boolean => {
|
const isActiveTab = (path: string): boolean => {
|
||||||
// Exact match for dashboard
|
// Exact match for dashboard
|
||||||
@ -53,8 +68,8 @@ export const PageHeader = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs Navigation */}
|
{/* Tabs Navigation - Only show for super_admin */}
|
||||||
{tabs.length > 0 && (
|
{isSuperAdmin && tabs.length > 0 && (
|
||||||
<div className="border border-[rgba(0,0,0,0.2)] rounded-md p-1.5 flex gap-2 overflow-x-auto">
|
<div className="border border-[rgba(0,0,0,0.2)] rounded-md p-1.5 flex gap-2 overflow-x-auto">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = isActiveTab(tab.path);
|
const isActive = isActiveTab(tab.path);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { ReactElement, ButtonHTMLAttributes } from 'react';
|
import type { ReactElement, ButtonHTMLAttributes } from 'react';
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||||
|
|
||||||
const primaryButtonVariants = cva(
|
const primaryButtonVariants = cva(
|
||||||
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
|
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
|
||||||
@ -38,10 +39,60 @@ export const PrimaryButton = ({
|
|||||||
...props
|
...props
|
||||||
}: PrimaryButtonProps): ReactElement => {
|
}: PrimaryButtonProps): ReactElement => {
|
||||||
const buttonVariant = disabled ? 'disabled' : variant || 'default';
|
const buttonVariant = disabled ? 'disabled' : variant || 'default';
|
||||||
|
const { theme } = useAppSelector((state) => state.theme);
|
||||||
|
const { roles } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
|
// Check if user is tenant admin (not super_admin) or if we're on a tenant route
|
||||||
|
let rolesArray: string[] = [];
|
||||||
|
if (Array.isArray(roles)) {
|
||||||
|
rolesArray = roles;
|
||||||
|
} else if (typeof roles === 'string') {
|
||||||
|
try {
|
||||||
|
rolesArray = JSON.parse(roles);
|
||||||
|
} catch {
|
||||||
|
rolesArray = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isSuperAdmin = rolesArray.includes('super_admin');
|
||||||
|
const isTenantAdmin = !isSuperAdmin && rolesArray.length > 0;
|
||||||
|
|
||||||
|
// Check if we're on a tenant route (for login page where user might not be authenticated)
|
||||||
|
const isTenantRoute = typeof window !== 'undefined' && window.location.pathname.startsWith('/tenant');
|
||||||
|
|
||||||
|
// Use theme colors for tenant admin or tenant routes, default colors for super admin
|
||||||
|
const shouldUseTheme = (isTenantAdmin || (isTenantRoute && !isSuperAdmin)) && theme;
|
||||||
|
const primaryColor = shouldUseTheme && theme.primary_color ? theme.primary_color : '#112868';
|
||||||
|
const secondaryColor = shouldUseTheme && theme.secondary_color ? theme.secondary_color : '#23dce1';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(primaryButtonVariants({ size, variant: buttonVariant }), className)}
|
className={cn(primaryButtonVariants({ size, variant: buttonVariant }), className)}
|
||||||
|
style={
|
||||||
|
buttonVariant === 'default'
|
||||||
|
? {
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
color: secondaryColor,
|
||||||
|
}
|
||||||
|
: buttonVariant === 'disabled'
|
||||||
|
? {
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
color: secondaryColor,
|
||||||
|
opacity: 0.5,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (buttonVariant === 'default' && !disabled) {
|
||||||
|
e.currentTarget.style.backgroundColor = secondaryColor;
|
||||||
|
e.currentTarget.style.color = primaryColor;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (buttonVariant === 'default' && !disabled) {
|
||||||
|
e.currentTarget.style.backgroundColor = primaryColor;
|
||||||
|
e.currentTarget.style.color = secondaryColor;
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [limit, setLimit] = useState<number>(compact ? 10 : 20);
|
const [limit, setLimit] = useState<number>(5);
|
||||||
const [pagination, setPagination] = useState<{
|
const [pagination, setPagination] = useState<{
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@ -56,7 +56,7 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
}>({
|
}>({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: compact ? 10 : 20,
|
limit: 5,
|
||||||
total: 0,
|
total: 0,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { ReactElement, ButtonHTMLAttributes } from 'react';
|
import type { ReactElement, ButtonHTMLAttributes } from 'react';
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||||
|
|
||||||
const secondaryButtonVariants = cva(
|
const secondaryButtonVariants = cva(
|
||||||
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
|
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
|
||||||
@ -31,10 +32,60 @@ export const SecondaryButton = ({
|
|||||||
...props
|
...props
|
||||||
}: SecondaryButtonProps): ReactElement => {
|
}: SecondaryButtonProps): ReactElement => {
|
||||||
const buttonVariant = disabled ? 'disabled' : variant || 'default';
|
const buttonVariant = disabled ? 'disabled' : variant || 'default';
|
||||||
|
const { theme } = useAppSelector((state) => state.theme);
|
||||||
|
const { roles } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
|
// Check if user is tenant admin (not super_admin) or if we're on a tenant route
|
||||||
|
let rolesArray: string[] = [];
|
||||||
|
if (Array.isArray(roles)) {
|
||||||
|
rolesArray = roles;
|
||||||
|
} else if (typeof roles === 'string') {
|
||||||
|
try {
|
||||||
|
rolesArray = JSON.parse(roles);
|
||||||
|
} catch {
|
||||||
|
rolesArray = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isSuperAdmin = rolesArray.includes('super_admin');
|
||||||
|
const isTenantAdmin = !isSuperAdmin && rolesArray.length > 0;
|
||||||
|
|
||||||
|
// Check if we're on a tenant route (for login page where user might not be authenticated)
|
||||||
|
const isTenantRoute = typeof window !== 'undefined' && window.location.pathname.startsWith('/tenant');
|
||||||
|
|
||||||
|
// Use theme colors for tenant admin or tenant routes, default colors for super admin
|
||||||
|
const shouldUseTheme = (isTenantAdmin || (isTenantRoute && !isSuperAdmin)) && theme;
|
||||||
|
const primaryColor = shouldUseTheme && theme.primary_color ? theme.primary_color : '#112868';
|
||||||
|
const secondaryColor = shouldUseTheme && theme.secondary_color ? theme.secondary_color : '#23dce1';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(secondaryButtonVariants({ variant: buttonVariant }), className)}
|
className={cn(secondaryButtonVariants({ variant: buttonVariant }), className)}
|
||||||
|
style={
|
||||||
|
buttonVariant === 'default'
|
||||||
|
? {
|
||||||
|
backgroundColor: secondaryColor,
|
||||||
|
color: primaryColor,
|
||||||
|
}
|
||||||
|
: buttonVariant === 'disabled'
|
||||||
|
? {
|
||||||
|
backgroundColor: secondaryColor,
|
||||||
|
color: primaryColor,
|
||||||
|
opacity: 0.5,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (buttonVariant === 'default' && !disabled) {
|
||||||
|
e.currentTarget.style.backgroundColor = primaryColor;
|
||||||
|
e.currentTarget.style.color = secondaryColor;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (buttonVariant === 'default' && !disabled) {
|
||||||
|
e.currentTarget.style.backgroundColor = secondaryColor;
|
||||||
|
e.currentTarget.style.color = primaryColor;
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -120,12 +120,15 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
|||||||
last_name: string;
|
last_name: string;
|
||||||
status: 'active' | 'suspended' | 'deleted';
|
status: 'active' | 'suspended' | 'deleted';
|
||||||
auth_provider: 'local';
|
auth_provider: 'local';
|
||||||
tenant_id: string;
|
|
||||||
role_id: string;
|
role_id: string;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
const response = await userService.create(data);
|
// Explicitly add tenant_id when tenantId is provided (for super admin creating users in tenant details)
|
||||||
|
const createData = tenantId
|
||||||
|
? { ...data, tenant_id: tenantId }
|
||||||
|
: data;
|
||||||
|
const response = await userService.create(createData);
|
||||||
const message = response.message || `User created successfully`;
|
const message = response.message || `User created successfully`;
|
||||||
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been added`;
|
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been added`;
|
||||||
showToast.success(message, description);
|
showToast.success(message, description);
|
||||||
|
|||||||
35
src/hooks/useTenantTheme.ts
Normal file
35
src/hooks/useTenantTheme.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
||||||
|
import { fetchThemeAsync } from '@/store/themeSlice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch and apply tenant theme
|
||||||
|
* Should be used in tenant admin screens and login
|
||||||
|
*/
|
||||||
|
export const useTenantTheme = (): void => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { theme, faviconUrl, isInitialized, isLoading } = useAppSelector((state) => state.theme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only fetch if not already initialized
|
||||||
|
if (!isInitialized && !isLoading) {
|
||||||
|
dispatch(fetchThemeAsync());
|
||||||
|
}
|
||||||
|
}, [dispatch, isInitialized, isLoading]);
|
||||||
|
|
||||||
|
// Apply favicon
|
||||||
|
useEffect(() => {
|
||||||
|
if (faviconUrl) {
|
||||||
|
// Remove existing favicon links
|
||||||
|
const existingFavicons = document.querySelectorAll("link[rel='icon'], link[rel='shortcut icon']");
|
||||||
|
existingFavicons.forEach((favicon) => favicon.remove());
|
||||||
|
|
||||||
|
// Add new favicon
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'icon';
|
||||||
|
link.type = 'image/png';
|
||||||
|
link.href = faviconUrl;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
}, [faviconUrl]);
|
||||||
|
};
|
||||||
16
src/main.tsx
16
src/main.tsx
@ -1,4 +1,4 @@
|
|||||||
import { StrictMode } from 'react';
|
// import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { PersistGate } from 'redux-persist/integration/react';
|
import { PersistGate } from 'redux-persist/integration/react';
|
||||||
@ -7,11 +7,11 @@ import './index.css';
|
|||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
// <StrictMode>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<PersistGate loading={null} persistor={persistor}>
|
<PersistGate loading={null} persistor={persistor}>
|
||||||
<App />
|
<App />
|
||||||
</PersistGate>
|
</PersistGate>
|
||||||
</Provider>
|
</Provider>
|
||||||
</StrictMode>
|
// </StrictMode >
|
||||||
);
|
);
|
||||||
|
|||||||
@ -100,11 +100,9 @@ const subscriptionTierOptions = [
|
|||||||
{ value: 'enterprise', label: 'Enterprise' },
|
{ value: 'enterprise', label: 'Enterprise' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper function to get base URL without protocol
|
// Helper function to get base URL with protocol
|
||||||
const getBaseUrlWithoutProtocol = (): string => {
|
const getBaseUrlWithProtocol = (): string => {
|
||||||
const apiBaseUrl = import.meta.env.VITE_FRONTEND_BASE_URL || 'http://localhost:5173';
|
return import.meta.env.VITE_FRONTEND_BASE_URL || 'http://localhost:5173';
|
||||||
// Remove protocol (http:// or https://)
|
|
||||||
return apiBaseUrl.replace(/^https?:\/\//, '');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CreateTenantWizard = (): ReactElement => {
|
const CreateTenantWizard = (): ReactElement => {
|
||||||
@ -185,7 +183,7 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
|
|
||||||
// Auto-generate slug and domain from name
|
// Auto-generate slug and domain from name
|
||||||
const nameValue = tenantDetailsForm.watch('name');
|
const nameValue = tenantDetailsForm.watch('name');
|
||||||
const baseUrlWithoutProtocol = getBaseUrlWithoutProtocol();
|
const baseUrlWithProtocol = getBaseUrlWithProtocol();
|
||||||
const previousNameRef = useRef<string>('');
|
const previousNameRef = useRef<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -198,10 +196,20 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
tenantDetailsForm.setValue('slug', slug, { shouldValidate: true });
|
tenantDetailsForm.setValue('slug', slug, { shouldValidate: true });
|
||||||
|
|
||||||
// Auto-generate domain when tenant name changes (like slug)
|
// Auto-generate domain when tenant name changes (like slug)
|
||||||
// Always update domain when name changes, similar to slug behavior
|
// Format: http://tenant-slug.localhost:5173/tenant
|
||||||
|
// Extract host from base URL and construct domain with protocol
|
||||||
if (nameValue !== previousNameRef.current) {
|
if (nameValue !== previousNameRef.current) {
|
||||||
const autoGeneratedDomain = `${slug}.${baseUrlWithoutProtocol}`;
|
try {
|
||||||
tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false });
|
const baseUrlObj = new URL(baseUrlWithProtocol);
|
||||||
|
const host = baseUrlObj.host; // e.g., "localhost:5173"
|
||||||
|
const protocol = baseUrlObj.protocol; // e.g., "http:" or "https:"
|
||||||
|
const autoGeneratedDomain = `${protocol}//${slug}.${host}/tenant`;
|
||||||
|
tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false });
|
||||||
|
} catch {
|
||||||
|
// Fallback if URL parsing fails
|
||||||
|
const autoGeneratedDomain = `${baseUrlWithProtocol.replace(/\/$/, '')}/${slug}/tenant`;
|
||||||
|
tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false });
|
||||||
|
}
|
||||||
previousNameRef.current = nameValue;
|
previousNameRef.current = nameValue;
|
||||||
}
|
}
|
||||||
} else if (!nameValue && previousNameRef.current) {
|
} else if (!nameValue && previousNameRef.current) {
|
||||||
@ -209,7 +217,7 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
tenantDetailsForm.setValue('domain', '', { shouldValidate: false });
|
tenantDetailsForm.setValue('domain', '', { shouldValidate: false });
|
||||||
previousNameRef.current = '';
|
previousNameRef.current = '';
|
||||||
}
|
}
|
||||||
}, [nameValue, tenantDetailsForm, baseUrlWithoutProtocol]);
|
}, [nameValue, tenantDetailsForm, baseUrlWithProtocol]);
|
||||||
|
|
||||||
const handleNext = async (): Promise<void> => {
|
const handleNext = async (): Promise<void> => {
|
||||||
if (currentStep === 1) {
|
if (currentStep === 1) {
|
||||||
@ -315,8 +323,8 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
primary_color: primary_color || undefined,
|
primary_color: primary_color || undefined,
|
||||||
secondary_color: secondary_color || undefined,
|
secondary_color: secondary_color || undefined,
|
||||||
accent_color: accent_color || undefined,
|
accent_color: accent_color || undefined,
|
||||||
logo_file_path: logoFilePath || undefined,
|
logo_file_path: logoFileUrl || undefined,
|
||||||
favicon_file_path: faviconFilePath || undefined,
|
favicon_file_path: faviconFileUrl || undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@ -92,6 +92,11 @@ const subscriptionTierOptions = [
|
|||||||
{ value: 'enterprise', label: 'Enterprise' },
|
{ value: 'enterprise', label: 'Enterprise' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Helper function to get base URL with protocol
|
||||||
|
const getBaseUrlWithProtocol = (): string => {
|
||||||
|
return import.meta.env.VITE_FRONTEND_BASE_URL || 'http://localhost:5173';
|
||||||
|
};
|
||||||
|
|
||||||
const EditTenant = (): ReactElement => {
|
const EditTenant = (): ReactElement => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@ -107,7 +112,6 @@ const EditTenant = (): ReactElement => {
|
|||||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||||
const [faviconFile, setFaviconFile] = useState<File | null>(null);
|
const [faviconFile, setFaviconFile] = useState<File | null>(null);
|
||||||
const [logoFilePath, setLogoFilePath] = useState<string | null>(null);
|
const [logoFilePath, setLogoFilePath] = useState<string | null>(null);
|
||||||
const [logoFileUrl, setLogoFileUrl] = useState<string | null>(null);
|
|
||||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||||
const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
|
const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
|
||||||
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
|
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
|
||||||
@ -169,6 +173,45 @@ const EditTenant = (): ReactElement => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-generate slug and domain from name
|
||||||
|
const nameValue = tenantDetailsForm.watch('name');
|
||||||
|
const baseUrlWithProtocol = getBaseUrlWithProtocol();
|
||||||
|
const previousNameRef = useRef<string>('');
|
||||||
|
|
||||||
|
// Auto-generate slug and domain when name changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (nameValue) {
|
||||||
|
const slug = nameValue
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
tenantDetailsForm.setValue('slug', slug, { shouldValidate: true });
|
||||||
|
|
||||||
|
// Auto-generate domain when tenant name changes (like slug)
|
||||||
|
// Format: http://tenant-slug.localhost:5173/tenant
|
||||||
|
// Extract host from base URL and construct domain with protocol
|
||||||
|
if (nameValue !== previousNameRef.current) {
|
||||||
|
try {
|
||||||
|
const baseUrlObj = new URL(baseUrlWithProtocol);
|
||||||
|
const host = baseUrlObj.host; // e.g., "localhost:5173"
|
||||||
|
const protocol = baseUrlObj.protocol; // e.g., "http:" or "https:"
|
||||||
|
const autoGeneratedDomain = `${protocol}//${slug}.${host}/tenant`;
|
||||||
|
tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false });
|
||||||
|
} catch {
|
||||||
|
// Fallback if URL parsing fails
|
||||||
|
const autoGeneratedDomain = `${baseUrlWithProtocol.replace(/\/$/, '')}/${slug}/tenant`;
|
||||||
|
tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false });
|
||||||
|
}
|
||||||
|
previousNameRef.current = nameValue;
|
||||||
|
}
|
||||||
|
} else if (!nameValue && previousNameRef.current) {
|
||||||
|
// Clear domain when name is cleared
|
||||||
|
tenantDetailsForm.setValue('domain', '', { shouldValidate: false });
|
||||||
|
previousNameRef.current = '';
|
||||||
|
}
|
||||||
|
}, [nameValue, tenantDetailsForm, baseUrlWithProtocol]);
|
||||||
|
|
||||||
// Load tenant data on mount
|
// Load tenant data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadTenant = async (): Promise<void> => {
|
const loadTenant = async (): Promise<void> => {
|
||||||
@ -198,15 +241,14 @@ const EditTenant = (): ReactElement => {
|
|||||||
// Set file paths and URLs if they exist
|
// Set file paths and URLs if they exist
|
||||||
if (logoPath) {
|
if (logoPath) {
|
||||||
setLogoFilePath(logoPath);
|
setLogoFilePath(logoPath);
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||||
const baseUrl = apiBaseUrl.replace('/api/v1', '');
|
setLogoPreviewUrl(logoPath);
|
||||||
setLogoFileUrl(logoPath.startsWith('http') ? logoPath : `${baseUrl}/uploads/${logoPath}`);
|
|
||||||
}
|
}
|
||||||
if (faviconPath) {
|
if (faviconPath) {
|
||||||
setFaviconFilePath(faviconPath);
|
setFaviconFilePath(faviconPath);
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||||
const baseUrl = apiBaseUrl.replace('/api/v1', '');
|
setFaviconFileUrl(faviconPath);
|
||||||
setFaviconFileUrl(faviconPath.startsWith('http') ? faviconPath : `${baseUrl}/uploads/${faviconPath}`);
|
setFaviconPreviewUrl(faviconPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate subscription_tier
|
// Validate subscription_tier
|
||||||
@ -245,6 +287,9 @@ const EditTenant = (): ReactElement => {
|
|||||||
modules: tenantModules,
|
modules: tenantModules,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set previous name ref to track changes
|
||||||
|
previousNameRef.current = tenant.name;
|
||||||
|
|
||||||
contactDetailsForm.reset({
|
contactDetailsForm.reset({
|
||||||
email: contactInfo.email || '',
|
email: contactInfo.email || '',
|
||||||
first_name: contactInfo.first_name || '',
|
first_name: contactInfo.first_name || '',
|
||||||
@ -826,8 +871,7 @@ const EditTenant = (): ReactElement => {
|
|||||||
setIsUploadingLogo(true);
|
setIsUploadingLogo(true);
|
||||||
try {
|
try {
|
||||||
const response = await fileService.uploadSimple(file);
|
const response = await fileService.uploadSimple(file);
|
||||||
setLogoFilePath(response.data.file_path);
|
setLogoFilePath(response.data.file_url);
|
||||||
setLogoFileUrl(response.data.file_url);
|
|
||||||
showToast.success('Logo uploaded successfully');
|
showToast.success('Logo uploaded successfully');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@ -839,7 +883,6 @@ const EditTenant = (): ReactElement => {
|
|||||||
setLogoFile(null);
|
setLogoFile(null);
|
||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
setLogoPreviewUrl(null);
|
setLogoPreviewUrl(null);
|
||||||
setLogoFileUrl(null);
|
|
||||||
setLogoFilePath(null);
|
setLogoFilePath(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingLogo(false);
|
setIsUploadingLogo(false);
|
||||||
@ -854,17 +897,16 @@ const EditTenant = (): ReactElement => {
|
|||||||
<div className="text-xs text-[#6b7280]">
|
<div className="text-xs text-[#6b7280]">
|
||||||
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
|
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
|
||||||
</div>
|
</div>
|
||||||
{(logoPreviewUrl || logoFileUrl) && (
|
{(logoPreviewUrl ) && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<img
|
<img
|
||||||
key={logoPreviewUrl || logoFileUrl}
|
key={logoPreviewUrl}
|
||||||
src={logoPreviewUrl || logoFileUrl || ''}
|
src={logoPreviewUrl || ''}
|
||||||
alt="Logo preview"
|
alt="Logo preview"
|
||||||
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||||
style={{ display: 'block', maxHeight: '80px' }}
|
style={{ display: 'block', maxHeight: '80px' }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.error('Failed to load logo preview image', {
|
console.error('Failed to load logo preview image', {
|
||||||
logoFileUrl,
|
|
||||||
logoPreviewUrl,
|
logoPreviewUrl,
|
||||||
src: e.currentTarget.src,
|
src: e.currentTarget.src,
|
||||||
});
|
});
|
||||||
@ -874,10 +916,10 @@ const EditTenant = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!logoFile && logoFileUrl && (
|
{!logoFile && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<img
|
<img
|
||||||
src={logoFileUrl}
|
src={logoPreviewUrl || ''}
|
||||||
alt="Current logo"
|
alt="Current logo"
|
||||||
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||||
style={{ display: 'block', maxHeight: '80px' }}
|
style={{ display: 'block', maxHeight: '80px' }}
|
||||||
@ -925,7 +967,7 @@ const EditTenant = (): ReactElement => {
|
|||||||
setIsUploadingFavicon(true);
|
setIsUploadingFavicon(true);
|
||||||
try {
|
try {
|
||||||
const response = await fileService.uploadSimple(file);
|
const response = await fileService.uploadSimple(file);
|
||||||
setFaviconFilePath(response.data.file_path);
|
setFaviconFilePath(response.data.file_url);
|
||||||
setFaviconFileUrl(response.data.file_url);
|
setFaviconFileUrl(response.data.file_url);
|
||||||
showToast.success('Favicon uploaded successfully');
|
showToast.success('Favicon uploaded successfully');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -956,11 +998,11 @@ const EditTenant = (): ReactElement => {
|
|||||||
{(faviconPreviewUrl || faviconFileUrl) && (
|
{(faviconPreviewUrl || faviconFileUrl) && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<img
|
<img
|
||||||
key={faviconFileUrl || faviconPreviewUrl}
|
key={faviconPreviewUrl || faviconFileUrl}
|
||||||
src={faviconFileUrl || faviconPreviewUrl || ''}
|
src={faviconPreviewUrl || faviconFileUrl || ''}
|
||||||
alt="Favicon preview"
|
alt="Favicon preview"
|
||||||
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||||
style={{ display: 'block', width: '64px', height: '64px' }}
|
style={{ display: 'block', maxHeight: '80px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -971,8 +1013,8 @@ const EditTenant = (): ReactElement => {
|
|||||||
<img
|
<img
|
||||||
src={faviconFileUrl}
|
src={faviconFileUrl}
|
||||||
alt="Current favicon"
|
alt="Current favicon"
|
||||||
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||||
style={{ display: 'block', width: '64px', height: '64px' }}
|
style={{ display: 'block', maxHeight: '80px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -63,7 +63,7 @@ const Login = (): ReactElement => {
|
|||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
// Tenant admin - redirect to tenant dashboard
|
// Tenant admin - redirect to tenant dashboard
|
||||||
navigate('/tenant/dashboard');
|
navigate('/tenant');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, roles, navigate]);
|
}, [isAuthenticated, roles, navigate]);
|
||||||
@ -95,7 +95,7 @@ const Login = (): ReactElement => {
|
|||||||
if (userRoles.includes('super_admin')) {
|
if (userRoles.includes('super_admin')) {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
navigate('/tenant/dashboard');
|
navigate('/tenant');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@ -48,7 +48,7 @@ const Roles = (): ReactElement => {
|
|||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [limit, setLimit] = useState<number>(20);
|
const [limit, setLimit] = useState<number>(5);
|
||||||
const [pagination, setPagination] = useState<{
|
const [pagination, setPagination] = useState<{
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@ -57,7 +57,7 @@ const Roles = (): ReactElement => {
|
|||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
}>({
|
}>({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 20,
|
limit: 5,
|
||||||
total: 0,
|
total: 0,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
|
|||||||
@ -220,10 +220,15 @@ const TenantDetails = (): ReactElement => {
|
|||||||
<span className="truncate">{tenant.slug}</span>
|
<span className="truncate">{tenant.slug}</span>
|
||||||
</div>
|
</div>
|
||||||
{tenant.domain && (
|
{tenant.domain && (
|
||||||
<div className="flex items-center gap-1.5">
|
<a
|
||||||
|
href={tenant.domain.startsWith('http') ? tenant.domain : `https://${tenant.domain}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-[#6b7280] hover:text-[#112868] transition-colors"
|
||||||
|
>
|
||||||
<Globe className="w-4 h-4" />
|
<Globe className="w-4 h-4" />
|
||||||
<span className="truncate">{tenant.domain}</span>
|
<span className="truncate">{tenant.domain}</span>
|
||||||
</div>
|
</a>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
|
|||||||
390
src/pages/tenant/AuditLogs.tsx
Normal file
390
src/pages/tenant/AuditLogs.tsx
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Layout } from '@/components/layout/Layout';
|
||||||
|
import {
|
||||||
|
ViewAuditLogModal,
|
||||||
|
DataTable,
|
||||||
|
Pagination,
|
||||||
|
FilterDropdown,
|
||||||
|
StatusBadge,
|
||||||
|
type Column,
|
||||||
|
} from '@/components/shared';
|
||||||
|
import { Download, ArrowUpDown } from 'lucide-react';
|
||||||
|
import { auditLogService } from '@/services/audit-log-service';
|
||||||
|
import type { AuditLog } from '@/types/audit-log';
|
||||||
|
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||||
|
|
||||||
|
// Helper function to format date
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get action badge variant
|
||||||
|
const getActionVariant = (action: string): 'success' | 'failure' | 'info' | 'process' => {
|
||||||
|
const lowerAction = action.toLowerCase();
|
||||||
|
if (lowerAction.includes('create') || lowerAction.includes('register')) return 'success';
|
||||||
|
if (lowerAction.includes('update') || lowerAction.includes('version_update') || lowerAction.includes('login')) return 'info';
|
||||||
|
if (lowerAction.includes('delete') || lowerAction.includes('deregister')) return 'failure';
|
||||||
|
if (lowerAction.includes('read') || lowerAction.includes('get') || lowerAction.includes('status_change')) return 'process';
|
||||||
|
return 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get method badge variant
|
||||||
|
const getMethodVariant = (method: string | null): 'success' | 'failure' | 'info' | 'process' => {
|
||||||
|
if (!method) return 'info';
|
||||||
|
const upperMethod = method.toUpperCase();
|
||||||
|
if (upperMethod === 'GET') return 'success';
|
||||||
|
if (upperMethod === 'POST') return 'info';
|
||||||
|
if (upperMethod === 'PUT' || upperMethod === 'PATCH') return 'process';
|
||||||
|
if (upperMethod === 'DELETE') return 'failure';
|
||||||
|
return 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get status badge color based on response status
|
||||||
|
const getStatusColor = (status: number | null): string => {
|
||||||
|
if (!status) return 'text-[#6b7280]';
|
||||||
|
if (status >= 200 && status < 300) return 'text-[#10b981]';
|
||||||
|
if (status >= 300 && status < 400) return 'text-[#f59e0b]';
|
||||||
|
if (status >= 400) return 'text-[#ef4444]';
|
||||||
|
return 'text-[#6b7280]';
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuditLogs = (): ReactElement => {
|
||||||
|
const tenantId = useAppSelector((state) => state.auth.tenantId);
|
||||||
|
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 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 [methodFilter, setMethodFilter] = useState<string | null>(null);
|
||||||
|
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||||
|
|
||||||
|
// View modal
|
||||||
|
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||||
|
const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchAuditLogs = async (
|
||||||
|
page: number,
|
||||||
|
itemsPerPage: number,
|
||||||
|
method: string | null = null,
|
||||||
|
sortBy: string[] | null = null
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await auditLogService.getAll(page, itemsPerPage, method, sortBy, tenantId);
|
||||||
|
if (response.success) {
|
||||||
|
setAuditLogs(response.data);
|
||||||
|
setPagination(response.pagination);
|
||||||
|
} else {
|
||||||
|
setError('Failed to load audit logs');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error?.message || 'Failed to load audit logs');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch audit logs on mount and when pagination/filters change
|
||||||
|
useEffect(() => {
|
||||||
|
if (tenantId) {
|
||||||
|
fetchAuditLogs(currentPage, limit, methodFilter, orderBy);
|
||||||
|
}
|
||||||
|
}, [currentPage, limit, methodFilter, orderBy, tenantId]);
|
||||||
|
|
||||||
|
// View audit log handler
|
||||||
|
const handleViewAuditLog = (auditLogId: string): void => {
|
||||||
|
setSelectedAuditLogId(auditLogId);
|
||||||
|
setViewModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load audit log for view
|
||||||
|
const loadAuditLog = async (id: string): Promise<AuditLog> => {
|
||||||
|
const response = await auditLogService.getById(id);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define table columns
|
||||||
|
const columns: Column<AuditLog>[] = [
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
label: 'Timestamp',
|
||||||
|
render: (log) => (
|
||||||
|
<span className="text-sm font-normal text-[#0f1724]">{formatDate(log.created_at)}</span>
|
||||||
|
),
|
||||||
|
mobileLabel: 'Time',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'resource_type',
|
||||||
|
label: 'Resource Type',
|
||||||
|
render: (log) => (
|
||||||
|
<span className="text-sm font-normal text-[#0f1724]">{log.resource_type}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
label: 'Action',
|
||||||
|
render: (log) => (
|
||||||
|
<StatusBadge variant={getActionVariant(log.action)}>
|
||||||
|
{log.action}
|
||||||
|
</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'resource_id',
|
||||||
|
label: 'Resource ID',
|
||||||
|
render: (log) => (
|
||||||
|
<span className="text-sm font-normal text-[#0f1724] font-mono truncate max-w-[150px]">
|
||||||
|
{log.resource_id || 'N/A'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user',
|
||||||
|
label: 'User',
|
||||||
|
render: (log) => (
|
||||||
|
<span className="text-sm font-normal text-[#0f1724]">
|
||||||
|
{log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'request_method',
|
||||||
|
label: 'Method',
|
||||||
|
render: (log) => (
|
||||||
|
<StatusBadge variant={getMethodVariant(log.request_method)}>
|
||||||
|
{log.request_method ? log.request_method.toUpperCase() : 'N/A'}
|
||||||
|
</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'response_status',
|
||||||
|
label: 'Status',
|
||||||
|
render: (log) => (
|
||||||
|
<span className={`text-sm font-normal ${getStatusColor(log.response_status)}`}>
|
||||||
|
{log.response_status || 'N/A'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ip_address',
|
||||||
|
label: 'IP Address',
|
||||||
|
render: (log) => (
|
||||||
|
<span className="text-sm font-normal text-[#0f1724] font-mono">
|
||||||
|
{log.ip_address || 'N/A'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: 'Actions',
|
||||||
|
align: 'right',
|
||||||
|
render: (log) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleViewAuditLog(log.id)}
|
||||||
|
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mobile card renderer
|
||||||
|
const mobileCardRenderer = (log: AuditLog) => (
|
||||||
|
<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">{log.resource_type}</h3>
|
||||||
|
<div className="mt-1">
|
||||||
|
<StatusBadge variant={getActionVariant(log.action)}>
|
||||||
|
{log.action}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleViewAuditLog(log.id)}
|
||||||
|
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-[#9aa6b2]">Timestamp:</span>
|
||||||
|
<p className="text-[#0f1724] font-normal mt-1">{formatDate(log.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[#9aa6b2]">User:</span>
|
||||||
|
<p className="text-[#0f1724] font-normal mt-1">
|
||||||
|
{log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[#9aa6b2]">Method:</span>
|
||||||
|
<div className="mt-1">
|
||||||
|
<StatusBadge variant={getMethodVariant(log.request_method)}>
|
||||||
|
{log.request_method ? log.request_method.toUpperCase() : 'N/A'}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[#9aa6b2]">Status:</span>
|
||||||
|
<p className={`font-normal mt-1 ${getStatusColor(log.response_status)}`}>
|
||||||
|
{log.response_status || 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[#9aa6b2]">Resource ID:</span>
|
||||||
|
<p className="text-[#0f1724] font-normal mt-1 font-mono truncate">
|
||||||
|
{log.resource_id || 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[#9aa6b2]">IP Address:</span>
|
||||||
|
<p className="text-[#0f1724] font-normal mt-1 font-mono">{log.ip_address || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Audit Logs"
|
||||||
|
pageHeader={{
|
||||||
|
title: 'Audit Logs',
|
||||||
|
description: 'View and manage all audit logs in the QAssure platform.',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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">
|
||||||
|
{/* Method Filter */}
|
||||||
|
<FilterDropdown
|
||||||
|
label="Method"
|
||||||
|
options={[
|
||||||
|
{ value: 'GET', label: 'GET' },
|
||||||
|
{ value: 'POST', label: 'POST' },
|
||||||
|
{ value: 'PUT', label: 'PUT' },
|
||||||
|
{ value: 'DELETE', label: 'DELETE' },
|
||||||
|
{ value: 'PATCH', label: 'PATCH' },
|
||||||
|
]}
|
||||||
|
value={methodFilter}
|
||||||
|
onChange={(value) => {
|
||||||
|
setMethodFilter(value as string | null);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="All"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sort Filter */}
|
||||||
|
<FilterDropdown
|
||||||
|
label="Sort by"
|
||||||
|
options={[
|
||||||
|
{ value: ['created_at', 'desc'], label: 'Newest First' },
|
||||||
|
{ value: ['created_at', 'asc'], label: 'Oldest First' },
|
||||||
|
{ value: ['action', 'asc'], label: 'Action (A-Z)' },
|
||||||
|
{ value: ['action', 'desc'], label: 'Action (Z-A)' },
|
||||||
|
{ value: ['resource_type', 'asc'], label: 'Resource Type (A-Z)' },
|
||||||
|
{ value: ['resource_type', 'desc'], label: 'Resource Type (Z-A)' },
|
||||||
|
]}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={auditLogs}
|
||||||
|
keyExtractor={(log) => log.id}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
mobileCardRenderer={mobileCardRenderer}
|
||||||
|
emptyMessage="No audit logs found"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination.total > 0 && (
|
||||||
|
<div className="border-t border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3">
|
||||||
|
<Pagination
|
||||||
|
currentPage={pagination.page}
|
||||||
|
totalPages={pagination.totalPages}
|
||||||
|
totalItems={pagination.total}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={(page: number) => setCurrentPage(page)}
|
||||||
|
onLimitChange={(newLimit: number) => {
|
||||||
|
setLimit(newLimit);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Audit Log Modal */}
|
||||||
|
<ViewAuditLogModal
|
||||||
|
isOpen={viewModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setViewModalOpen(false);
|
||||||
|
setSelectedAuditLogId(null);
|
||||||
|
}}
|
||||||
|
auditLogId={selectedAuditLogId}
|
||||||
|
onLoadAuditLog={loadAuditLog}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuditLogs;
|
||||||
129
src/pages/tenant/Dashboard.tsx
Normal file
129
src/pages/tenant/Dashboard.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { Layout } from '@/components/layout/Layout';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Info, FileCheck, Briefcase, FileText, GraduationCap } from 'lucide-react';
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
status: 'success' | 'process' | 'warning' | 'disabled';
|
||||||
|
statusLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard = ({ icon: Icon, value, label, status, statusLabel }: StatCardProps): ReactElement => {
|
||||||
|
const statusConfig = {
|
||||||
|
success: {
|
||||||
|
bg: 'bg-[#f1fffb]',
|
||||||
|
dot: 'bg-[#16c784]',
|
||||||
|
text: 'text-[#16c784]',
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
bg: 'bg-[#fff5e5]',
|
||||||
|
dot: 'bg-[#fca004]',
|
||||||
|
text: 'text-[#fca004]',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: 'bg-[#fdf5f4]',
|
||||||
|
dot: 'bg-[#e0352a]',
|
||||||
|
text: 'text-[#e0352a]',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
bg: 'bg-[#e5e7eb]',
|
||||||
|
dot: 'bg-[#9ca3af]',
|
||||||
|
text: 'text-[#9ca3af]',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[status];
|
||||||
|
const valueColor = status === 'warning' && label === 'Overdue Tasks' ? 'text-[#e0352a]' : 'text-[#0f1724]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative p-[1px] rounded-lg" style={{ backgroundImage: 'linear-gradient(172.99deg, rgb(8, 76, 200) 1.15%, rgb(117, 192, 68) 44.3%, rgb(254, 211, 20) 89.74%)' }}>
|
||||||
|
<div className="bg-white border border-[#d1d5db] rounded-lg p-[17px] flex flex-col gap-3">
|
||||||
|
{/* Header with icon and status */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<Icon className="w-5 h-5 text-[#0f1724] shrink-0" />
|
||||||
|
<div className={`${config.bg} flex gap-1 items-center px-3 py-1 rounded-full`}>
|
||||||
|
<div className={`${config.dot} rounded-sm w-1.5 h-1.5`} />
|
||||||
|
<span className={`${config.text} text-xs font-medium capitalize`}>{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value and Label */}
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
<div className={`text-2xl font-bold tracking-[-0.48px] ${valueColor}`}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-[#6b7280]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Dashboard = (): ReactElement => {
|
||||||
|
const statCards: StatCardProps[] = [
|
||||||
|
{
|
||||||
|
icon: Info,
|
||||||
|
value: '18',
|
||||||
|
label: 'Open CAPAs',
|
||||||
|
status: 'success',
|
||||||
|
statusLabel: 'Success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileCheck,
|
||||||
|
value: '7',
|
||||||
|
label: 'Pending Approvals',
|
||||||
|
status: 'process',
|
||||||
|
statusLabel: 'Process',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Briefcase,
|
||||||
|
value: '9',
|
||||||
|
label: 'Active Projects',
|
||||||
|
status: 'warning',
|
||||||
|
statusLabel: 'Warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Info,
|
||||||
|
value: '3',
|
||||||
|
label: 'Overdue Tasks',
|
||||||
|
status: 'warning',
|
||||||
|
statusLabel: 'Warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
value: '14',
|
||||||
|
label: 'Docs Pending Review',
|
||||||
|
status: 'disabled',
|
||||||
|
statusLabel: 'Disabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GraduationCap,
|
||||||
|
value: '94%',
|
||||||
|
label: 'Training Compliance',
|
||||||
|
status: 'success',
|
||||||
|
statusLabel: 'Success',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Dashboard Overview"
|
||||||
|
pageHeader={{
|
||||||
|
title: 'Tenant Overview',
|
||||||
|
description: 'Key quality metrics and performance indicators.',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{statCards.map((card, index) => (
|
||||||
|
<StatCard key={index} {...card} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
443
src/pages/tenant/Roles.tsx
Normal file
443
src/pages/tenant/Roles.tsx
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Layout } from '@/components/layout/Layout';
|
||||||
|
import {
|
||||||
|
PrimaryButton,
|
||||||
|
StatusBadge,
|
||||||
|
ActionDropdown,
|
||||||
|
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';
|
||||||
|
import { NewRoleModal } from '@/components/shared/NewRoleModal';
|
||||||
|
|
||||||
|
// Helper function to format date
|
||||||
|
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 scope badge variant
|
||||||
|
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;
|
||||||
567
src/pages/tenant/Settings.tsx
Normal file
567
src/pages/tenant/Settings.tsx
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
import { Layout } from '@/components/layout/Layout';
|
||||||
|
import { ImageIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { useState, useEffect, type ReactElement } from 'react';
|
||||||
|
import { useAppSelector, useAppDispatch } from '@/hooks/redux-hooks';
|
||||||
|
import { tenantService } from '@/services/tenant-service';
|
||||||
|
import { fileService } from '@/services/file-service';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
|
import { updateTheme } from '@/store/themeSlice';
|
||||||
|
import { PrimaryButton } from '@/components/shared';
|
||||||
|
import type { Tenant } from '@/types/tenant';
|
||||||
|
|
||||||
|
const Settings = (): ReactElement => {
|
||||||
|
const tenantId = useAppSelector((state) => state.auth.tenantId);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Tenant data
|
||||||
|
const [tenant, setTenant] = useState<Tenant | null>(null);
|
||||||
|
|
||||||
|
// Color states
|
||||||
|
const [primaryColor, setPrimaryColor] = useState<string>('#112868');
|
||||||
|
const [secondaryColor, setSecondaryColor] = useState<string>('#23DCE1');
|
||||||
|
const [accentColor, setAccentColor] = useState<string>('#084CC8');
|
||||||
|
|
||||||
|
// Logo states
|
||||||
|
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||||
|
const [logoFilePath, setLogoFilePath] = useState<string | null>(null);
|
||||||
|
const [logoFileUrl, setLogoFileUrl] = useState<string | null>(null);
|
||||||
|
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Favicon states
|
||||||
|
const [faviconFile, setFaviconFile] = useState<File | null>(null);
|
||||||
|
const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
|
||||||
|
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
|
||||||
|
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Fetch tenant data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTenant = async (): Promise<void> => {
|
||||||
|
if (!tenantId) {
|
||||||
|
setError('Tenant ID not found');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await tenantService.getById(tenantId);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const tenantData = response.data;
|
||||||
|
setTenant(tenantData);
|
||||||
|
|
||||||
|
// Set colors
|
||||||
|
setPrimaryColor(tenantData.primary_color || '#112868');
|
||||||
|
setSecondaryColor(tenantData.secondary_color || '#23DCE1');
|
||||||
|
setAccentColor(tenantData.accent_color || '#084CC8');
|
||||||
|
|
||||||
|
// Set logo
|
||||||
|
if (tenantData.logo_file_path) {
|
||||||
|
setLogoFileUrl(tenantData.logo_file_path);
|
||||||
|
setLogoFilePath(tenantData.logo_file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set favicon
|
||||||
|
if (tenantData.favicon_file_path) {
|
||||||
|
setFaviconFileUrl(tenantData.favicon_file_path);
|
||||||
|
setFaviconFilePath(tenantData.favicon_file_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err?.response?.data?.error?.message ||
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.message ||
|
||||||
|
'Failed to load tenant settings';
|
||||||
|
setError(errorMessage);
|
||||||
|
showToast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTenant();
|
||||||
|
}, [tenantId]);
|
||||||
|
|
||||||
|
// Cleanup preview URLs
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (logoPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(logoPreviewUrl);
|
||||||
|
}
|
||||||
|
if (faviconPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(faviconPreviewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [logoPreviewUrl, faviconPreviewUrl]);
|
||||||
|
|
||||||
|
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file size (2MB max)
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
showToast.error('Logo file size must be less than 2MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const validTypes = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/jpg'];
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
showToast.error('Logo must be PNG, SVG, or JPG format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke previous preview URL
|
||||||
|
if (logoPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(logoPreviewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrl = URL.createObjectURL(file);
|
||||||
|
setLogoFile(file);
|
||||||
|
setLogoPreviewUrl(previewUrl);
|
||||||
|
setIsUploadingLogo(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fileService.uploadSimple(file);
|
||||||
|
setLogoFilePath(response.data.file_url);
|
||||||
|
setLogoFileUrl(response.data.file_url);
|
||||||
|
showToast.success('Logo uploaded successfully');
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err?.response?.data?.error?.message ||
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.message ||
|
||||||
|
'Failed to upload logo. Please try again.';
|
||||||
|
showToast.error(errorMessage);
|
||||||
|
setLogoFile(null);
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
setLogoPreviewUrl(null);
|
||||||
|
setLogoFileUrl(null);
|
||||||
|
setLogoFilePath(null);
|
||||||
|
} finally {
|
||||||
|
setIsUploadingLogo(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFaviconChange = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file size (500KB max)
|
||||||
|
if (file.size > 500 * 1024) {
|
||||||
|
showToast.error('Favicon file size must be less than 500KB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const validTypes = ['image/x-icon', 'image/png', 'image/vnd.microsoft.icon'];
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
showToast.error('Favicon must be ICO or PNG format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke previous preview URL
|
||||||
|
if (faviconPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(faviconPreviewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrl = URL.createObjectURL(file);
|
||||||
|
setFaviconFile(file);
|
||||||
|
setFaviconPreviewUrl(previewUrl);
|
||||||
|
setIsUploadingFavicon(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fileService.uploadSimple(file);
|
||||||
|
setFaviconFilePath(response.data.file_url);
|
||||||
|
setFaviconFileUrl(response.data.file_url);
|
||||||
|
showToast.success('Favicon uploaded successfully');
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err?.response?.data?.error?.message ||
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.message ||
|
||||||
|
'Failed to upload favicon. Please try again.';
|
||||||
|
showToast.error(errorMessage);
|
||||||
|
setFaviconFile(null);
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
setFaviconPreviewUrl(null);
|
||||||
|
setFaviconFileUrl(null);
|
||||||
|
setFaviconFilePath(null);
|
||||||
|
} finally {
|
||||||
|
setIsUploadingFavicon(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (): Promise<void> => {
|
||||||
|
if (!tenantId || !tenant) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Build update data matching EditTenantModal format
|
||||||
|
const existingSettings = (tenant.settings as Record<string, unknown>) || {};
|
||||||
|
const existingContact = (existingSettings.contact as Record<string, unknown>) || {};
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
status: tenant.status,
|
||||||
|
domain: tenant.domain || null,
|
||||||
|
subscription_tier: tenant.subscription_tier || null,
|
||||||
|
max_users: tenant.max_users || null,
|
||||||
|
max_modules: tenant.max_modules || null,
|
||||||
|
settings: {
|
||||||
|
enable_sso: tenant.enable_sso || false,
|
||||||
|
enable_2fa: tenant.enable_2fa || false,
|
||||||
|
contact: existingContact,
|
||||||
|
branding: {
|
||||||
|
primary_color: primaryColor || undefined,
|
||||||
|
secondary_color: secondaryColor || undefined,
|
||||||
|
accent_color: accentColor || undefined,
|
||||||
|
logo_file_path: logoFilePath || undefined,
|
||||||
|
favicon_file_path: faviconFilePath || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await tenantService.update(tenantId, updateData);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showToast.success('Settings updated successfully');
|
||||||
|
|
||||||
|
// Update theme in Redux
|
||||||
|
dispatch(
|
||||||
|
updateTheme({
|
||||||
|
logo_file_path: logoFilePath,
|
||||||
|
favicon_file_path: faviconFilePath,
|
||||||
|
primary_color: primaryColor,
|
||||||
|
secondary_color: secondaryColor,
|
||||||
|
accent_color: accentColor,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update local tenant state
|
||||||
|
setTenant({
|
||||||
|
...tenant,
|
||||||
|
primary_color: primaryColor,
|
||||||
|
secondary_color: secondaryColor,
|
||||||
|
accent_color: accentColor,
|
||||||
|
logo_file_path: logoFilePath,
|
||||||
|
favicon_file_path: faviconFilePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err?.response?.data?.error?.message ||
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.message ||
|
||||||
|
'Failed to update settings. Please try again.';
|
||||||
|
setError(errorMessage);
|
||||||
|
showToast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Settings"
|
||||||
|
pageHeader={{
|
||||||
|
title: 'Settings',
|
||||||
|
description: 'Manage your tenant settings',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !tenant) {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Settings"
|
||||||
|
pageHeader={{
|
||||||
|
title: 'Settings',
|
||||||
|
description: 'Manage your tenant settings',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
||||||
|
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Settings"
|
||||||
|
pageHeader={{
|
||||||
|
title: 'Settings',
|
||||||
|
description: 'Manage your tenant settings',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
||||||
|
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Branding Section */}
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6 flex flex-col gap-4">
|
||||||
|
{/* Section Header */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-base font-semibold text-[#0f1724]">Branding</h3>
|
||||||
|
<p className="text-sm font-normal text-[#9ca3af]">
|
||||||
|
Customize logo, favicon, and colors for this tenant experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo and Favicon Upload */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
{/* Company Logo */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-[#0f1724]">Company Logo</label>
|
||||||
|
<label
|
||||||
|
htmlFor="logo-upload"
|
||||||
|
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-[#d1d5db] rounded-lg size-12 flex items-center justify-center shrink-0">
|
||||||
|
{isUploadingLogo ? (
|
||||||
|
<Loader2 className="w-5 h-5 text-[#6b7280] animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="w-5 h-5 text-[#6b7280]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium text-[#0f1724]">Upload Logo</span>
|
||||||
|
<span className="text-xs font-normal text-[#9ca3af]">PNG, SVG, JPG up to 2MB.</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="logo-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/svg+xml,image/jpeg,image/jpg"
|
||||||
|
onChange={handleLogoChange}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isUploadingLogo}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{logoFile && (
|
||||||
|
<div className="flex flex-col gap-2 mt-1">
|
||||||
|
<div className="text-xs text-[#6b7280]">
|
||||||
|
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
|
||||||
|
</div>
|
||||||
|
{(logoPreviewUrl || logoFileUrl) && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<img
|
||||||
|
src={logoPreviewUrl || logoFileUrl || ''}
|
||||||
|
alt="Logo preview"
|
||||||
|
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||||
|
style={{ display: 'block', maxHeight: '80px' }}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('Failed to load logo preview image', {
|
||||||
|
logoFileUrl,
|
||||||
|
logoPreviewUrl,
|
||||||
|
src: e.currentTarget.src,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!logoFile && logoFileUrl && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<img
|
||||||
|
src={logoFileUrl}
|
||||||
|
alt="Current logo"
|
||||||
|
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||||
|
style={{ display: 'block', maxHeight: '80px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Favicon */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-[#0f1724]">Favicon</label>
|
||||||
|
<label
|
||||||
|
htmlFor="favicon-upload"
|
||||||
|
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-[#d1d5db] rounded-lg size-12 flex items-center justify-center shrink-0">
|
||||||
|
{isUploadingFavicon ? (
|
||||||
|
<Loader2 className="w-5 h-5 text-[#6b7280] animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="w-5 h-5 text-[#6b7280]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium text-[#0f1724]">Upload Favicon</span>
|
||||||
|
<span className="text-xs font-normal text-[#9ca3af]">ICO or PNG up to 500KB.</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="favicon-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/x-icon,image/png,image/vnd.microsoft.icon"
|
||||||
|
onChange={handleFaviconChange}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isUploadingFavicon}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{faviconFile && (
|
||||||
|
<div className="flex flex-col gap-2 mt-1">
|
||||||
|
<div className="text-xs text-[#6b7280]">
|
||||||
|
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
|
||||||
|
</div>
|
||||||
|
{(faviconPreviewUrl || faviconFileUrl) && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<img
|
||||||
|
src={faviconPreviewUrl || faviconFileUrl || ''}
|
||||||
|
alt="Favicon preview"
|
||||||
|
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||||
|
style={{ display: 'block', width: '64px', height: '64px' }}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('Failed to load favicon preview image', {
|
||||||
|
faviconFileUrl,
|
||||||
|
faviconPreviewUrl,
|
||||||
|
src: e.currentTarget.src,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!faviconFile && faviconFileUrl && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<img
|
||||||
|
src={faviconFileUrl}
|
||||||
|
alt="Current favicon"
|
||||||
|
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||||
|
style={{ display: 'block', width: '64px', height: '64px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary Color */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-[#0f1724]">Primary Color</label>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div
|
||||||
|
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
|
||||||
|
style={{ backgroundColor: primaryColor }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
|
className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent"
|
||||||
|
placeholder="#112868"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
|
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-normal text-[#9ca3af]">
|
||||||
|
Used for navigation, headers, and key actions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary and Accent Colors */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
{/* Secondary Color */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-[#0f1724]">Secondary Color</label>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div
|
||||||
|
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
|
||||||
|
style={{ backgroundColor: secondaryColor }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={secondaryColor}
|
||||||
|
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||||
|
className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent"
|
||||||
|
placeholder="#23DCE1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={secondaryColor}
|
||||||
|
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||||
|
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-normal text-[#9ca3af]">
|
||||||
|
Used for highlights and supporting elements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accent Color */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-[#0f1724]">Accent Color</label>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div
|
||||||
|
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
|
||||||
|
style={{ backgroundColor: accentColor }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => setAccentColor(e.target.value)}
|
||||||
|
className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent"
|
||||||
|
placeholder="#084CC8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => setAccentColor(e.target.value)}
|
||||||
|
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-normal text-[#9ca3af]">
|
||||||
|
Used for alerts and special notices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end pt-4 border-t border-[rgba(0,0,0,0.08)]">
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || isUploadingLogo || isUploadingFavicon}
|
||||||
|
className="px-6 py-2.5"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
@ -11,6 +11,7 @@ import { FormField } from '@/components/shared';
|
|||||||
import { PrimaryButton } from '@/components/shared';
|
import { PrimaryButton } from '@/components/shared';
|
||||||
import type { LoginError } from '@/services/auth-service';
|
import type { LoginError } from '@/services/auth-service';
|
||||||
import { showToast } from '@/utils/toast';
|
import { showToast } from '@/utils/toast';
|
||||||
|
import { useTenantTheme } from '@/hooks/useTenantTheme';
|
||||||
|
|
||||||
// Zod validation schema
|
// Zod validation schema
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@ -30,6 +31,10 @@ const TenantLogin = (): ReactElement => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { isLoading, error, isAuthenticated, roles } = useAppSelector((state) => state.auth);
|
const { isLoading, error, isAuthenticated, roles } = useAppSelector((state) => state.auth);
|
||||||
|
const { theme, logoUrl } = useAppSelector((state) => state.theme);
|
||||||
|
|
||||||
|
// Fetch and apply tenant theme
|
||||||
|
useTenantTheme();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@ -64,7 +69,7 @@ const TenantLogin = (): ReactElement => {
|
|||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
// Tenant admin - redirect to tenant dashboard
|
// Tenant admin - redirect to tenant dashboard
|
||||||
navigate('/tenant/dashboard');
|
navigate('/tenant');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, roles, navigate]);
|
}, [isAuthenticated, roles, navigate]);
|
||||||
@ -104,7 +109,7 @@ const TenantLogin = (): ReactElement => {
|
|||||||
if (rolesArray.includes('super_admin')) {
|
if (rolesArray.includes('super_admin')) {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
navigate('/tenant/dashboard');
|
navigate('/tenant');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -145,16 +150,43 @@ const TenantLogin = (): ReactElement => {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f6f9ff] relative flex">
|
<div className="min-h-screen bg-[#f6f9ff] relative flex">
|
||||||
{/* Left Side - Blue Background */}
|
{/* Left Side - Blue Background */}
|
||||||
<div className="hidden lg:flex lg:w-[48%] bg-[#112868] flex-col justify-between px-8 py-8 min-w-[320px]">
|
<div
|
||||||
|
className="hidden lg:flex lg:w-[48%] flex-col justify-between px-8 py-8 min-w-[320px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme?.primary_color || '#112868',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-2.5 py-22">
|
<div className="flex flex-col gap-2.5 py-22">
|
||||||
{/* Logo Section */}
|
{/* Logo Section */}
|
||||||
<div className="flex flex-col gap-3 max-w-[280px]">
|
<div className="flex flex-col gap-3 max-w-[280px]">
|
||||||
<div className="flex items-center justify-between px-2 w-[206px]">
|
<div className="flex items-center justify-between px-2 w-[206px]">
|
||||||
<div className="bg-[#23dce1] rounded-[10px] shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] w-9 h-9 flex items-center justify-center shrink-0">
|
{logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt="Logo"
|
||||||
|
className="h-9 w-auto max-w-[180px] object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback to icon if image fails
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
|
||||||
|
if (fallback) fallback.style.display = 'flex';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="rounded-[10px] shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] w-9 h-9 flex items-center justify-center shrink-0"
|
||||||
|
style={{
|
||||||
|
display: logoUrl ? 'none' : 'flex',
|
||||||
|
backgroundColor: theme?.secondary_color || '#23dce1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Shield className="w-5 h-5 text-white" strokeWidth={1.67} />
|
<Shield className="w-5 h-5 text-white" strokeWidth={1.67} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] font-bold text-[#23dce1] tracking-[-0.36px]">
|
<div
|
||||||
QAssure
|
className="text-[18px] font-bold tracking-[-0.36px]"
|
||||||
|
style={{ color: theme?.secondary_color || '#23dce1' }}
|
||||||
|
>
|
||||||
|
{logoUrl ? '' : 'QAssure'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[13px] font-medium text-white uppercase">-</div>
|
<div className="text-[13px] font-medium text-white uppercase">-</div>
|
||||||
<div className="text-[13px] font-medium text-white uppercase">Tenant</div>
|
<div className="text-[13px] font-medium text-white uppercase">Tenant</div>
|
||||||
@ -275,7 +307,11 @@ const TenantLogin = (): ReactElement => {
|
|||||||
id="remember-me"
|
id="remember-me"
|
||||||
checked={rememberMe}
|
checked={rememberMe}
|
||||||
onChange={(e) => setRememberMe(e.target.checked)}
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
className="w-[18px] h-[18px] rounded border-[#d1d5db] bg-[#112868] text-[#112868] focus:ring-2 focus:ring-[#112868]"
|
className="w-[18px] h-[18px] rounded border-[#d1d5db] focus:ring-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme?.primary_color || '#112868',
|
||||||
|
accentColor: theme?.primary_color || '#112868',
|
||||||
|
} as React.CSSProperties & { accentColor?: string }}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="remember-me" className="text-sm font-normal text-[#0f1724] cursor-pointer">
|
<label htmlFor="remember-me" className="text-sm font-normal text-[#0f1724] cursor-pointer">
|
||||||
Remember Me
|
Remember Me
|
||||||
@ -287,7 +323,8 @@ const TenantLogin = (): ReactElement => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate('/tenant/forgot-password');
|
navigate('/tenant/forgot-password');
|
||||||
}}
|
}}
|
||||||
className="text-[13px] font-medium text-[#112868] underline"
|
className="text-[13px] font-medium underline"
|
||||||
|
style={{ color: theme?.primary_color || '#112868' }}
|
||||||
>
|
>
|
||||||
Forgot Password?
|
Forgot Password?
|
||||||
</a>
|
</a>
|
||||||
@ -314,7 +351,8 @@ const TenantLogin = (): ReactElement => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate('/tenant/request-access');
|
navigate('/tenant/request-access');
|
||||||
}}
|
}}
|
||||||
className="text-[13px] font-medium text-[#23dce1] underline"
|
className="text-[13px] font-medium underline"
|
||||||
|
style={{ color: theme?.secondary_color || '#23dce1' }}
|
||||||
>
|
>
|
||||||
Request Access
|
Request Access
|
||||||
</a>
|
</a>
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||||
|
import { useTenantTheme } from '@/hooks/useTenantTheme';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
interface TenantProtectedRouteProps {
|
interface TenantProtectedRouteProps {
|
||||||
@ -9,6 +10,9 @@ interface TenantProtectedRouteProps {
|
|||||||
const TenantProtectedRoute = ({ children }: TenantProtectedRouteProps): ReactElement => {
|
const TenantProtectedRoute = ({ children }: TenantProtectedRouteProps): ReactElement => {
|
||||||
const { isAuthenticated, roles } = useAppSelector((state) => state.auth);
|
const { isAuthenticated, roles } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
|
// Fetch and apply tenant theme
|
||||||
|
useTenantTheme();
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/tenant/login" replace />;
|
return <Navigate to="/tenant/login" replace />;
|
||||||
}
|
}
|
||||||
476
src/pages/tenant/Users.tsx
Normal file
476
src/pages/tenant/Users.tsx
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Layout } from '@/components/layout/Layout';
|
||||||
|
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 getUserInitials = (firstName: string, lastName: string): string => {
|
||||||
|
return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to format date
|
||||||
|
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;
|
||||||
@ -2,7 +2,8 @@ import { Routes, Route } from 'react-router-dom';
|
|||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import NotFound from '@/pages/NotFound';
|
import NotFound from '@/pages/NotFound';
|
||||||
import ProtectedRoute from '@/pages/ProtectedRoute';
|
import ProtectedRoute from '@/pages/ProtectedRoute';
|
||||||
import TenantProtectedRoute from '@/pages/TenantProtectedRoute';
|
import TenantProtectedRoute from '@/pages/tenant/TenantProtectedRoute';
|
||||||
|
import { NavigationInitializer } from '@/components/NavigationInitializer';
|
||||||
import { publicRoutes } from './public-routes';
|
import { publicRoutes } from './public-routes';
|
||||||
import { superAdminRoutes } from './super-admin-routes';
|
import { superAdminRoutes } from './super-admin-routes';
|
||||||
import { tenantAdminRoutes } from './tenant-admin-routes';
|
import { tenantAdminRoutes } from './tenant-admin-routes';
|
||||||
@ -10,7 +11,9 @@ import { tenantAdminRoutes } from './tenant-admin-routes';
|
|||||||
// App Routes Component
|
// App Routes Component
|
||||||
export const AppRoutes = (): ReactElement => {
|
export const AppRoutes = (): ReactElement => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<>
|
||||||
|
<NavigationInitializer />
|
||||||
|
<Routes>
|
||||||
{/* Public Routes */}
|
{/* Public Routes */}
|
||||||
{publicRoutes.map((route) => (
|
{publicRoutes.map((route) => (
|
||||||
<Route key={route.path} path={route.path} element={route.element} />
|
<Route key={route.path} path={route.path} element={route.element} />
|
||||||
@ -44,5 +47,6 @@ export const AppRoutes = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import Login from '@/pages/Login';
|
import Login from '@/pages/Login';
|
||||||
import TenantLogin from '@/pages/TenantLogin';
|
import TenantLogin from '@/pages/tenant/TenantLogin';
|
||||||
import ForgotPassword from '@/pages/ForgotPassword';
|
import ForgotPassword from '@/pages/ForgotPassword';
|
||||||
import ResetPassword from '@/pages/ResetPassword';
|
import ResetPassword from '@/pages/ResetPassword';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import Dashboard from '@/pages/Dashboard';
|
import Dashboard from '@/pages/tenant/Dashboard';
|
||||||
|
import Roles from '@/pages/tenant/Roles';
|
||||||
|
import Settings from '@/pages/tenant/Settings';
|
||||||
|
import Users from '@/pages/tenant/Users';
|
||||||
|
import AuditLogs from '@/pages/tenant/AuditLogs';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
export interface RouteConfig {
|
export interface RouteConfig {
|
||||||
@ -9,9 +13,29 @@ export interface RouteConfig {
|
|||||||
// Tenant Admin routes (requires authentication but NOT super_admin role)
|
// Tenant Admin routes (requires authentication but NOT super_admin role)
|
||||||
export const tenantAdminRoutes: RouteConfig[] = [
|
export const tenantAdminRoutes: RouteConfig[] = [
|
||||||
{
|
{
|
||||||
path: '/tenant/dashboard',
|
path: '/tenant',
|
||||||
element: <Dashboard />, // TODO: Replace with TenantDashboard when created
|
element: <Dashboard />, // TODO: Replace with TenantDashboard when created
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/tenant/roles',
|
||||||
|
element: <Roles />, // TODO: Replace with TenantDashboard when created
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tenant/users',
|
||||||
|
element: <Users />, // TODO: Replace with TenantDashboard when created
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tenant/modules',
|
||||||
|
element: <div>Modules</div>, // TODO: Replace with TenantDashboard when created
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tenant/audit-logs',
|
||||||
|
element: <AuditLogs />, // TODO: Replace with TenantDashboard when created
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tenant/settings',
|
||||||
|
element: <Settings />, // TODO: Replace with TenantDashboard when created
|
||||||
|
},
|
||||||
// Add more tenant admin routes here as needed
|
// Add more tenant admin routes here as needed
|
||||||
// Example:
|
// Example:
|
||||||
// {
|
// {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
||||||
import type { RootState } from '@/store/store';
|
import type { RootState } from '@/store/store';
|
||||||
|
import { navigate } from '@/utils/navigation';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
@ -55,15 +56,19 @@ apiClient.interceptors.response.use(
|
|||||||
|
|
||||||
if (!isAuthEndpoint) {
|
if (!isAuthEndpoint) {
|
||||||
// Handle unauthorized - clear auth and redirect to login (only for non-auth endpoints)
|
// Handle unauthorized - clear auth and redirect to login (only for non-auth endpoints)
|
||||||
|
// Check if user is on a tenant route to determine redirect path
|
||||||
|
const isTenantRoute = window.location.pathname.startsWith('/tenant');
|
||||||
|
const redirectPath = isTenantRoute ? '/tenant/login' : '/';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const store = (window as any).__REDUX_STORE__;
|
const store = (window as any).__REDUX_STORE__;
|
||||||
if (store) {
|
if (store) {
|
||||||
store.dispatch({ type: 'auth/logout' });
|
store.dispatch({ type: 'auth/logout' });
|
||||||
window.location.href = '/';
|
navigate(redirectPath, { replace: true });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently fail if store is not available
|
// Silently fail if store is not available
|
||||||
window.location.href = '/';
|
navigate(redirectPath, { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// For auth endpoints, just reject the promise so the component can handle the error
|
// For auth endpoints, just reject the promise so the component can handle the error
|
||||||
|
|||||||
21
src/services/theme-service.ts
Normal file
21
src/services/theme-service.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import apiClient from './api-client';
|
||||||
|
|
||||||
|
export interface ThemeResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logo_file_path: string | null; // This will be a full URL from the API
|
||||||
|
favicon_file_path: string | null; // This will be a full URL from the API
|
||||||
|
primary_color: string | null;
|
||||||
|
secondary_color: string | null;
|
||||||
|
accent_color: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeService = {
|
||||||
|
getTheme: async (domain: string): Promise<ThemeResponse> => {
|
||||||
|
const response = await apiClient.get<ThemeResponse>(`/tenants/theme?domain=${encodeURIComponent(domain)}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -2,6 +2,7 @@ import { configureStore } from '@reduxjs/toolkit';
|
|||||||
import { persistStore, persistReducer } from 'redux-persist';
|
import { persistStore, persistReducer } from 'redux-persist';
|
||||||
import storage from 'redux-persist/lib/storage';
|
import storage from 'redux-persist/lib/storage';
|
||||||
import authReducer from './authSlice';
|
import authReducer from './authSlice';
|
||||||
|
import themeReducer from './themeSlice';
|
||||||
|
|
||||||
// Persist config for auth slice only
|
// Persist config for auth slice only
|
||||||
const authPersistConfig = {
|
const authPersistConfig = {
|
||||||
@ -15,6 +16,7 @@ const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);
|
|||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
auth: persistedAuthReducer,
|
auth: persistedAuthReducer,
|
||||||
|
theme: themeReducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
|
|||||||
139
src/store/themeSlice.ts
Normal file
139
src/store/themeSlice.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { themeService } from '@/services/theme-service';
|
||||||
|
import { extractDomainFromUrl, applyThemeColors } from '@/utils/theme';
|
||||||
|
|
||||||
|
export interface ThemeData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logo_file_path: string | null;
|
||||||
|
favicon_file_path: string | null;
|
||||||
|
primary_color: string | null;
|
||||||
|
secondary_color: string | null;
|
||||||
|
accent_color: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
theme: ThemeData | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
faviconUrl: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
isInitialized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default theme (super admin theme)
|
||||||
|
const defaultTheme: ThemeData = {
|
||||||
|
id: '',
|
||||||
|
name: 'Default',
|
||||||
|
logo_file_path: null,
|
||||||
|
favicon_file_path: null,
|
||||||
|
primary_color: '#112868',
|
||||||
|
secondary_color: '#23DCE1',
|
||||||
|
accent_color: '#084CC8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: ThemeState = {
|
||||||
|
theme: null,
|
||||||
|
logoUrl: null,
|
||||||
|
faviconUrl: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isInitialized: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Async thunk to fetch theme
|
||||||
|
export const fetchThemeAsync = createAsyncThunk<
|
||||||
|
ThemeData,
|
||||||
|
string | undefined,
|
||||||
|
{ rejectValue: { message: string } }
|
||||||
|
>('theme/fetch', async (domain, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const domainToUse = domain || extractDomainFromUrl();
|
||||||
|
if (!domainToUse) {
|
||||||
|
return rejectWithValue({ message: 'Domain not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await themeService.getTheme(domainToUse);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
return rejectWithValue({ message: 'Failed to fetch theme' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue({
|
||||||
|
message: error?.response?.data?.error?.message || error?.message || 'Failed to fetch theme',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const themeSlice = createSlice({
|
||||||
|
name: 'theme',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setDefaultTheme: (state) => {
|
||||||
|
state.theme = defaultTheme;
|
||||||
|
state.logoUrl = null;
|
||||||
|
state.faviconUrl = null;
|
||||||
|
applyThemeColors(defaultTheme.primary_color, defaultTheme.secondary_color, defaultTheme.accent_color);
|
||||||
|
},
|
||||||
|
clearTheme: (state) => {
|
||||||
|
state.theme = null;
|
||||||
|
state.logoUrl = null;
|
||||||
|
state.faviconUrl = null;
|
||||||
|
state.error = null;
|
||||||
|
applyThemeColors(defaultTheme.primary_color, defaultTheme.secondary_color, defaultTheme.accent_color);
|
||||||
|
},
|
||||||
|
updateTheme: (state, action: PayloadAction<Partial<ThemeData>>) => {
|
||||||
|
if (state.theme) {
|
||||||
|
state.theme = { ...state.theme, ...action.payload };
|
||||||
|
if (action.payload.logo_file_path !== undefined) {
|
||||||
|
state.logoUrl = action.payload.logo_file_path;
|
||||||
|
}
|
||||||
|
if (action.payload.favicon_file_path !== undefined) {
|
||||||
|
state.faviconUrl = action.payload.favicon_file_path;
|
||||||
|
}
|
||||||
|
const primaryColor = action.payload.primary_color ?? state.theme.primary_color;
|
||||||
|
const secondaryColor = action.payload.secondary_color ?? state.theme.secondary_color;
|
||||||
|
const accentColor = action.payload.accent_color ?? state.theme.accent_color;
|
||||||
|
if (primaryColor || secondaryColor || accentColor) {
|
||||||
|
applyThemeColors(primaryColor, secondaryColor, accentColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchThemeAsync.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchThemeAsync.fulfilled, (state, action: PayloadAction<ThemeData>) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.theme = action.payload;
|
||||||
|
// Use URLs directly from API response (logo_file_path and favicon_file_path contain full URLs)
|
||||||
|
state.logoUrl = action.payload.logo_file_path;
|
||||||
|
state.faviconUrl = action.payload.favicon_file_path;
|
||||||
|
state.error = null;
|
||||||
|
state.isInitialized = true;
|
||||||
|
|
||||||
|
// Apply theme colors
|
||||||
|
applyThemeColors(
|
||||||
|
action.payload.primary_color,
|
||||||
|
action.payload.secondary_color,
|
||||||
|
action.payload.accent_color
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.addCase(fetchThemeAsync.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload?.message || 'Failed to fetch theme';
|
||||||
|
// Use default theme on error
|
||||||
|
state.theme = defaultTheme;
|
||||||
|
state.logoUrl = null;
|
||||||
|
state.faviconUrl = null;
|
||||||
|
state.isInitialized = true;
|
||||||
|
applyThemeColors(defaultTheme.primary_color, defaultTheme.secondary_color, defaultTheme.accent_color);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setDefaultTheme, clearTheme, updateTheme } = themeSlice.actions;
|
||||||
|
export default themeSlice.reducer;
|
||||||
@ -40,7 +40,7 @@ export interface CreateUserRequest {
|
|||||||
last_name: string;
|
last_name: string;
|
||||||
status: 'active' | 'suspended' | 'deleted';
|
status: 'active' | 'suspended' | 'deleted';
|
||||||
auth_provider: 'local';
|
auth_provider: 'local';
|
||||||
tenant_id: string;
|
tenant_id?: string; // Optional - backend handles it automatically for tenant admin users
|
||||||
role_id: string;
|
role_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
src/utils/navigation.ts
Normal file
34
src/utils/navigation.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Navigation utility for programmatic navigation outside React components
|
||||||
|
* This allows us to use React Router's navigate function in services/interceptors
|
||||||
|
*/
|
||||||
|
|
||||||
|
type NavigateFunction = (to: string, options?: { replace?: boolean }) => void;
|
||||||
|
|
||||||
|
let navigateFunction: NavigateFunction | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the navigate function (should be called once during app initialization)
|
||||||
|
*/
|
||||||
|
export const setNavigate = (navigate: NavigateFunction): void => {
|
||||||
|
navigateFunction = navigate;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the navigate function
|
||||||
|
*/
|
||||||
|
export const getNavigate = (): NavigateFunction | null => {
|
||||||
|
return navigateFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a path (wrapper around React Router's navigate)
|
||||||
|
*/
|
||||||
|
export const navigate = (to: string, options?: { replace?: boolean }): void => {
|
||||||
|
if (navigateFunction) {
|
||||||
|
navigateFunction(to, options);
|
||||||
|
} else {
|
||||||
|
// Fallback to window.location if navigate is not available
|
||||||
|
window.location.href = to;
|
||||||
|
}
|
||||||
|
};
|
||||||
76
src/utils/theme.ts
Normal file
76
src/utils/theme.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Extract domain from current URL for theme API
|
||||||
|
* Example: https://tenant-admin.localhost:5173/tenant/login -> https://tenant-admin.localhost:5173/tenant
|
||||||
|
*/
|
||||||
|
export const extractDomainFromUrl = (): string => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = window.location.href;
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
// Get protocol (http: or https:)
|
||||||
|
const protocol = urlObj.protocol; // e.g., "http:" or "https:"
|
||||||
|
// Get host (hostname + port) e.g., "tenant-admin.localhost:5173"
|
||||||
|
const host = urlObj.host;
|
||||||
|
const pathname = urlObj.pathname; // e.g., "/tenant/login"
|
||||||
|
|
||||||
|
// Extract base path - if pathname starts with /tenant, use /tenant, otherwise use empty
|
||||||
|
let basePath = '';
|
||||||
|
if (pathname.startsWith('/tenant')) {
|
||||||
|
// Get /tenant and any sub-path before the last segment
|
||||||
|
const pathSegments = pathname.split('/').filter(Boolean);
|
||||||
|
if (pathSegments[0] === 'tenant') {
|
||||||
|
// If there are more segments, we still want just /tenant
|
||||||
|
// But if user wants the full path, we can adjust
|
||||||
|
basePath = '/tenant';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return with protocol: http://tenant-admin.localhost:5173/tenant
|
||||||
|
return `${protocol}//${host}${basePath}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to extract domain from URL:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply theme colors to CSS variables
|
||||||
|
*/
|
||||||
|
export const applyThemeColors = (
|
||||||
|
primaryColor: string | null,
|
||||||
|
secondaryColor: string | null,
|
||||||
|
accentColor: string | null
|
||||||
|
): void => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
if (primaryColor) {
|
||||||
|
root.style.setProperty('--theme-primary', primaryColor);
|
||||||
|
} else {
|
||||||
|
root.style.removeProperty('--theme-primary');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secondaryColor) {
|
||||||
|
root.style.setProperty('--theme-secondary', secondaryColor);
|
||||||
|
} else {
|
||||||
|
root.style.removeProperty('--theme-secondary');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accentColor) {
|
||||||
|
root.style.setProperty('--theme-accent', accentColor);
|
||||||
|
} else {
|
||||||
|
root.style.removeProperty('--theme-accent');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full URL for logo/favicon
|
||||||
|
*/
|
||||||
|
export const getThemeImageUrl = (filePath: string | null): string | null => {
|
||||||
|
if (!filePath) return null;
|
||||||
|
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||||
|
return `${apiBaseUrl}/uploads/${filePath}`;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user