Add module management system for enabling/disabling features
Stores can enable/disable feature modules from Settings. When disabled, nav links are hidden and API routes return 403. Designed as the foundation for future license-based gating (licensed + enabled flags). Core modules (Accounts, Members, Users, Roles, Settings) are always on. - module_config table with slug, name, description, licensed, enabled - In-memory cache for fast per-request module checks - requireModule middleware wraps route groups in main.ts - Settings page Modules card with toggle switches - Sidebar hides nav links for disabled modules - Default modules seeded: inventory, pos, repairs, rentals, lessons, files, vault, email, reports
This commit is contained in:
@@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { myPermissionsOptions } from '@/api/rbac'
|
||||
import { moduleListOptions } from '@/api/modules'
|
||||
import { Avatar } from '@/components/shared/avatar-upload'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings } from 'lucide-react'
|
||||
@@ -90,6 +91,17 @@ function AuthenticatedLayout() {
|
||||
enabled: !!useAuthStore.getState().token,
|
||||
})
|
||||
|
||||
// Fetch enabled modules
|
||||
const { data: modulesData } = useQuery({
|
||||
...moduleListOptions(),
|
||||
enabled: !!useAuthStore.getState().token,
|
||||
})
|
||||
|
||||
const enabledModules = new Set(
|
||||
(modulesData?.data ?? []).filter((m) => m.enabled && m.licensed).map((m) => m.slug),
|
||||
)
|
||||
const isModuleEnabled = (slug: string) => enabledModules.has(slug)
|
||||
|
||||
useEffect(() => {
|
||||
if (permData?.permissions) {
|
||||
setPermissions(permData.permissions)
|
||||
@@ -124,7 +136,7 @@ function AuthenticatedLayout() {
|
||||
<NavLink to="/members" icon={<UserRound className="h-4 w-4" />} label="Members" />
|
||||
</>
|
||||
)}
|
||||
{canViewRepairs && (
|
||||
{isModuleEnabled('repairs') && canViewRepairs && (
|
||||
<>
|
||||
<NavLink to="/repairs" icon={<Wrench className="h-4 w-4" />} label="Repairs" />
|
||||
<NavLink to="/repair-batches" icon={<Package className="h-4 w-4" />} label="Repair Batches" />
|
||||
@@ -133,8 +145,12 @@ function AuthenticatedLayout() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<NavLink to="/files" icon={<FolderOpen className="h-4 w-4" />} label="Files" />
|
||||
<NavLink to="/vault" icon={<KeyRound className="h-4 w-4" />} label="Vault" />
|
||||
{isModuleEnabled('files') && (
|
||||
<NavLink to="/files" icon={<FolderOpen className="h-4 w-4" />} label="Files" />
|
||||
)}
|
||||
{isModuleEnabled('vault') && (
|
||||
<NavLink to="/vault" icon={<KeyRound className="h-4 w-4" />} label="Vault" />
|
||||
)}
|
||||
{canViewUsers && (
|
||||
<div className="mt-4 mb-1 px-3">
|
||||
<span className="text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wide">Admin</span>
|
||||
|
||||
@@ -13,7 +13,9 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { AvatarUpload } from '@/components/shared/avatar-upload'
|
||||
import { Save, Plus, Trash2, MapPin, Building, ImageIcon } from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { moduleListOptions, moduleMutations, moduleKeys, type ModuleConfig } from '@/api/modules'
|
||||
import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface StoreSettings {
|
||||
@@ -232,10 +234,69 @@ function SettingsPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modules */}
|
||||
<ModulesCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModulesCard() {
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canEdit = hasPermission('settings.edit')
|
||||
|
||||
const { data: modulesData, isLoading } = useQuery(moduleListOptions())
|
||||
const modules = modulesData?.data ?? []
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ slug, enabled }: { slug: string; enabled: boolean }) => moduleMutations.toggle(slug, enabled),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: moduleKeys.list })
|
||||
toast.success('Module updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Blocks className="h-5 w-5" />Modules
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{modules.map((mod) => (
|
||||
<div key={mod.id} className="flex items-center justify-between p-3 rounded-md border">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{mod.name}</span>
|
||||
{!mod.licensed && (
|
||||
<Badge variant="outline" className="text-[10px] gap-1">
|
||||
<Lock className="h-2.5 w-2.5" />Not Licensed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{mod.description && <p className="text-xs text-muted-foreground mt-0.5">{mod.description}</p>}
|
||||
</div>
|
||||
<Switch
|
||||
checked={mod.enabled}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ slug: mod.slug, enabled: checked })}
|
||||
disabled={!canEdit || !mod.licensed || toggleMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function LocationCard({ location }: { location: Location }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
Reference in New Issue
Block a user