Implement webhook URL functionality in NewModuleModal and ViewModuleModal. Update Modules component to include launch module feature for super admins, enhancing module management capabilities. Refactor Sidebar for improved layout and responsiveness.

This commit is contained in:
Yashwin 2026-02-03 18:05:26 +05:30
parent 803ca1cd1a
commit 73e533694e
8 changed files with 172 additions and 114 deletions

View File

@ -1,11 +1,11 @@
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { import {
LayoutDashboard, LayoutDashboard,
Building2, Building2,
Users, Users,
Package, Package,
FileText, FileText,
Settings, Settings,
HelpCircle, HelpCircle,
X, X,
Shield Shield
@ -77,7 +77,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
}; };
const isSuperAdmin = isSuperAdminCheck(); const isSuperAdmin = isSuperAdminCheck();
// Fetch theme if tenant admin // Fetch theme if tenant admin
if (!isSuperAdmin) { if (!isSuperAdmin) {
useTenantTheme(); useTenantTheme();
@ -90,16 +90,16 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
} }
const allowedActions = requiredAction ? [requiredAction] : ['*', 'read']; const allowedActions = requiredAction ? [requiredAction] : ['*', 'read'];
return permissions.some((perm) => { return permissions.some((perm) => {
// Check if resource matches (exact match or wildcard) // Check if resource matches (exact match or wildcard)
const resourceMatches = perm.resource === resource || perm.resource === '*'; const resourceMatches = perm.resource === resource || perm.resource === '*';
// Check if action matches (exact match or wildcard) // Check if action matches (exact match or wildcard)
const actionMatches = allowedActions.some( const actionMatches = allowedActions.some(
(allowedAction) => perm.action === allowedAction || perm.action === '*' (allowedAction) => perm.action === allowedAction || perm.action === '*'
); );
return resourceMatches && actionMatches; return resourceMatches && actionMatches;
}); });
}; };
@ -109,13 +109,13 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
if (isSuperAdmin) { if (isSuperAdmin) {
return items; // Show all items for super admin return items; // Show all items for super admin
} }
return items.filter((item) => { return items.filter((item) => {
// If no required permission, always show (e.g., Dashboard, Modules, Settings) // If no required permission, always show (e.g., Dashboard, Modules, Settings)
if (!item.requiredPermission) { if (!item.requiredPermission) {
return true; return true;
} }
return hasPermission( return hasPermission(
item.requiredPermission.resource, item.requiredPermission.resource,
item.requiredPermission.action item.requiredPermission.action
@ -134,8 +134,8 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => ( const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
<div className="w-full"> <div className="w-full">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="pb-1 px-3"> <div className="pb-1 px-2 md:px-2 lg:px-3">
<div className="text-[11px] font-semibold text-[#6b7280] uppercase tracking-[0.88px]"> <div className="text-[10px] md:text-[10px] lg:text-[11px] font-semibold text-[#6b7280] uppercase tracking-[0.88px]">
{title} {title}
</div> </div>
</div> </div>
@ -154,7 +154,7 @@ 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 md:gap-2 lg:gap-2.5 px-2 md:px-2 lg:px-3 py-2 rounded-md transition-colors min-h-[44px]',
isActive isActive
? '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'
@ -162,14 +162,14 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
style={ style={
isActive isActive
? { ? {
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868', backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868',
color: !isSuperAdmin && theme?.secondary_color ? theme.secondary_color : '#23dce1', color: !isSuperAdmin && theme?.secondary_color ? theme.secondary_color : '#23dce1',
} }
: undefined : 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-xs md:text-xs lg:text-[13px] font-medium whitespace-nowrap">{item.label}</span>
</Link> </Link>
); );
})} })}
@ -192,9 +192,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<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">
{!isSuperAdmin && logoUrl ? ( {!isSuperAdmin && logoUrl ? (
<img <img
src={logoUrl} src={logoUrl}
alt="Logo" alt="Logo"
className="h-9 w-auto max-w-[180px] object-contain" className="h-9 w-auto max-w-[180px] object-contain"
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = 'none'; e.currentTarget.style.display = 'none';
@ -203,9 +203,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
}} }}
/> />
) : null} ) : null}
<div <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" 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={{ style={{
display: (!isSuperAdmin && logoUrl) ? 'none' : 'flex', display: (!isSuperAdmin && logoUrl) ? 'none' : 'flex',
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868', backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868',
}} }}
@ -241,55 +241,55 @@ 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-3 md:p-3 lg:p-[17px] w-[220px] md:w-[220px] lg:w-[240px] h-full max-h-screen flex-col gap-4 md:gap-4 lg:gap-6 shrink-0 overflow-hidden"> <aside className="hidden md:flex bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-2 md:p-2.5 lg:p-3 xl:p-[17px] w-[160px] md:w-[160px] lg:w-[180px] xl:w-[240px] h-full max-h-screen flex-col gap-3 md:gap-3.5 lg:gap-4 xl:gap-6 shrink-0 overflow-hidden">
{/* Logo */} {/* Logo */}
<div className="w-full md:w-[190px] lg:w-[206px] shrink-0"> <div className="w-full md:w-[140px] lg:w-[160px] xl:w-[206px] shrink-0">
<div className="flex gap-3 items-center px-2"> <div className="flex gap-3 items-center px-2">
{!isSuperAdmin && logoUrl ? ( {!isSuperAdmin && logoUrl ? (
<img <img
src={logoUrl} src={logoUrl}
alt="Logo" alt="Logo"
className="h-9 w-auto max-w-[180px] object-contain" className="h-9 w-auto max-w-[180px] object-contain"
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = 'none'; e.currentTarget.style.display = 'none';
const fallback = e.currentTarget.nextElementSibling as HTMLElement; const fallback = e.currentTarget.nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = 'flex'; 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',
}} }}
/> >
) : null} <Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
<div </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" <div className="text-base md:text-base lg:text-base xl:text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
style={{ {(!isSuperAdmin && logoUrl) ? '' : 'QAssure'}
display: (!isSuperAdmin && logoUrl) ? 'none' : 'flex', </div>
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868',
}}
>
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
</div>
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'}
</div> </div>
</div> </div>
</div>
{/* Platform Menu */} {/* Platform Menu */}
{platformMenu.length > 0 && ( {platformMenu.length > 0 && (
<MenuSection title="Platform" items={platformMenu} /> <MenuSection title="Platform" items={platformMenu} />
)} )}
{/* System Menu */} {/* System Menu */}
{systemMenu.length > 0 && ( {systemMenu.length > 0 && (
<MenuSection title="System" items={systemMenu} /> <MenuSection title="System" items={systemMenu} />
)} )}
{/* Support Center */} {/* Support Center */}
<div className="mt-auto w-full"> <div className="mt-auto w-full">
<button className="w-full bg-white border border-[rgba(0,0,0,0.08)] rounded-md px-[13px] py-[9px] flex gap-2.5 items-center hover:bg-gray-50 transition-colors"> <button className="w-full bg-white border border-[rgba(0,0,0,0.08)] rounded-md px-2 md:px-2.5 lg:px-[13px] py-[9px] flex gap-2 md:gap-2 lg:gap-2.5 items-center hover:bg-gray-50 transition-colors">
<HelpCircle className="w-4 h-4 shrink-0 text-[#0f1724]" /> <HelpCircle className="w-4 h-4 shrink-0 text-[#0f1724]" />
<span className="text-[13px] font-medium text-[#0f1724]">Support Center</span> <span className="text-xs md:text-xs lg:text-[13px] font-medium text-[#0f1724]">Support Center</span>
</button> </button>
</div> </div>
</aside> </aside>
</> </>
); );
}; };

