From 806582604db43a7ab860869b1d04473845ff3f8e Mon Sep 17 00:00:00 2001 From: Furqan-14 Date: Mon, 2 Feb 2026 17:40:13 +0530 Subject: [PATCH] feat: Implement module-based access control system with integrated authentication, authorization, and security utilities. --- .env.development | 2 +- .env.production | 2 +- .env.test | 1 + package.json | 6 +- src/application/dashboard/Dashboard.tsx | 214 +++++--- .../modules/admin/AdminModuleApi.ts | 97 ++++ .../modules/admin/AdminModuleTypes.ts | 108 ++++ .../admin/components/EnvironmentForm.tsx | 247 +++++++++ .../admin/components/ModuleEnvironments.tsx | 186 +++++++ .../modules/admin/components/ModuleForm.tsx | 180 ++++++ .../modules/admin/components/ModuleList.tsx | 191 +++++++ .../admin/components/ModulePermissions.tsx | 97 ++++ .../components/TenantModuleAssignment.tsx | 197 +++++++ .../modules/admin/hooks/useModuleApi.ts | 156 ++++++ src/application/modules/admin/index.tsx | 16 + .../user/UserModuleApi.ts} | 9 +- src/application/roles/RolesTypes.ts | 2 + src/application/roles/components/AddRoles.tsx | 32 +- src/application/roles/components/AllRoles.tsx | 39 +- .../components/GroupedAccessSelector.tsx | 519 +++++++++++------- .../components/NodeGroupAccessViewer.tsx | 147 +++++ src/application/tenants/TenantsTypes.ts | 9 +- .../tenants/components/AddTenants.tsx | 150 ++++- .../tenants/components/AllTenants.tsx | 215 ++++++-- src/components/custom/CustomInput.tsx | 4 +- src/components/layout/AppHeader.tsx | 2 +- src/components/layout/AppSidebar.tsx | 11 +- src/components/layout/AuthLayout.tsx | 15 +- src/i18n/config.ts | 6 +- src/i18n/locales/ar/modules.json | 63 +++ src/i18n/locales/en/common.json | 5 +- src/i18n/locales/en/dashboard.json | 3 +- src/i18n/locales/en/modules.json | 63 +++ src/routes/index.tsx | 10 +- 34 files changed, 2635 insertions(+), 369 deletions(-) create mode 100644 .env.test create mode 100644 src/application/modules/admin/AdminModuleApi.ts create mode 100644 src/application/modules/admin/AdminModuleTypes.ts create mode 100644 src/application/modules/admin/components/EnvironmentForm.tsx create mode 100644 src/application/modules/admin/components/ModuleEnvironments.tsx create mode 100644 src/application/modules/admin/components/ModuleForm.tsx create mode 100644 src/application/modules/admin/components/ModuleList.tsx create mode 100644 src/application/modules/admin/components/ModulePermissions.tsx create mode 100644 src/application/modules/admin/components/TenantModuleAssignment.tsx create mode 100644 src/application/modules/admin/hooks/useModuleApi.ts create mode 100644 src/application/modules/admin/index.tsx rename src/application/{module/ModuleApi.ts => modules/user/UserModuleApi.ts} (81%) create mode 100644 src/application/roles/components/NodeGroupAccessViewer.tsx create mode 100644 src/i18n/locales/ar/modules.json create mode 100644 src/i18n/locales/en/modules.json diff --git a/.env.development b/.env.development index a6c4437..2abaab2 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,2 @@ # Development environment -VITE_API_BASE_URL=https://dev-api.yourdomain.com +VITE_API_BASE_URL=https://saas-dev.maskantech.in \ No newline at end of file diff --git a/.env.production b/.env.production index cb37462..661ca19 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,2 @@ # Production environment -VITE_API_BASE_URL=https://api.yourdomain.com +VITE_API_BASE_URL=https://api.yourdomain.com \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..26bcd50 --- /dev/null +++ b/.env.test @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://saas-test.maskantech.in \ No newline at end of file diff --git a/package.json b/package.json index 5b60508..55e3a2b 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,12 @@ "scripts": { "local": "vite --mode localhost", "dev": "vite --mode development", + "test": "vite --mode test", "prod": "vite --mode production", "build": "tsc -b && vite build", + "build:dev": "tsc -b && vite build --mode development", + "build:test": "tsc -b && vite build --mode test", + "build:prod": "tsc -b && vite build --mode production", "lint": "eslint .", "preview": "vite preview" }, @@ -43,4 +47,4 @@ "typescript-eslint": "^8.46.4", "vite": "^7.2.4" } -} +} \ No newline at end of file diff --git a/src/application/dashboard/Dashboard.tsx b/src/application/dashboard/Dashboard.tsx index 13667c3..ab3e433 100644 --- a/src/application/dashboard/Dashboard.tsx +++ b/src/application/dashboard/Dashboard.tsx @@ -1,12 +1,15 @@ -import { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ExternalLink, Box } from 'lucide-react'; -import { moduleApi, type Module } from '../module/ModuleApi'; +import { useState, useEffect, useMemo } from 'react'; +import axios from 'axios'; +import { ExternalLink, Box, Search, Rocket } from 'lucide-react'; +import { moduleApi, type Module } from '../modules/user/UserModuleApi'; +import { useAuth } from '../../context/AuthContext'; const Dashboard = () => { - const { t } = useTranslation('dashboard'); + const { user } = useAuth(); + const [modules, setModules] = useState([]); const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); useEffect(() => { const fetchModules = async () => { @@ -26,87 +29,162 @@ const Dashboard = () => { const handleModuleClick = async (moduleId: string) => { try { const response = await moduleApi.launchModule(moduleId); + + await axios.post(response.target_url, response.payload, { + headers: response.headers, + withCredentials: true + }); + if (response.redirect_url) { window.location.href = response.redirect_url; } } catch (error) { - console.error('Failed to launch module', error); + alert("Failed to launch module. Please try again."); } }; + const groupedModules = useMemo(() => { + const filtered = modules.filter(m => + m.module_name.toLowerCase().includes(searchTerm.toLowerCase()) || + (m.description && m.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); + + const groups: Record = {}; + + if (filtered.length === 0) return {}; + + filtered.forEach(module => { + const category = module.category || 'All Modules'; + if (!groups[category]) { + groups[category] = []; + } + groups[category].push(module); + }); + + return Object.keys(groups).sort().reduce( + (obj, key) => { + obj[key] = groups[key]; + return obj; + }, + {} as Record + ); + }, [modules, searchTerm]); + return ( -
-
-

