feat: add module update functionality, webhook sync, and API key reissue support with new management modals

This commit is contained in:
Yashwin 2026-04-21 14:58:46 +05:30
parent 92ea75ce77
commit bcd029950f
9 changed files with 996 additions and 111 deletions

View File

@ -0,0 +1,208 @@
import { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Modal, FormField, PrimaryButton, SecondaryButton } from '@/components/shared';
import { Copy, Check, Key, Loader2 } from 'lucide-react';
import { showToast } from '@/utils/toast';
import { moduleService } from '@/services/module-service';
import type { Module } from '@/types/module';
const reissueSchema = z.object({
webhookurl: z
.string()
.min(1, 'Webhook URL is required')
.url('Invalid URL format')
.max(500, 'Webhook URL must be at most 500 characters'),
});
type ReissueFormData = z.infer<typeof reissueSchema>;
interface ApikeyReissueModalProps {
isOpen: boolean;
onClose: () => void;
moduleId: string | null;
onLoadModule: (id: string) => Promise<Module>;
}
export const ApikeyReissueModal = ({
isOpen,
onClose,
moduleId,
onLoadModule,
}: ApikeyReissueModalProps): ReactElement | null => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [apiKey, setApiKey] = useState<string | null>(null);
const [copied, setCopied] = useState<boolean>(false);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<ReissueFormData>({
resolver: zodResolver(reissueSchema),
});
useEffect(() => {
if (isOpen && moduleId) {
const load = async () => {
try {
setIsLoading(true);
const data = await onLoadModule(moduleId);
reset({ webhookurl: data.webhookurl || '' });
} catch (err: any) {
showToast.error(err?.message || 'Failed to load module details');
} finally {
setIsLoading(false);
}
};
load();
} else {
setApiKey(null);
setCopied(false);
}
}, [isOpen, moduleId, onLoadModule, reset]);
const handleFormSubmit = async (data: ReissueFormData): Promise<void> => {
if (!moduleId) return;
try {
setIsSubmitting(true);
const response = await moduleService.reissueApiKey(moduleId, data.webhookurl);
if (response.success && response.data?.api_key?.key) {
setApiKey(response.data.api_key.key);
showToast.success(
'API Key Reissued',
'A new API key has been generated and sent to the webhook URL.'
);
}
} catch (err: any) {
showToast.error(err?.response?.data?.error?.message || err?.message || 'Failed to reissue API key');
} finally {
setIsSubmitting(false);
}
};
const handleCopyApiKey = async (): Promise<void> => {
if (apiKey) {
try {
await navigator.clipboard.writeText(apiKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
showToast.success('API key copied to clipboard');
} catch (err) {
showToast.error('Failed to copy API key');
}
}
};
const handleClose = (): void => {
if (!isSubmitting) {
onClose();
}
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Reissue X-API-Key"
description="Regenerate the module's API key and send it to the configured webhook."
maxWidth="md"
footer={
<div className="flex justify-end gap-3 w-full">
<SecondaryButton
type="button"
onClick={handleClose}
disabled={isSubmitting}
className="px-4 py-2.5 text-sm"
>
{apiKey ? 'Close' : 'Cancel'}
</SecondaryButton>
{!apiKey && (
<PrimaryButton
type="button"
onClick={handleSubmit(handleFormSubmit)}
disabled={isSubmitting || isLoading}
size="default"
className="px-4 py-2.5 text-sm flex items-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Reissuing...</span>
</>
) : (
<>
<Key className="w-4 h-4" />
<span>Reissue & Send Key</span>
</>
)}
</PrimaryButton>
)}
</div>
}
>
<div className="p-5">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
</div>
) : (
<>
{apiKey ? (
<div className="p-4 bg-[rgba(34,197,94,0.1)] border border-[#22c55e] rounded-md">
<div className="mb-3">
<h3 className="text-sm font-semibold text-[#0f1724] mb-2">
New API Key Generated
</h3>
<p className="text-sm text-[#6b7280] mb-3">
The new API key has been generated and sent to the module. Store it securely - this is the only time you will see this key here.
</p>
</div>
<div className="flex items-center gap-2 p-3 bg-white border border-[rgba(0,0,0,0.08)] rounded-md">
<code className="flex-1 text-sm font-mono text-[#0f1724] break-all">{apiKey}</code>
<button
type="button"
onClick={handleCopyApiKey}
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#112868] text-white rounded-md text-xs font-medium hover:bg-[#0d1f4e] transition-colors cursor-pointer"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit(handleFormSubmit)} className="flex flex-col gap-5">
<div className="bg-[#f8fafc] border border-[#e2e8f0] rounded-md p-4 flex gap-3">
<Key className="w-5 h-5 text-[#112868] shrink-0" />
<div>
<h4 className="text-sm font-semibold text-[#0f1724]">Security Notice</h4>
<p className="text-xs text-[#64748b] mt-1 leading-relaxed">
Reissuing the API key will immediately invalidate the previous key. The new key will be sent via POST request to the webhook URL below.
</p>
</div>
</div>
<FormField
label="X-API-Key Webhook URL"
type="url"
required
placeholder="https://module-app.com/api/v1/webhooks/api-key"
error={errors.webhookurl?.message}
{...register('webhookurl')}
/>
<div className="text-xs text-[#94a3b8] italic">
Note: This URL will be updated in the module configuration if it differs from the current one.
</div>
</form>
)}
</>
)}
</div>
</Modal>
);
};

