feat: add module update functionality, webhook sync, and API key reissue support with new management modals
This commit is contained in:
parent
92ea75ce77
commit
bcd029950f
208
src/components/superadmin/ApikeyReissueModal.tsx
Normal file
208
src/components/superadmin/ApikeyReissueModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
295
src/components/superadmin/EditModuleModal.tsx
Normal file
295
src/components/superadmin/EditModuleModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
186
src/components/superadmin/WebhookSyncModal.tsx
Normal file
186
src/components/superadmin/WebhookSyncModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
<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',
|
||||
mobileLabel: "Name",
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
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'}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user