diff --git a/src/components/superadmin/ApikeyReissueModal.tsx b/src/components/superadmin/ApikeyReissueModal.tsx new file mode 100644 index 0000000..b063c94 --- /dev/null +++ b/src/components/superadmin/ApikeyReissueModal.tsx @@ -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; + +interface ApikeyReissueModalProps { + isOpen: boolean; + onClose: () => void; + moduleId: string | null; + onLoadModule: (id: string) => Promise; +} + +export const ApikeyReissueModal = ({ + isOpen, + onClose, + moduleId, + onLoadModule, +}: ApikeyReissueModalProps): ReactElement | null => { + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [apiKey, setApiKey] = useState(null); + const [copied, setCopied] = useState(false); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + 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 => { + 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 => { + 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 ( + + + {apiKey ? 'Close' : 'Cancel'} + + {!apiKey && ( + + {isSubmitting ? ( + <> + + Reissuing... + + ) : ( + <> + + Reissue & Send Key + + )} + + )} + + } + > +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {apiKey ? ( +
+
+

+ ⚠️ New API Key Generated +

+

+ 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. +

+
+
+ {apiKey} + +
+
+ ) : ( +
+
+ +
+

Security Notice

+

+ Reissuing the API key will immediately invalidate the previous key. The new key will be sent via POST request to the webhook URL below. +

+
+
+ + + +
+ Note: This URL will be updated in the module configuration if it differs from the current one. +
+ + )} + + )} +
+
+ ); +}; diff --git a/src/components/superadmin/EditModuleModal.tsx b/src/components/superadmin/EditModuleModal.tsx new file mode 100644 index 0000000..b92ca71 --- /dev/null +++ b/src/components/superadmin/EditModuleModal.tsx @@ -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; + +interface EditModuleModalProps { + isOpen: boolean; + onClose: () => void; + module: Module | null; + onSubmit: (data: UpdateModuleRequest) => Promise; + isLoading?: boolean; +} + +export const EditModuleModal = ({ + isOpen, + onClose, + module, + onSubmit, + isLoading = false, +}: EditModuleModalProps): ReactElement | null => { + const [apiKey, setApiKey] = useState(null); + const [copied, setCopied] = useState(false); + + const { + register, + handleSubmit, + reset, + setError, + clearErrors, + formState: { errors }, + } = useForm({ + 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 => { + 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 => { + 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 ( + + + {apiKey ? 'Close' : 'Cancel'} + + {!apiKey && ( + + {isLoading && } + {isLoading ? 'Updating...' : 'Save Changes'} + + )} + + } + > +
+ {/* API Key Display Section (Only if webhookurl changed) */} + {apiKey && ( +
+
+

+ ⚠️ New API Key Generated +

+

+ 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. +

+
+
+ {apiKey} + +
+
+ )} + + {errors.root && ( +
+

{errors.root.message}

+
+ )} + + {!apiKey && ( +
+
+

Basic Information

+
+ +
+ +
+ {module.module_id} +
+
+
+ +
+ +
+

Endpoint Configuration

+
+ + +
+ +
+ +
+

Resource Limits (Optional)

+
+ + +
+
+ + +
+
+
+ )} +
+
+ ); +}; diff --git a/src/components/superadmin/NewModuleModal.tsx b/src/components/superadmin/NewModuleModal.tsx index 779829a..f62386b 100644 --- a/src/components/superadmin/NewModuleModal.tsx +++ b/src/components/superadmin/NewModuleModal.tsx @@ -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 = ({
+
diff --git a/src/components/superadmin/ViewModuleModal.tsx b/src/components/superadmin/ViewModuleModal.tsx index 923f108..8e9723b 100644 --- a/src/components/superadmin/ViewModuleModal.tsx +++ b/src/components/superadmin/ViewModuleModal.tsx @@ -157,8 +157,12 @@ export const ViewModuleModal = ({

{module.health_endpoint}

- -

{module.webhookurl || '-'}

+ +

{module.webhookurl || '-'}

+
+
+ +

{module.sync_webhook_url || '-'}

diff --git a/src/components/superadmin/WebhookSyncModal.tsx b/src/components/superadmin/WebhookSyncModal.tsx new file mode 100644 index 0000000..8a1d355 --- /dev/null +++ b/src/components/superadmin/WebhookSyncModal.tsx @@ -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; + +interface WebhookSyncModalProps { + isOpen: boolean; + onClose: () => void; + moduleId: string | null; + onLoadModule: (id: string) => Promise; +} + +export const WebhookSyncModal = ({ + isOpen, + onClose, + moduleId, + onLoadModule, +}: WebhookSyncModalProps): ReactElement | null => { + const [module, setModule] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const { + register, + handleSubmit, + setValue, + reset, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(webhookSyncSchema), + mode: 'onChange', + }); + + // Load module data when modal opens + useEffect(() => { + if (isOpen && moduleId) { + const loadModule = async (): Promise => { + 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 => { + 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 ( + + + Close + + + {isSyncing ? : } + Sync Data + + + } + > +
+ {isLoading && ( +
+ +
+ )} + + {error && ( +
+

{error}

+
+ )} + + {success && ( +
+

{success}

+
+ )} + + {!isLoading && module && ( +
+
+ +
+

{module.name}

+

{module.module_id}

+
+
+ + + +
+

Data Synchronized:

+
    +
  • +
    Tenants +
  • +
  • +
    Roles +
  • +
  • +
    Departments +
  • +
  • +
    Designations +
  • +
  • +
    Suppliers +
  • +
  • +
    Users +
  • +
+
+
+ )} +
+
+ ); +}; diff --git a/src/components/superadmin/index.ts b/src/components/superadmin/index.ts index 3f9810a..ad36490 100644 --- a/src/components/superadmin/index.ts +++ b/src/components/superadmin/index.ts @@ -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'; diff --git a/src/pages/superadmin/Modules.tsx b/src/pages/superadmin/Modules.tsx index ff09130..9c7d667 100644 --- a/src/pages/superadmin/Modules.tsx +++ b/src/pages/superadmin/Modules.tsx @@ -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(false); const [selectedModuleId, setSelectedModuleId] = useState(null); + const [editModalOpen, setEditModalOpen] = useState(false); + const [selectedModuleForEdit, setSelectedModuleForEdit] = useState(null); + const [isUpdating, setIsUpdating] = useState(false); + const [webhookModalOpen, setWebhookModalOpen] = useState(false); + const [reissueModalOpen, setReissueModalOpen] = useState(false); const fetchModules = async ( page: number, itemsPerPage: number, status: string | null = null, - sortBy: string[] | null = null + sortBy: string[] | null = null, ): Promise => { 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 => { + 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 => { const response = await moduleService.getById(id); @@ -126,8 +185,8 @@ const Modules = (): ReactElement => { // Define table columns const columns: Column[] = [ { - key: 'name', - label: 'Module Name', + key: "name", + label: "Module Name", render: (module) => (
@@ -136,78 +195,128 @@ const Modules = (): ReactElement => {
- {module.name} - {module.module_id} + + {module.name} + + + {module.module_id} +
), - mobileLabel: 'Name', + mobileLabel: "Name", }, { - key: 'description', - label: 'Description', + key: "description", + label: "Description", render: (module) => ( - {module.description} - ), - }, - { - key: 'version', - label: 'Version', - render: (module) => ( - {module.version} - ), - }, - { - key: 'status', - label: 'Status', - render: (module) => ( - - {module.status || 'Unknown'} - - ), - }, - { - key: 'health_status', - label: 'Health Status', - render: (module) => ( - - {module.health_status || 'N/A'} - - ), - }, - { - key: 'runtime_language', - label: 'Runtime', - render: (module) => ( - - {module.runtime_language || 'N/A'} + + {module.description} ), }, { - key: 'created_at', - label: 'Registered Date', + key: "version", + label: "Version", render: (module) => ( - {formatDate(module.created_at)} + + {module.version} + ), - mobileLabel: 'Registered', }, { - key: 'actions', - label: 'Actions', - align: 'right', + key: "status", + label: "Status", render: (module) => ( -
- + + {module.status || "Unknown"} + + ), + }, + { + key: "health_status", + label: "Health Status", + render: (module) => ( + + {module.health_status || "N/A"} + + ), + }, + { + key: "runtime_language", + label: "Runtime", + render: (module) => ( + + {module.runtime_language || "N/A"} + + ), + }, + { + key: "created_at", + label: "Registered Date", + render: (module) => ( + + {formatDate(module.created_at)} + + ), + mobileLabel: "Registered", + }, + { + key: "actions", + label: "Actions", + align: "right", + render: (module) => ( +
+ , + label: "View", + onClick: () => handleViewModule(module.id), + }, + { + icon: , + label: "Edit", + onClick: () => handleEditModule(module), + }, + { + icon: , + label: "Reissue API Key", + onClick: () => handleOpenReissueApiKey(module.id), + }, + { + icon: , + label: "Webhook Sync", + onClick: () => handleOpenWebhookSync(module.id), + }, + ]} + />
), }, + // { + // key: "actions", + // label: "Actions", + // align: "right", + // render: (module) => ( + //
+ // + // handleOpenWebhookSync(module.id)} + // > + // WebhookSync + // + //
+ // ), + // }, ]; // Mobile card renderer @@ -221,8 +330,12 @@ const Modules = (): ReactElement => {
-

{module.name}

-

{module.module_id}

+

+ {module.name} +

+

+ {module.module_id} +