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:
Ryan Moon
2026-03-30 06:52:27 -05:00
parent 1f9297f533
commit e346e072b8
10 changed files with 294 additions and 13 deletions

View File

@@ -0,0 +1,28 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
export interface ModuleConfig {
id: string
slug: string
name: string
description: string | null
licensed: boolean
enabled: boolean
createdAt: string
updatedAt: string
}
export const moduleKeys = {
list: ['modules'] as const,
}
export function moduleListOptions() {
return queryOptions({
queryKey: moduleKeys.list,
queryFn: () => api.get<{ data: ModuleConfig[] }>('/v1/modules'),
})
}
export const moduleMutations = {
toggle: (slug: string, enabled: boolean) => api.patch<ModuleConfig>(`/v1/modules/${slug}`, { enabled }),
}

View File

@@ -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>

View File

@@ -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)