View File

@ -0,0 +1,295 @@
import { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Modal, FormField, PrimaryButton, SecondaryButton } from '@/components/shared';
import { Copy, Check, Loader2 } from 'lucide-react';
import { showToast } from '@/utils/toast';
import type { Module, UpdateModuleRequest } from '@/types/module';
// Validation schema for update - matches backend validation
const updateModuleSchema = z.object({
name: z
.string()
.min(1, 'name is required')
.min(3, 'name must be at least 3 characters')
.max(100, 'name must be at most 100 characters'),
description: z.string().max(1000, 'description must be at most 1000 characters').optional().nullable(),
frontend_base_url: z
.string()
.min(1, 'frontend_base_url is required')
.max(255, 'frontend_base_url must be at most 255 characters')
.url('Invalid URL format'),
backend_base_url: z
.string()
.min(1, 'backend_base_url is required')
.max(255, 'backend_base_url must be at most 255 characters')
.url('Invalid URL format'),
health_endpoint: z
.string()
.min(1, 'health_endpoint is required')
.max(255, 'health_endpoint must be at most 255 characters'),
cpu_request: z.string().max(20, 'cpu_request must be at most 20 characters').optional().nullable(),
cpu_limit: z.string().max(20, 'cpu_limit must be at most 20 characters').optional().nullable(),
memory_request: z.string().max(20, 'memory_request must be at most 20 characters').optional().nullable(),
memory_limit: z.string().max(20, 'memory_limit must be at most 20 characters').optional().nullable(),
min_replicas: z.number().int().min(1, 'min_replicas must be at least 1').max(50, 'min_replicas must be at most 50').optional().nullable(),
max_replicas: z.number().int().min(1, 'max_replicas must be at least 1').max(50, 'max_replicas must be at most 50').optional().nullable(),
metadata: z.any().optional().nullable(),
});
type EditModuleFormData = z.infer<typeof updateModuleSchema>;
interface EditModuleModalProps {
isOpen: boolean;
onClose: () => void;
module: Module | null;
onSubmit: (data: UpdateModuleRequest) => Promise<any>;
isLoading?: boolean;
}
export const EditModuleModal = ({
isOpen,
onClose,
module,
onSubmit,
isLoading = false,
}: EditModuleModalProps): ReactElement | null => {
const [apiKey, setApiKey] = useState<string | null>(null);
const [copied, setCopied] = useState<boolean>(false);
const {
register,
handleSubmit,
reset,
setError,
clearErrors,
formState: { errors },
} = useForm<EditModuleFormData>({
resolver: zodResolver(updateModuleSchema),
});
// Reset form when modal opens or module changes
useEffect(() => {
if (isOpen && module) {
reset({
name: module.name,
description: module.description,
frontend_base_url: module.frontend_base_url,
backend_base_url: module.backend_base_url,
health_endpoint: module.health_endpoint,
cpu_request: module.cpu_request,
cpu_limit: module.cpu_limit,
memory_request: module.memory_request,
memory_limit: module.memory_limit,
min_replicas: module.min_replicas,
max_replicas: module.max_replicas,
metadata: module.metadata,
});
clearErrors();
setApiKey(null);
setCopied(false);
}
}, [isOpen, module, reset, clearErrors]);
const handleFormSubmit = async (data: EditModuleFormData): Promise<void> => {
if (!module) return;
clearErrors();
try {
const response = await onSubmit(data);
// Backend might return a new API key if webhookurl changed
if (response?.data?.api_key?.key) {
setApiKey(response.data.api_key.key);
showToast.success(
'Module updated successfully',
'Webhook URL changed. Your new API key has been generated and sent to the module. Please copy and store it securely.'
);
} else {
showToast.success('Module updated successfully');
onClose();
}
} catch (error: any) {
const errorMessage = error?.response?.data?.error?.message || error?.message || 'Failed to update module';
setError('root', { type: 'server', message: errorMessage });
}
};
const handleCopyApiKey = async (): Promise<void> => {
if (apiKey) {
try {
await navigator.clipboard.writeText(apiKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
showToast.success('API key copied to clipboard');
} catch (err) {
showToast.error('Failed to copy API key');
}
}
};
const handleClose = (): void => {
setApiKey(null);
setCopied(false);
onClose();
};
if (!module) return null;
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Edit Module Configuration"
description={`Update configuration for ${module.name} (${module.module_id})`}
maxWidth="lg"
footer={
<div className="flex justify-end gap-3 w-full">
<SecondaryButton
type="button"
onClick={handleClose}
disabled={isLoading}
className="px-4 py-2.5 text-sm"
>
{apiKey ? 'Close' : 'Cancel'}
</SecondaryButton>
{!apiKey && (
<PrimaryButton
type="button"
onClick={handleSubmit(handleFormSubmit)}
disabled={isLoading}
size="default"
className="px-4 py-2.5 text-sm flex items-center gap-2"
>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{isLoading ? 'Updating...' : 'Save Changes'}
</PrimaryButton>
)}
</div>
}
>
<div className="p-5">
{/* API Key Display Section (Only if webhookurl changed) */}
{apiKey && (
<div className="mb-6 p-4 bg-[rgba(34,197,94,0.1)] border border-[#22c55e] rounded-md">
<div className="mb-3">
<h3 className="text-sm font-semibold text-[#0f1724] mb-2">
New API Key Generated
</h3>
<p className="text-sm text-[#6b7280] mb-3">
Since the Webhook URL was changed, a new API key has been generated and sent to the module. Store it securely - this is the only time you will see this key here.
</p>
</div>
<div className="flex items-center gap-2 p-3 bg-white border border-[rgba(0,0,0,0.08)] rounded-md">
<code className="flex-1 text-sm font-mono text-[#0f1724] break-all">{apiKey}</code>
<button
type="button"
onClick={handleCopyApiKey}
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#112868] text-white rounded-md text-xs font-medium hover:bg-[#0d1f4e] transition-colors cursor-pointer"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
</div>
)}
{errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
</div>
)}
{!apiKey && (
<form onSubmit={handleSubmit(handleFormSubmit)} className="flex flex-col gap-6">
<section>
<h3 className="text-sm font-semibold text-[#0f1724] mb-4">Basic Information</h3>
<div className="grid grid-cols-2 gap-5">
<FormField
label="Module Name"
required
placeholder="Enter module name"
error={errors.name?.message}
{...register('name')}
/>
<div className="flex flex-col gap-2">
<label className="text-[13px] font-medium text-[#0e1b2a]">Module ID (Read Only)</label>
<div className="h-10 px-3.5 py-2 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-md text-sm text-gray-500 font-mono">
{module.module_id}
</div>
</div>
</div>
<FormField
label="Description"
placeholder="Enter module description"
error={errors.description?.message}
{...register('description')}
/>
</section>
<section>
<h3 className="text-sm font-semibold text-[#0f1724] mb-4">Endpoint Configuration</h3>
<div className="grid grid-cols-2 gap-5">
<FormField
label="Frontend Base URL"
required
type="url"
placeholder="https://frontend.example.com"
error={errors.frontend_base_url?.message}
{...register('frontend_base_url')}
/>
<FormField
label="Backend Base URL"
required
type="url"
placeholder="https://backend.example.com"
error={errors.backend_base_url?.message}
{...register('backend_base_url')}
/>
</div>
<FormField
label="Health Endpoint"
required
placeholder="/health"
error={errors.health_endpoint?.message}
{...register('health_endpoint')}
/>
</section>
<section>
<h3 className="text-sm font-semibold text-[#0f1724] mb-4">Resource Limits (Optional)</h3>
<div className="grid grid-cols-2 gap-5">
<FormField
label="CPU Request"
placeholder="e.g., 100m"
error={errors.cpu_request?.message}
{...register('cpu_request')}
/>
<FormField
label="CPU Limit"
placeholder="e.g., 500m"
error={errors.cpu_limit?.message}
{...register('cpu_limit')}
/>
</div>
<div className="grid grid-cols-2 gap-5 mt-4">
<FormField
label="Memory Request"
placeholder="e.g., 128Mi"
error={errors.memory_request?.message}
{...register('memory_request')}
/>
<FormField
label="Memory Limit"
placeholder="e.g., 512Mi"
error={errors.memory_limit?.message}
{...register('memory_limit')}
/>
</div>
</section>
</form>
)}
</div>
</Modal>
);
};