View File

@ -30,6 +30,7 @@ const newModuleSchema = z.object({
.min(1, 'runtime_language is required') .min(1, 'runtime_language is required')
.max(50, 'runtime_language must be at most 50 characters'), .max(50, 'runtime_language must be at most 50 characters'),
framework: z.string().max(50, 'framework must be at most 50 characters').optional().nullable(), framework: z.string().max(50, 'framework must be at most 50 characters').optional().nullable(),
webhookurl: z.string().max(500, "webhookurl must be at most 500 characters").url("Invalid URL format").nullable(),
base_url: z base_url: z
.string() .string()
.min(1, 'base_url is required') .min(1, 'base_url is required')
@ -85,6 +86,7 @@ export const NewModuleModal = ({
defaultValues: { defaultValues: {
description: null, description: null,
framework: null, framework: null,
webhookurl: null,
endpoints: null, endpoints: null,
kafka_topics: null, kafka_topics: null,
cpu_request: null, cpu_request: null,
@ -112,6 +114,7 @@ export const NewModuleModal = ({
version: '', version: '',
runtime_language: '', runtime_language: '',
framework: null, framework: null,
webhookurl: null,
base_url: '', base_url: '',
health_endpoint: '', health_endpoint: '',
endpoints: null, endpoints: null,
@ -155,7 +158,7 @@ export const NewModuleModal = ({
if (error?.response?.data?.details && Array.isArray(error.response.data.details)) { if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
const validationErrors = error.response.data.details; const validationErrors = error.response.data.details;
validationErrors.forEach((detail: { path: string; message: string }) => { validationErrors.forEach((detail: { path: string; message: string }) => {
if (detail.path === 'name' || detail.path === 'module_id' || detail.path === 'description' || detail.path === 'version' || detail.path === 'runtime_language' || detail.path === 'framework' || detail.path === 'base_url' || detail.path === 'health_endpoint' || detail.path === 'endpoints' || detail.path === 'kafka_topics' || detail.path === 'cpu_request' || detail.path === 'cpu_limit' || detail.path === 'memory_request' || detail.path === 'memory_limit' || detail.path === 'min_replicas' || detail.path === 'max_replicas' || detail.path === 'last_health_check' || detail.path === 'health_status' || detail.path === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') { if (detail.path === 'name' || detail.path === 'module_id' || detail.path === 'description' || detail.path === 'version' || detail.path === 'runtime_language' || detail.path === 'framework' || detail.path === 'webhookurl' || detail.path === 'base_url' || detail.path === 'health_endpoint' || detail.path === 'endpoints' || detail.path === 'kafka_topics' || detail.path === 'cpu_request' || detail.path === 'cpu_limit' || detail.path === 'memory_request' || detail.path === 'memory_limit' || detail.path === 'min_replicas' || detail.path === 'max_replicas' || detail.path === 'last_health_check' || detail.path === 'health_status' || detail.path === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') {
setError(detail.path as keyof NewModuleFormData, { setError(detail.path as keyof NewModuleFormData, {
type: 'server', type: 'server',
message: detail.message, message: detail.message,
@ -352,6 +355,18 @@ export const NewModuleModal = ({
/> />
</div> </div>
</div> </div>
<div className="flex gap-5">
<div className="flex-1">
<FormField
label="Webhook URL"
placeholder="e.g., https://example.com/webhook"
error={errors.webhookurl?.message}
{...register('webhookurl')}
/>
</div>
<div className="flex-1">
</div>
</div>
</div> </div>
{/* URL Configuration Section */} {/* URL Configuration Section */}

View File

@ -152,6 +152,10 @@ export const ViewModuleModal = ({
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Health Endpoint</label> <label className="text-xs font-medium text-[#6b7280] mb-1 block">Health Endpoint</label>
<p className="text-sm font-medium text-[#0e1b2a]">{module.health_endpoint}</p> <p className="text-sm font-medium text-[#0e1b2a]">{module.health_endpoint}</p>
</div> </div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Webhook URL</label>
<p className="text-sm font-medium text-[#0e1b2a]">{module.webhookurl || '-'}</p>
</div>
</div> </div>
</div> </div>

View File

@ -197,7 +197,7 @@ const Modules = (): ReactElement => {
label: 'Actions', label: 'Actions',
align: 'right', align: 'right',
render: (module) => ( render: (module) => (
<div className="flex justify-end"> <div className="flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={() => handleViewModule(module.id)} onClick={() => handleViewModule(module.id)}

View File

@ -9,6 +9,7 @@ import {
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppSelector } from '@/hooks/redux-hooks';
import { tenantService } from '@/services/tenant-service'; import { tenantService } from '@/services/tenant-service';
import type { AssignedModule } from '@/types/tenant'; import type { AssignedModule } from '@/types/tenant';
import { moduleService } from '@/services/module-service';
// Helper function to get status badge variant // Helper function to get status badge variant
const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => { const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => {
@ -26,7 +27,8 @@ const getStatusVariant = (status: string | null): 'success' | 'failure' | 'proce
}; };
const Modules = (): ReactElement => { const Modules = (): ReactElement => {
const tenantId = useAppSelector((state) => state.auth.tenantId); const { roles, tenantId } = useAppSelector((state) => state.auth);
// const tenantId = useAppSelector((state) => state.auth.tenantId);
const [modules, setModules] = useState<AssignedModule[]>([]); const [modules, setModules] = useState<AssignedModule[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -58,6 +60,33 @@ const Modules = (): ReactElement => {
fetchTenantModules(); fetchTenantModules();
}, [tenantId]); }, [tenantId]);
// Launch module handler
const handleLaunchModule = async (moduleId: string): Promise<void> => {
try {
// 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');
// If super_admin, pass tenantId as query param
const launchTenantId = isSuperAdmin ? tenantId : null;
const response = await moduleService.launch(moduleId, launchTenantId);
if (response.success && response.data.launch_url) {
window.open(response.data.launch_url, '_blank', 'noopener,noreferrer');
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to launch module');
}
};
// Define table columns // Define table columns
const columns: Column<AssignedModule>[] = [ const columns: Column<AssignedModule>[] = [
{ {
@ -101,6 +130,22 @@ const Modules = (): ReactElement => {
), ),
mobileLabel: 'URL', mobileLabel: 'URL',
}, },
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (module) => (
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => handleLaunchModule(module.id)}
className="text-sm text-[#FFC107] hover:text-[#FFC107] font-medium transition-colors cursor-pointer"
>
Lunch
</button>
</div>
),
},
]; ];
// Mobile card renderer // Mobile card renderer
@ -131,6 +176,15 @@ const Modules = (): ReactElement => {
{module.base_url || 'N/A'} {module.base_url || 'N/A'}
</p> </p>
</div> </div>
<div className="col-span-2">
<button
type="button"
onClick={() => handleLaunchModule(module.id)}
className="text-sm text-[#FFC107] hover:text-[#FFC107] font-medium transition-colors"
>
Lunch
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,5 +1,5 @@
import apiClient from './api-client'; import apiClient from './api-client';
import type { ModulesResponse, GetModuleResponse, CreateModuleRequest, CreateModuleResponse } from '@/types/module'; import type { ModulesResponse, GetModuleResponse, CreateModuleRequest, CreateModuleResponse, LaunchModuleResponse } from '@/types/module';
export const moduleService = { export const moduleService = {
getAll: async ( getAll: async (
@ -66,4 +66,13 @@ export const moduleService = {
const response = await apiClient.post<CreateModuleResponse>('/modules', data); const response = await apiClient.post<CreateModuleResponse>('/modules', data);
return response.data; return response.data;
}, },
launch: async (id: string, tenantId?: string | null): Promise<LaunchModuleResponse> => {
const params = new URLSearchParams();
if (tenantId) {
params.append('tenant_id', tenantId);
}
const url = `/modules/${id}/launch${params.toString() ? `?${params.toString()}` : ''}`;
const response = await apiClient.post<LaunchModuleResponse>(url);
return response.data;
},
}; };

View File

@ -7,6 +7,7 @@ export interface Module {
status: string; status: string;
runtime_language: string | null; runtime_language: string | null;
framework: string | null; framework: string | null;
webhookurl: string | null;
base_url: string; base_url: string;
health_endpoint: string; health_endpoint: string;
endpoints: string[] | null; endpoints: string[] | null;
@ -83,3 +84,20 @@ export interface CreateModuleResponse {
}; };
message?: string; message?: string;
} }
export interface LaunchModuleResponse {
success: boolean;
data: {
launch_url: string;
expires_in: number;
module: {
id: string;
module_id: string;
name: string;
};
tenant: {
id: string;
name: string;
};
};
}

View File

@ -11,46 +11,4 @@ export default defineConfig({
"@": path.resolve(__dirname, "src"), "@": path.resolve(__dirname, "src"),
}, },
}, },
// build: {
// rollupOptions: {
// output: {
// manualChunks(id) {
// // Feature-based chunks - only for source files
// if (!id.includes('node_modules')) {
// if (id.includes('/src/pages/superadmin/')) {
// return 'superadmin';
// }
// if (id.includes('/src/pages/tenant/')) {
// return 'tenant';
// }
// return;
// }
// // Vendor chunks - group React ecosystem (including Redux) together
// // to avoid circular dependencies
// if (
// id.includes('node_modules/react') ||
// id.includes('node_modules/react-dom') ||
// id.includes('node_modules/react-router') ||
// id.includes('node_modules/@reduxjs') ||
// id.includes('node_modules/redux') ||
// id.includes('node_modules/react-redux') ||
// id.includes('node_modules/scheduler') ||
// id.includes('node_modules/object-assign')
// ) {
// return 'react-vendor';
// }
// // UI libraries
// if (id.includes('node_modules/lucide-react')) {
// return 'ui-vendor';
// }
// // All other node_modules go to vendor
// return 'vendor';
// },
// },
// },
// chunkSizeWarningLimit: 600,
// },
}) })