diff --git a/apps/web/src/app/(portal)/admin/modules/page.tsx b/apps/web/src/app/(portal)/admin/modules/page.tsx new file mode 100644 index 0000000..381a100 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/modules/page.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useAuthStore } from '@/lib/stores/auth-store'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +interface Module { + id: string; + slug: string; + name: string; + version: string; + category: string; + description: Record | string; + icon: string | null; + isSystem: boolean; +} + +interface TenantActivation { + id: string; + moduleId: string; + isActive: boolean; +} + +export default function AdminModulesPage() { + const t = useTranslations('adminModules'); + const tCommon = useTranslations('common'); + const currentUser = useAuthStore((s) => s.user); + + const [modules, setModules] = useState([]); + const [activations, setActivations] = useState>(new Map()); + const [loading, setLoading] = useState(true); + const [toggling, setToggling] = useState(null); + + const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; + + const fetchModules = useCallback(async () => { + try { + const [allRes, activeRes] = await Promise.all([ + fetch(`${API_URL}/modules`, { credentials: 'include' }), + fetch(`${API_URL}/modules/active`, { credentials: 'include' }), + ]); + + if (allRes.ok) { + const allModules: Module[] = await allRes.json(); + setModules(allModules); + } + + if (activeRes.ok) { + const activeModules: (Module & { activations?: TenantActivation[] })[] = await activeRes.json(); + const map = new Map(); + for (const mod of activeModules) { + map.set(mod.id, true); + } + setActivations(map); + } + } catch { + // silently fail + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (isAdmin) { + fetchModules(); + } else { + setLoading(false); + } + }, [isAdmin, fetchModules]); + + const toggleModule = async (moduleId: string, currentlyActive: boolean) => { + setToggling(moduleId); + try { + const action = currentlyActive ? 'deactivate' : 'activate'; + const res = await fetch(`${API_URL}/modules/${moduleId}/${action}`, { + method: 'POST', + credentials: 'include', + }); + + if (res.ok) { + setActivations((prev) => { + const next = new Map(prev); + if (currentlyActive) { + next.delete(moduleId); + } else { + next.set(moduleId, true); + } + return next; + }); + } + } catch { + // silently fail + } finally { + setToggling(null); + } + }; + + const getDescription = (desc: Record | string): string => { + if (typeof desc === 'string') return desc; + return desc.de || desc.en || Object.values(desc)[0] || ''; + }; + + if (!isAdmin) { + return ( +
+

{tCommon('accessDenied')}

+
+ ); + } + + return ( +
+
+

{t('title')}

+

{t('description')}

+
+ + {loading ? ( +

{tCommon('loading')}

+ ) : modules.length === 0 ? ( +

{t('noModules')}

+ ) : ( +
+ {modules.map((mod) => { + const isActive = activations.has(mod.id); + const isToggling = toggling === mod.id; + + return ( +
+
+
+

{mod.name}

+ + v{mod.version} + + {mod.isSystem && ( + + {t('system')} + + )} + + {mod.category} + +
+

+ {getDescription(mod.description)} +

+
+ +
+ + {isActive ? t('activated') : t('deactivated')} + + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index ce4b156..8e6aea4 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -1,18 +1,45 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSidebarStore } from '@/lib/stores/sidebar-store'; import { useAuthStore } from '@/lib/stores/auth-store'; import { SidebarFooter } from '@/components/layout/sidebar-footer'; +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +interface SidebarModule { + id: string; + slug: string; + name: string; + category: string; +} + export function Sidebar() { const t = useTranslations('sidebar'); const tCommon = useTranslations('common'); const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore(); - const [categoriesOpen, setCategoriesOpen] = useState(false); + const [categoriesOpen, setCategoriesOpen] = useState(true); + const [activeModules, setActiveModules] = useState([]); const user = useAuthStore((s) => s.user); + const fetchActiveModules = useCallback(async () => { + try { + const res = await fetch(`${API_URL}/modules/active`, { + credentials: 'include', + }); + if (res.ok) { + setActiveModules(await res.json()); + } + } catch { + // silently fail + } + }, []); + + useEffect(() => { + fetchActiveModules(); + }, [fetchActiveModules]); + // Admin section visibility based on role const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'; const isSuperAdmin = user?.role === 'SUPER_ADMIN'; @@ -76,7 +103,7 @@ export function Sidebar() { - {/* Categories accordion (D-05) */} + {/* Categories accordion with active modules (D-05) */} {!isCollapsed && (
{categoriesOpen && ( -
- {t('categories.empty')} +
+ {activeModules.length === 0 ? ( +
+ {t('categories.empty')} +
+ ) : ( +
    + {(() => { + const categories = new Map(); + for (const mod of activeModules) { + const cat = mod.category || 'other'; + if (!categories.has(cat)) categories.set(cat, []); + categories.get(cat)!.push(mod); + } + return Array.from(categories.entries()).map(([category, mods]) => ( +
  • + + + + + {category.replace(/-/g, ' ')} + {mods.length} + + +
  • + )); + })()} +
+ )}
)}
@@ -172,6 +240,34 @@ export function Sidebar() { )} + {/* Modules link -- ADMIN and SUPER_ADMIN */} +
  • + + + + + + + {!isCollapsed && ( + {t('modules')} + )} + +
  • + {/* LDAP link -- ADMIN and SUPER_ADMIN (D-18) */}