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:
Yashwin 2026-01-29 17:27:34 +05:30
parent c6ee8c7032
commit bb5a086110
35 changed files with 2764 additions and 134 deletions

View 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;
};

View File

@ -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 });
} }
}; };

View File

@ -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

View File

@ -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>

View File

@ -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) {

View File

@ -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

View File

@ -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);

View File

@ -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}
> >

View File

@ -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,

View File

@ -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}
> >

View File

@ -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);

View 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]);
};

View File

@ -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 >
); );

View File

@ -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,
}, },
}, },
}; };

View File

@ -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>
)} )}

View File

@ -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) {

View File

@ -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,

View File

@ -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" />

View 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;

View 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
View 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;

View 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;

View File

@ -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>

View File

@ -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
View 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;

View File

@ -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>
</>
); );
}; };

View File

@ -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';

View File

@ -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:
// { // {

View File

@ -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

View 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;
},
};

View File

@ -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
View 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;

View File

@ -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
View 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
View 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}`;
};