View File

@ -37,6 +37,13 @@ const newModuleSchema = z.object({
z.null(),
])
.optional(),
sync_webhook_url: z
.union([
z.string().url("Invalid URL format").max(500, "sync_webhook_url must be at most 500 characters"),
z.literal("").transform(() => null),
z.null(),
])
.optional(),
frontend_base_url: z
.string()
.min(1, 'frontend_base_url is required')
@ -110,6 +117,7 @@ export const NewModuleModal = ({
registered_by: null,
tenant_id: null,
metadata: null,
sync_webhook_url: null,
},
});
@ -140,6 +148,7 @@ export const NewModuleModal = ({
registered_by: null,
tenant_id: null,
metadata: null,
sync_webhook_url: null,
});
clearErrors();
setApiKey(null);
@ -167,7 +176,7 @@ export const NewModuleModal = ({
if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
const validationErrors = error.response.data.details;
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 === 'webhookurl' || detail.path === 'frontend_base_url' || detail.path === 'backend_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 === '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 === 'sync_webhook_url' || detail.path === 'frontend_base_url' || detail.path === 'backend_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 === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') {
setError(detail.path as keyof NewModuleFormData, {
type: 'server',
message: detail.message,
@ -367,13 +376,21 @@ export const NewModuleModal = ({
<div className="flex gap-5">
<div className="flex-1">
<FormField
label="Webhook URL"
placeholder="e.g., https://example.com/webhook"
label="X-API-Key Webhook URL"
placeholder="e.g., https://example.com/webhook/api-key"
error={errors.webhookurl?.message}
helperText="URL to receive the system API key"
{...register('webhookurl')}
/>
</div>
<div className="flex-1">
<FormField
label="Identity Sync Webhook URL"
placeholder="e.g., https://example.com/webhook/sync"
error={errors.sync_webhook_url?.message}
helperText="URL to receive identity data (tenants, users, etc.)"
{...register('sync_webhook_url')}
/>
</div>
</div>
</div>

View File

@ -157,8 +157,12 @@ export const ViewModuleModal = ({
<p className="text-sm font-medium text-[#0e1b2a]">{module.health_endpoint}</p>
</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>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">X-API-Key Webhook URL</label>
<p className="text-sm font-medium text-[#0e1b2a] break-all">{module.webhookurl || '-'}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Identity Sync Webhook URL</label>
<p className="text-sm font-medium text-[#0e1b2a] break-all">{module.sync_webhook_url || '-'}</p>
</div>
</div>
</div>

View File

@ -0,0 +1,186 @@
import { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2, Send } from 'lucide-react';
import { Modal, SecondaryButton, PrimaryButton, FormField } from '@/components/shared';
import { moduleService } from '@/services/module-service';
import type { Module } from '@/types/module';
const webhookSyncSchema = z.object({
sync_webhook_url: z.string().min(1, 'Webhook URL is required').url('Please enter a valid URL'),
});
type WebhookSyncFormData = z.infer<typeof webhookSyncSchema>;
interface WebhookSyncModalProps {
isOpen: boolean;
onClose: () => void;
moduleId: string | null;
onLoadModule: (id: string) => Promise<Module>;
}
export const WebhookSyncModal = ({
isOpen,
onClose,
moduleId,
onLoadModule,
}: WebhookSyncModalProps): ReactElement | null => {
const [module, setModule] = useState<Module | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isSyncing, setIsSyncing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const {
register,
handleSubmit,
setValue,
reset,
formState: { errors, isValid },
} = useForm<WebhookSyncFormData>({
resolver: zodResolver(webhookSyncSchema),
mode: 'onChange',
});
// Load module data when modal opens
useEffect(() => {
if (isOpen && moduleId) {
const loadModule = async (): Promise<void> => {
try {
setIsLoading(true);
setError(null);
setSuccess(null);
const data = await onLoadModule(moduleId);
setModule(data);
setValue('sync_webhook_url', data.sync_webhook_url || data.webhookurl || '', { shouldValidate: true });
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load module details');
} finally {
setIsLoading(false);
}
};
loadModule();
} else {
setModule(null);
reset({ sync_webhook_url: '' });
setError(null);
setSuccess(null);
}
}, [isOpen, moduleId, onLoadModule, setValue, reset]);
const handleSync = async (data: WebhookSyncFormData): Promise<void> => {
if (!moduleId) return;
try {
setIsSyncing(true);
setError(null);
setSuccess(null);
const response = await moduleService.syncWebhookData(moduleId, data.sync_webhook_url);
if (response.success) {
setSuccess('Identity data sync triggered successfully. The module application will receive the data shortly.');
} else {
setError('Failed to trigger sync');
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to trigger identity sync');
} finally {
setIsSyncing(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Identity Data Webhook Sync"
description="Sync identity data (tenants, roles, departments, designations, suppliers, users) to the module's webhook URL."
maxWidth="md"
footer={
<div className="flex justify-end gap-3 w-full">
<SecondaryButton type="button" onClick={onClose} className="px-4 py-2.5 text-sm">
Close
</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleSubmit(handleSync)}
disabled={isSyncing || isLoading || !isValid}
className="px-4 py-2.5 text-sm flex items-center gap-2"
>
{isSyncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
Sync Data
</PrimaryButton>
</div>
}
>
<div className="p-5">
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
</div>
)}
{error && (
<div className="p-4 mb-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
<p className="text-sm text-[#ef4444]">{error}</p>
</div>
)}
{success && (
<div className="p-4 mb-4 bg-[rgba(34,197,94,0.1)] border border-[#22c55e] rounded-md">
<p className="text-sm text-[#22c55e]">{success}</p>
</div>
)}
{!isLoading && module && (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1.5 mb-4">
<label className="text-sm font-medium text-[#0e1b2a]">Module</label>
<div className="p-3 bg-[#f8fafc] border border-[rgba(0,0,0,0.08)] rounded-md">
<p className="text-sm font-semibold text-[#0e1b2a]">{module.name}</p>
<p className="text-xs text-[#64748b] font-mono">{module.module_id}</p>
</div>
</div>
<FormField
label="Identity Sync Webhook URL"
type="url"
required
placeholder="https://module-app.com/api/v1/webhooks/identity"
error={errors.sync_webhook_url?.message}
helperText="The super admin can trigger this to send the current state of all identity tables. Any changes to this URL will be saved back to the module registration."
{...register('sync_webhook_url')}
/>
<div className="p-4 bg-[rgba(17,40,104,0.04)] border border-[rgba(17,40,104,0.1)] rounded-md mt-2">
<h4 className="text-xs font-semibold text-[#112868] uppercase tracking-wider mb-2">Data Synchronized:</h4>
<ul className="grid grid-cols-2 gap-y-2 gap-x-4">
<li className="text-xs text-[#475569] flex items-center gap-2">
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Tenants
</li>
<li className="text-xs text-[#475569] flex items-center gap-2">
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Roles
</li>
<li className="text-xs text-[#475569] flex items-center gap-2">
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Departments
</li>
<li className="text-xs text-[#475569] flex items-center gap-2">
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Designations
</li>
<li className="text-xs text-[#475569] flex items-center gap-2">
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Suppliers
</li>
<li className="text-xs text-[#475569] flex items-center gap-2">
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Users
</li>
</ul>
</div>
</div>
)}
</div>
</Modal>
);
};

View File

@ -3,6 +3,9 @@
// export { EditTenantModal } from './EditTenantModal';
export { NewModuleModal } from './NewModuleModal';
export { ViewModuleModal } from './ViewModuleModal';
export { EditModuleModal } from './EditModuleModal';
export { WebhookSyncModal } from './WebhookSyncModal';
export { ApikeyReissueModal } from './ApikeyReissueModal';
export { UsersTable } from './UsersTable';
export { RolesTable } from './RolesTable';
export { default as DepartmentsTable } from './DepartmentsTable';

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
import { useState, useEffect } from "react";
import type { ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import {
StatusBadge,
PrimaryButton,
@ -8,32 +8,46 @@ import {
Pagination,
FilterDropdown,
type Column,
} from '@/components/shared';
import { ViewModuleModal, NewModuleModal } from '@/components/superadmin';
import { Plus, ArrowUpDown } from 'lucide-react';
import { moduleService } from '@/services/module-service';
import type { Module } from '@/types/module';
ActionDropdown,
// SecondaryButton,
} from "@/components/shared";
import {
ViewModuleModal,
NewModuleModal,
EditModuleModal,
WebhookSyncModal,
ApikeyReissueModal,
} from "@/components/superadmin";
import { Plus, ArrowUpDown, Eye, CloudSync, Edit, Key } from "lucide-react";
import { moduleService } from "@/services/module-service";
import type { Module } from "@/types/module";
// 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' });
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
// Helper function to get status badge variant
const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => {
if (!status) return 'process';
const getStatusVariant = (
status: string | null,
): "success" | "failure" | "process" => {
if (!status) return "process";
switch (status.toLowerCase()) {
case 'running':
case 'active':
case 'healthy':
return 'success';
case 'stopped':
case 'failed':
case 'unhealthy':
return 'failure';
case "running":
case "active":
case "healthy":
return "success";
case "stopped":
case "failed":
case "unhealthy":
return "failure";
default:
return 'process';
return "process";
}
};
@ -68,25 +82,35 @@ const Modules = (): ReactElement => {
// View modal
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
const [selectedModuleForEdit, setSelectedModuleForEdit] = useState<Module | null>(null);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [webhookModalOpen, setWebhookModalOpen] = useState<boolean>(false);
const [reissueModalOpen, setReissueModalOpen] = useState<boolean>(false);
const fetchModules = async (
page: number,
itemsPerPage: number,
status: string | null = null,
sortBy: string[] | null = null
sortBy: string[] | null = null,
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await moduleService.getAll(page, itemsPerPage, status, sortBy);
const response = await moduleService.getAll(
page,
itemsPerPage,
status,
sortBy,
);
if (response.success) {
setModules(response.data);
setPagination(response.pagination);
} else {
setError('Failed to load modules');
setError("Failed to load modules");
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load modules');
setError(err?.response?.data?.error?.message || "Failed to load modules");
} finally {
setIsLoading(false);
}
@ -103,8 +127,22 @@ const Modules = (): ReactElement => {
setViewModalOpen(true);
};
// Webhook sync handler
const handleOpenWebhookSync = (moduleId: string): void => {
setSelectedModuleId(moduleId);
setWebhookModalOpen(true);
};
// Reissue API key handler
const handleOpenReissueApiKey = (moduleId: string): void => {
setSelectedModuleId(moduleId);
setReissueModalOpen(true);
};
// Create module handler
const handleCreateModule = async (data: any): Promise<{ api_key: { key: string } }> => {
const handleCreateModule = async (
data: any,
): Promise<{ api_key: { key: string } }> => {
try {
setIsCreating(true);
const response = await moduleService.create(data);
@ -117,6 +155,27 @@ const Modules = (): ReactElement => {
}
};
// Edit module handler
const handleEditModule = (module: Module): void => {
setSelectedModuleForEdit(module);
setEditModalOpen(true);
};
// Update module handler
const handleUpdateModule = async (data: any): Promise<any> => {
if (!selectedModuleForEdit) return;
try {
setIsUpdating(true);
const response = await moduleService.update(selectedModuleForEdit.id, data);
await fetchModules(currentPage, limit, statusFilter, orderBy);
return response;
} catch (err: any) {
throw err;
} finally {
setIsUpdating(false);
}
};
// Load module for view
const loadModule = async (id: string): Promise<Module> => {
const response = await moduleService.getById(id);
@ -126,8 +185,8 @@ const Modules = (): ReactElement => {
// Define table columns
const columns: Column<Module>[] = [
{
key: 'name',
label: 'Module Name',
key: "name",
label: "Module Name",
render: (module) => (
<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">
@ -136,78 +195,128 @@ const Modules = (): ReactElement => {
</span>
</div>
<div className="flex flex-col">
<span className="text-sm font-normal text-[#0f1724]">{module.name}</span>
<span className="text-xs text-[#6b7280] font-mono">{module.module_id}</span>
</div>
</div>
),
mobileLabel: 'Name',
},
{
key: 'description',
label: 'Description',
render: (module) => (
<span className="text-sm font-normal text-[#0f1724] line-clamp-1">{module.description}</span>
),
},
{
key: 'version',
label: 'Version',
render: (module) => (
<span className="text-sm font-normal text-[#0f1724]">{module.version}</span>
),
},
{
key: 'status',
label: 'Status',
render: (module) => (
<StatusBadge variant={getStatusVariant(module.status)}>
{module.status || 'Unknown'}
</StatusBadge>
),
},
{
key: 'health_status',
label: 'Health Status',
render: (module) => (
<StatusBadge variant={getStatusVariant(module.health_status)}>
{module.health_status || 'N/A'}
</StatusBadge>
),
},
{
key: 'runtime_language',
label: 'Runtime',
render: (module) => (
<span className="text-sm font-normal text-[#0f1724]">
{module.runtime_language || 'N/A'}
{module.name}
</span>
<span className="text-xs text-[#6b7280] font-mono">
{module.module_id}
</span>
</div>
</div>
),
mobileLabel: "Name",
},
{
key: "description",
label: "Description",
render: (module) => (
<span className="text-sm font-normal text-[#0f1724] line-clamp-1">
{module.description}
</span>
),
},
{
key: 'created_at',
label: 'Registered Date',
key: "version",
label: "Version",
render: (module) => (
<span className="text-sm font-normal text-[#6b7280]">{formatDate(module.created_at)}</span>
<span className="text-sm font-normal text-[#0f1724]">
{module.version}
</span>
),
mobileLabel: 'Registered',
},
{
key: 'actions',
label: 'Actions',
align: 'right',
key: "status",
label: "Status",
render: (module) => (
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => handleViewModule(module.id)}
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
>
View
</button>
<StatusBadge variant={getStatusVariant(module.status)}>
{module.status || "Unknown"}
</StatusBadge>
),
},
{
key: "health_status",
label: "Health Status",
render: (module) => (
<StatusBadge variant={getStatusVariant(module.health_status)}>
{module.health_status || "N/A"}
</StatusBadge>
),
},
{
key: "runtime_language",
label: "Runtime",
render: (module) => (
<span className="text-sm font-normal text-[#0f1724]">
{module.runtime_language || "N/A"}
</span>
),
},
{
key: "created_at",
label: "Registered Date",
render: (module) => (
<span className="text-sm font-normal text-[#6b7280]">
{formatDate(module.created_at)}
</span>
),
mobileLabel: "Registered",
},
{
key: "actions",
label: "Actions",
align: "right",
render: (module) => (
<div className="flex justify-end">
<ActionDropdown
actions={[
{
icon: <Eye className="w-3.5 h-3.5 shrink-0" />,
label: "View",
onClick: () => handleViewModule(module.id),
},
{
icon: <Edit className="w-3.5 h-3.5 shrink-0" />,
label: "Edit",
onClick: () => handleEditModule(module),
},
{
icon: <Key className="w-3.5 h-3.5 shrink-0" />,
label: "Reissue API Key",
onClick: () => handleOpenReissueApiKey(module.id),
},
{
icon: <CloudSync className="w-3.5 h-3.5 shrink-0" />,
label: "Webhook Sync",
onClick: () => handleOpenWebhookSync(module.id),
},
]}
/>
</div>
),
},
// {
// key: "actions",
// label: "Actions",
// align: "right",
// render: (module) => (
// <div className="flex justify-end gap-3">
// <button
// type="button"
// onClick={() => handleViewModule(module.id)}
// className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
// >
// View
// </button>
// <PrimaryButton
// size="default"
// type="button"
// onClick={() => handleOpenWebhookSync(module.id)}
// >
// WebhookSync
// </PrimaryButton>
// </div>
// ),
// },
];
// Mobile card renderer
@ -221,8 +330,12 @@ const Modules = (): ReactElement => {
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">{module.name}</h3>
<p className="text-xs text-[#6b7280] mt-0.5 truncate font-mono">{module.module_id}</p>
<h3 className="text-sm font-medium text-[#0f1724] truncate">
{module.name}
</h3>
<p className="text-xs text-[#6b7280] mt-0.5 truncate font-mono">
{module.module_id}
</p>
</div>
</div>
<button
@ -238,7 +351,7 @@ const Modules = (): ReactElement => {
<span className="text-[#9aa6b2]">Status:</span>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(module.status)}>
{module.status || 'Unknown'}
{module.status || "Unknown"}
</StatusBadge>
</div>
</div>
@ -246,7 +359,7 @@ const Modules = (): ReactElement => {
<span className="text-[#9aa6b2]">Health:</span>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(module.health_status)}>
{module.health_status || 'N/A'}
{module.health_status || "N/A"}
</StatusBadge>
</div>
</div>
@ -256,15 +369,21 @@ const Modules = (): ReactElement => {
</div>
<div>
<span className="text-[#9aa6b2]">Runtime:</span>
<p className="text-[#0f1724] font-normal mt-1">{module.runtime_language || 'N/A'}</p>
<p className="text-[#0f1724] font-normal mt-1">
{module.runtime_language || "N/A"}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Registered:</span>
<p className="text-[#6b7280] font-normal mt-1">{formatDate(module.created_at)}</p>
<p className="text-[#6b7280] font-normal mt-1">
{formatDate(module.created_at)}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Description:</span>
<p className="text-[#0f1724] font-normal mt-1 line-clamp-2">{module.description}</p>
<p className="text-[#0f1724] font-normal mt-1 line-clamp-2">
{module.description}
</p>
</div>
</div>
</div>
@ -274,8 +393,9 @@ const Modules = (): ReactElement => {
<Layout
currentPage="Modules"
pageHeader={{
title: 'Module List',
description: 'View and manage all system modules registered in the QAssure platform.',
title: "Module List",
description:
"View and manage all system modules registered in the QAssure platform.",
}}
>
{/* Table Container */}
@ -288,9 +408,9 @@ const Modules = (): ReactElement => {
<FilterDropdown
label="Status"
options={[
{ value: 'running', label: 'Running' },
{ value: 'stopped', label: 'Stopped' },
{ value: 'failed', label: 'Failed' },
{ value: "running", label: "Running" },
{ value: "stopped", label: "Stopped" },
{ value: "failed", label: "Failed" },
]}
value={statusFilter}
onChange={(value) => {
@ -304,16 +424,16 @@ const Modules = (): ReactElement => {
<FilterDropdown
label="Sort by"
options={[
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
{ value: ['module_id', 'asc'], label: 'Module ID (A-Z)' },
{ value: ['module_id', 'desc'], label: 'Module ID (Z-A)' },
{ value: ['status', 'asc'], label: 'Status (A-Z)' },
{ value: ['status', 'desc'], label: 'Status (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: ["name", "asc"], label: "Name (A-Z)" },
{ value: ["name", "desc"], label: "Name (Z-A)" },
{ value: ["module_id", "asc"], label: "Module ID (A-Z)" },
{ value: ["module_id", "desc"], label: "Module ID (Z-A)" },
{ value: ["status", "asc"], label: "Status (A-Z)" },
{ value: ["status", "desc"], label: "Status (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) => {
@ -396,6 +516,40 @@ const Modules = (): ReactElement => {
moduleId={selectedModuleId}
onLoadModule={loadModule}
/>
{/* Edit Module Modal */}
<EditModuleModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setSelectedModuleForEdit(null);
}}
module={selectedModuleForEdit}
onSubmit={handleUpdateModule}
isLoading={isUpdating}
/>
{/* Webhook Sync Modal */}
<WebhookSyncModal
isOpen={webhookModalOpen}
onClose={() => {
setWebhookModalOpen(false);
setSelectedModuleId(null);
}}
moduleId={selectedModuleId}
onLoadModule={loadModule}
/>
{/* Reissue API Key Modal */}
<ApikeyReissueModal
isOpen={reissueModalOpen}
onClose={() => {
setReissueModalOpen(false);
setSelectedModuleId(null);
}}
moduleId={selectedModuleId}
onLoadModule={loadModule}
/>
</Layout>
);
};

View File

@ -1,5 +1,5 @@
import apiClient from './api-client';
import type { ModulesResponse, GetModuleResponse, CreateModuleRequest, CreateModuleResponse, LaunchModuleResponse, MyModulesResponse } from '@/types/module';
import type { ModulesResponse, GetModuleResponse, CreateModuleRequest, CreateModuleResponse, LaunchModuleResponse, MyModulesResponse, UpdateModuleRequest } from '@/types/module';
export const moduleService = {
getDropdown: async (): Promise<{ success: boolean; data: Array<{ id: string; name: string }> }> => {
@ -70,6 +70,10 @@ export const moduleService = {
const response = await apiClient.post<CreateModuleResponse>('/modules', data);
return response.data;
},
update: async (id: string, data: UpdateModuleRequest): Promise<{ success: boolean; data: any }> => {
const response = await apiClient.put<{ success: boolean; data: any }>(`/modules/${id}`, data);
return response.data;
},
launch: async (id: string, tenantId?: string | null): Promise<LaunchModuleResponse> => {
const params = new URLSearchParams();
if (tenantId) {
@ -88,4 +92,12 @@ export const moduleService = {
const response = await apiClient.get<MyModulesResponse>(url);
return response.data;
},
syncWebhookData: async (id: string, sync_webhook_url?: string): Promise<{ success: boolean; data: any }> => {
const response = await apiClient.post<{ success: boolean; data: any }>(`/modules/${id}/webhook-sync`, { sync_webhook_url });
return response.data;
},
reissueApiKey: async (id: string, webhookurl?: string): Promise<{ success: boolean; data: { api_key: { key: string } } }> => {
const response = await apiClient.post<{ success: boolean; data: { api_key: { key: string } } }>(`/modules/${id}/reissue-key`, { webhookurl });
return response.data;
},
};

View File

@ -8,6 +8,7 @@ export interface Module {
runtime_language: string | null;
framework: string | null;
webhookurl: string | null;
sync_webhook_url: string | null;
frontend_base_url: string;
backend_base_url: string;
health_endpoint: string;
@ -54,11 +55,14 @@ export interface CreateModuleRequest {
name: string;
description?: string | null;
version: string;
status?: 'PENDING' | 'ACTIVE' | 'DEGRADED' | 'SUSPENDED' | 'DEPRECATED' | 'RETIRED';
status?: string;
runtime_language: string;
framework?: string | null;
base_url: string;
frontend_base_url: string;
backend_base_url: string;
health_endpoint: string;
webhookurl?: string | null;
sync_webhook_url?: string | null;
endpoints?: any | null;
kafka_topics?: any | null;
cpu_request?: string | null;
@ -75,6 +79,8 @@ export interface CreateModuleRequest {
metadata?: any | null;
}
export interface UpdateModuleRequest extends Partial<Omit<CreateModuleRequest, 'module_id' | 'version' | 'status' | 'runtime_language' | 'framework' | 'webhookurl' | 'sync_webhook_url'>> {}
export interface CreateModuleResponse {
success: boolean;
data: {