- {t('title')} -

-

- {t('message')} -

+
+ {/* Hero Section */} +
+
+

+ Welcome back, {user?.first_name || 'Admin'}! 👋 +

+

+ Manage your SaaS ecosystem from one central hub. +

+
+ +
+
+
+ +
+ setSearchTerm(e.target.value)} + className="block w-full pl-10 pr-3 py-2.5 bg-white border border-gray-200 rounded-xl leading-5 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm hover:shadow-md" + /> +
+ +
- {/* Modules Section */} -
-

- Available Modules -

- + {/* Modules Grid */} +
{loading ? ( -
- {[1, 2, 3].map((i) => ( -
-
-
-
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
))}
- ) : modules.length > 0 ? ( -
- {modules.map((module) => ( - +
- -
- -

- {module.module_name} -

- - {module.description && ( -

- {module.description} -

- )} - - ))} -
+ ))} +
+ + )) ) : ( -
- -

- No modules available +

+
+ +
+

No modules found

+

+ {searchTerm ? `No results found for "${searchTerm}". Try a different keyword.` : "Your dashboard is currently empty. Contact your administrator to assign modules."}

+ {searchTerm && ( + + )}
)}
diff --git a/src/application/modules/admin/AdminModuleApi.ts b/src/application/modules/admin/AdminModuleApi.ts new file mode 100644 index 0000000..a22efd7 --- /dev/null +++ b/src/application/modules/admin/AdminModuleApi.ts @@ -0,0 +1,97 @@ +import axios from 'axios'; +import { API_BASE_URL } from '../../../constant'; +import type { + Module, + ModuleCreate, + ModuleUpdate, + ModuleEnvironment, + EnvironmentCreate, + EnvironmentUpdate, + ModulePermission, + TenantModule, + TenantModuleCreate, + TenantModuleUpdate +} from './AdminModuleTypes'; + +const api = axios.create({ + baseURL: API_BASE_URL, + withCredentials: true, +}); + +export const adminModuleApi = { + listModules: async (): Promise => { + const response = await api.get('/api/admin/modules/'); + return response.data; + }, + + createModule: async (data: ModuleCreate): Promise => { + const response = await api.post('/api/admin/modules/', data); + return response.data; + }, + + getModule: async (moduleId: string): Promise => { + const response = await api.get(`/api/admin/modules/${moduleId}`); + return response.data; + }, + + updateModule: async (moduleId: string, data: ModuleUpdate): Promise => { + const response = await api.put(`/api/admin/modules/${moduleId}`, data); + return response.data; + }, + + deleteModule: async (moduleId: string): Promise => { + await api.delete(`/api/admin/modules/${moduleId}`); + }, + + listEnvironments: async (moduleId: string): Promise => { + const response = await api.get(`/api/admin/modules/${moduleId}/environments`); + return response.data; + }, + + createEnvironment: async (moduleId: string, data: EnvironmentCreate): Promise => { + const response = await api.post(`/api/admin/modules/${moduleId}/environments`, data); + return response.data; + }, + + updateEnvironment: async (moduleId: string, envId: string, data: EnvironmentUpdate): Promise => { + const response = await api.put(`/api/admin/modules/${moduleId}/environments/${envId}`, data); + return response.data; + }, + + setDefaultEnvironment: async (moduleId: string, envId: string): Promise => { + await api.patch(`/api/admin/modules/${moduleId}/environments/${envId}/default`); + }, + + deleteEnvironment: async (moduleId: string, envId: string): Promise => { + await api.delete(`/api/admin/modules/${moduleId}/environments/${envId}`); + }, + + getModulePermissions: async (moduleId: string): Promise => { + const response = await api.get(`/api/admin/modules/${moduleId}/permissions`); + return response.data; + }, + + syncModulePermissions: async (moduleId: string): Promise<{ message: string; synced_count: number }> => { + const response = await api.post(`/api/admin/modules/${moduleId}/permissions/sync`); + return response.data; + }, + + listTenantModules: async (tenantId: string): Promise => { + const response = await api.get(`/api/admin/tenants/${tenantId}/modules`); + return response.data; + }, + + assignModuleToTenant: async (tenantId: string, data: TenantModuleCreate): Promise => { + const response = await api.post(`/api/admin/tenants/${tenantId}/modules`, data); + return response.data; + }, + + updateTenantModule: async (tenantId: string, tenantModuleId: string, data: TenantModuleUpdate): Promise => { + const response = await api.put(`/api/admin/tenants/${tenantId}/modules/${tenantModuleId}`, data); + return response.data; + }, + + removeTenantModule: async (tenantId: string, tenantModuleId: string): Promise => { + await api.delete(`/api/admin/tenants/${tenantId}/modules/${tenantModuleId}`); + }, +}; \ No newline at end of file diff --git a/src/application/modules/admin/AdminModuleTypes.ts b/src/application/modules/admin/AdminModuleTypes.ts new file mode 100644 index 0000000..d5f1608 --- /dev/null +++ b/src/application/modules/admin/AdminModuleTypes.ts @@ -0,0 +1,108 @@ +export type ModuleStatus = 'active' | 'inactive'; +export type EnvironmentTrustType = 'internal' | 'full' | 'none'; + +export interface Module { + id: string; + module_id: string; + module_name: string; + description?: string; + icon_url?: string; + status: ModuleStatus; + display_order: number; + created_at: string; + updated_at?: string; +} + +export interface ModuleCreate { + module_id: string; + module_name: string; + description?: string; + icon_url?: string; + status?: ModuleStatus; + display_order?: number; +} + +export interface ModuleUpdate { + module_name?: string; + description?: string; + icon_url?: string; + status?: ModuleStatus; + display_order?: number; +} + +export interface ModuleEnvironment { + id: string; + module_id: string; + slug: string; + frontend_base_url: string; + backend_base_url: string; + sso_entry_path: string; + permission_sync_endpoint: string; + provisioning_endpoint: string; + trust_type: EnvironmentTrustType; + is_default: boolean; + is_active: boolean; + created_at: string; + updated_at?: string; +} + +export interface EnvironmentCreate { + slug: string; + frontend_base_url: string; + backend_base_url: string; + sso_entry_path?: string; + permission_sync_endpoint?: string; + provisioning_endpoint?: string; + trust_type?: EnvironmentTrustType; + trust_credentials?: Record; + is_default?: boolean; + is_active?: boolean; +} + +export interface EnvironmentUpdate { + slug?: string; + frontend_base_url?: string; + backend_base_url?: string; + sso_entry_path?: string; + permission_sync_endpoint?: string; + provisioning_endpoint?: string; + trust_type?: EnvironmentTrustType; + trust_credentials?: Record; + is_default?: boolean; + is_active?: boolean; +} + +export interface ModulePermission { + id: string; + access_code: string; + name: string; + category: string; + parent_id?: string; + scope: string; + module_id: string; +} + +export interface TenantModule { + id: string; + tenant_id: string; + module_id: string; + module_name: string; + module_icon_url?: string; + assigned_environment_slug: string; + is_active: boolean; + module_config?: Record; + created_at: string; +} + +export interface TenantModuleCreate { + module_id: string; + assigned_environment_slug?: string; + is_active?: boolean; + module_config?: Record; +} + +export interface TenantModuleUpdate { + assigned_environment_slug?: string; + is_active?: boolean; + module_config?: Record; +} \ No newline at end of file diff --git a/src/application/modules/admin/components/EnvironmentForm.tsx b/src/application/modules/admin/components/EnvironmentForm.tsx new file mode 100644 index 0000000..fe690e0 --- /dev/null +++ b/src/application/modules/admin/components/EnvironmentForm.tsx @@ -0,0 +1,247 @@ +import { useState, useEffect } from 'react'; +import { X } from 'lucide-react'; +import { adminModuleApi } from '../AdminModuleApi'; +import type { ModuleEnvironment, EnvironmentCreate, EnvironmentUpdate } from '../AdminModuleTypes'; + +interface EnvironmentFormProps { + moduleId: string; + environment: ModuleEnvironment | null; + onClose: (saved: boolean) => void; +} + +const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProps) => { + const [formData, setFormData] = useState({ + slug: '', + frontend_base_url: '', + backend_base_url: '', + sso_entry_path: '/sso/callback', + permission_sync_endpoint: '/internal/permissions', + provisioning_endpoint: '/internal/tenants/provision', + trust_type: 'hmac', + hmac_secret: '', + is_default: false, + is_active: true, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (environment) { + setFormData({ + slug: environment.slug, + frontend_base_url: environment.frontend_base_url, + backend_base_url: environment.backend_base_url, + sso_entry_path: environment.sso_entry_path, + permission_sync_endpoint: environment.permission_sync_endpoint, + provisioning_endpoint: environment.provisioning_endpoint, + trust_type: environment.trust_type, + hmac_secret: '', + is_default: environment.is_default, + is_active: environment.is_active, + }); + } + }, [environment]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + const trust_credentials = formData.trust_type === 'hmac' + ? { hmac_secret: formData.hmac_secret } + : { secret_key: formData.hmac_secret }; + + try { + if (environment) { + const updateData: EnvironmentUpdate = { + slug: formData.slug, + frontend_base_url: formData.frontend_base_url, + backend_base_url: formData.backend_base_url, + sso_entry_path: formData.sso_entry_path, + permission_sync_endpoint: formData.permission_sync_endpoint, + provisioning_endpoint: formData.provisioning_endpoint, + trust_type: formData.trust_type as any, + ...(formData.hmac_secret && { trust_credentials }), + is_default: formData.is_default, + is_active: formData.is_active, + }; + await adminModuleApi.updateEnvironment(moduleId, environment.id, updateData); + } else { + const createData: EnvironmentCreate = { + slug: formData.slug, + frontend_base_url: formData.frontend_base_url, + backend_base_url: formData.backend_base_url, + sso_entry_path: formData.sso_entry_path, + permission_sync_endpoint: formData.permission_sync_endpoint, + provisioning_endpoint: formData.provisioning_endpoint, + trust_type: formData.trust_type as any, + trust_credentials, + is_default: formData.is_default, + is_active: formData.is_active, + }; + await adminModuleApi.createEnvironment(moduleId, createData); + } + onClose(true); + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to save environment'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

{environment ? 'Edit' : 'Create'} Environment

+ +
+ +
+ {error &&
{error}
} + +
+
+ + setFormData({ ...formData, slug: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="prod, staging, eu-prod" + required + disabled={!!environment} + /> +
+
+ + +
+
+ +
+ + setFormData({ ...formData, frontend_base_url: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="https://module.example.com" + required + /> +
+ +
+ + setFormData({ ...formData, backend_base_url: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="https://api.module.example.com" + required + /> +
+ +
+
+ + setFormData({ ...formData, sso_entry_path: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> +
+
+ + setFormData({ ...formData, permission_sync_endpoint: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> +
+
+ +
+ + setFormData({ ...formData, provisioning_endpoint: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> +
+ +
+ + setFormData({ ...formData, hmac_secret: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder={environment ? "Leave blank to keep existing" : "Enter secret"} + required={!environment} + /> +

Stored securely on backend

+
+ +
+ + +
+ +
+ + +
+
+
+
+ ); +}; + +export default EnvironmentForm; \ No newline at end of file diff --git a/src/application/modules/admin/components/ModuleEnvironments.tsx b/src/application/modules/admin/components/ModuleEnvironments.tsx new file mode 100644 index 0000000..e7c733e --- /dev/null +++ b/src/application/modules/admin/components/ModuleEnvironments.tsx @@ -0,0 +1,186 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ArrowLeft, Plus, Edit, Trash2, Check, X } from 'lucide-react'; +import { adminModuleApi } from '../AdminModuleApi'; +import type { ModuleEnvironment, Module } from '../AdminModuleTypes'; +import EnvironmentForm from './EnvironmentForm'; + +const ModuleEnvironments = () => { + const { moduleId } = useParams<{ moduleId: string }>(); + const navigate = useNavigate(); + const [module, setModule] = useState(null); + const [environments, setEnvironments] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [selectedEnv, setSelectedEnv] = useState(null); + + const fetchData = async () => { + if (!moduleId) return; + + try { + const [moduleData, envsData] = await Promise.all([ + adminModuleApi.getModule(moduleId), + adminModuleApi.listEnvironments(moduleId) + ]); + setModule(moduleData); + setEnvironments(envsData); + } catch (error) { + console.error('Failed to fetch data', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, [moduleId]); + + const handleSetDefault = async (envId: string) => { + if (!moduleId) return; + + try { + await adminModuleApi.setDefaultEnvironment(moduleId, envId); + fetchData(); + } catch (error) { + console.error('Failed to set default', error); + } + }; + + const handleDelete = async (env: ModuleEnvironment) => { + if (!moduleId) return; + if (!confirm(`Delete environment "${env.slug}"?`)) return; + + try { + await adminModuleApi.deleteEnvironment(moduleId, env.id); + fetchData(); + } catch (error: any) { + alert(error.response?.data?.detail || 'Failed to delete environment'); + } + }; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+ +
+

+ {module?.module_name} - Environments +

+

Configure environment-specific settings

+
+ +
+ +
+ {environments.map((env) => ( +
+
+
+
+

{env.slug}

+ {env.is_default && ( + + Default + + )} + {env.is_active ? ( + + ) : ( + + )} +
+
+
+ +
+
+ Frontend: +

{env.frontend_base_url}

+
+
+ Backend: +

{env.backend_base_url}

+
+
+
+ Trust: +

{env.trust_type}

+
+
+ SSO Path: +

{env.sso_entry_path}

+
+
+
+ +
+ {!env.is_default && ( + + )} + + +
+
+ ))} +
+ + {environments.length === 0 && ( +
+

No environments configured

+ +
+ )} + + {showForm && moduleId && ( + { + setShowForm(false); + setSelectedEnv(null); + if (saved) fetchData(); + }} + /> + )} +
+ ); +}; + +export default ModuleEnvironments; \ No newline at end of file diff --git a/src/application/modules/admin/components/ModuleForm.tsx b/src/application/modules/admin/components/ModuleForm.tsx new file mode 100644 index 0000000..0ac44ec --- /dev/null +++ b/src/application/modules/admin/components/ModuleForm.tsx @@ -0,0 +1,180 @@ +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { X } from 'lucide-react'; +import { useModuleApi } from '../hooks/useModuleApi'; +import type { Module, ModuleCreate, ModuleUpdate, ModuleStatus } from '../AdminModuleTypes'; +import CustomInput from '../../../../components/custom/CustomInput'; +import CustomButton from '../../../../components/custom/CustomButton'; + +interface ModuleFormProps { + module: Module | null; + onClose: (saved: boolean) => void; +} + +interface ModuleFormData { + module_id: string; + module_name: string; + description: string; + icon_url: string; + status: ModuleStatus; + display_order: number; +} + +const ModuleForm = ({ module, onClose }: ModuleFormProps) => { + const { createModule, updateModule, loading } = useModuleApi(); + + const { register, handleSubmit, formState: { errors }, reset } = useForm({ + defaultValues: { + module_id: '', + module_name: '', + description: '', + icon_url: '', + status: 'active', + display_order: 0, + } + }); + + useEffect(() => { + if (module) { + reset({ + module_id: module.module_id, + module_name: module.module_name, + description: module.description || '', + icon_url: module.icon_url || '', + status: module.status, + display_order: module.display_order, + }); + } + }, [module, reset]); + + const onSubmit = async (data: ModuleFormData) => { + let success; + if (module) { + const updateData: ModuleUpdate = { + module_name: data.module_name, + description: data.description || undefined, + icon_url: data.icon_url || undefined, + status: data.status, + display_order: data.display_order, + }; + success = await updateModule(module.id, updateData); + } else { + const createData: ModuleCreate = { + module_id: data.module_id, + module_name: data.module_name, + description: data.description || undefined, + icon_url: data.icon_url || undefined, + status: data.status, + display_order: data.display_order, + }; + success = await createModule(createData); + } + + if (success) { + onClose(true); + } + }; + + return ( +
+
+
+

+ {module ? 'Edit Module' : 'Create Module'} +

+ +
+ +
+ +

Unique identifier (lowercase, alphanumeric, hyphens)

+ + + +
+ +