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)

View File

@@ -91,6 +91,22 @@ async function setupDatabase() {
}
}
// Seed default modules
const DEFAULT_MODULES = [
{ slug: 'inventory', name: 'Inventory', description: 'Product catalog, stock tracking, and unit management', enabled: true },
{ slug: 'pos', name: 'Point of Sale', description: 'Sales transactions, cash drawer, and receipts', enabled: true },
{ slug: 'repairs', name: 'Repairs', description: 'Repair ticket management, batches, and service templates', enabled: true },
{ slug: 'rentals', name: 'Rentals', description: 'Instrument rental agreements and billing', enabled: false },
{ slug: 'lessons', name: 'Lessons', description: 'Lesson scheduling, instructor management, and billing', enabled: false },
{ slug: 'files', name: 'Files', description: 'Shared file storage with folder organization', enabled: true },
{ slug: 'vault', name: 'Vault', description: 'Encrypted password and secret manager', enabled: true },
{ slug: 'email', name: 'Email', description: 'Email campaigns, templates, and sending', enabled: false },
{ slug: 'reports', name: 'Reports', description: 'Business reports and data export', enabled: true },
]
for (const m of DEFAULT_MODULES) {
await testSql`INSERT INTO module_config (slug, name, description, licensed, enabled) VALUES (${m.slug}, ${m.name}, ${m.description}, true, ${m.enabled}) ON CONFLICT (slug) DO NOTHING`
}
await testSql.end()
console.log(' Database ready')
}

View File

@@ -0,0 +1,24 @@
-- Module configuration table for enabling/disabling feature modules
CREATE TABLE module_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
licensed BOOLEAN NOT NULL DEFAULT true,
enabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Seed default modules
INSERT INTO module_config (slug, name, description, licensed, enabled) VALUES
('inventory', 'Inventory', 'Product catalog, stock tracking, and unit management', true, true),
('pos', 'Point of Sale', 'Sales transactions, cash drawer, and receipts', true, true),
('repairs', 'Repairs', 'Repair ticket management, batches, and service templates', true, true),
('rentals', 'Rentals', 'Instrument rental agreements and billing', true, false),
('lessons', 'Lessons', 'Lesson scheduling, instructor management, and billing', true, false),
('files', 'Files', 'Shared file storage with folder organization', true, true),
('vault', 'Vault', 'Encrypted password and secret manager', true, true),
('email', 'Email', 'Email campaigns, templates, and sending', true, false),
('reports', 'Reports', 'Business reports and data export', true, true);

View File

@@ -183,6 +183,13 @@
"when": 1774850000000,
"tag": "0025_vault",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1774860000000,
"tag": "0026_modules",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,14 @@
import { pgTable, uuid, varchar, text, timestamp, boolean } from 'drizzle-orm/pg-core'
export const moduleConfig = pgTable('module_config', {
id: uuid('id').primaryKey().defaultRandom(),
slug: varchar('slug', { length: 50 }).notNull().unique(),
name: varchar('name', { length: 100 }).notNull(),
description: text('description'),
licensed: boolean('licensed').notNull().default(true),
enabled: boolean('enabled').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export type ModuleConfig = typeof moduleConfig.$inferSelect

View File

@@ -20,7 +20,9 @@ import { storageRoutes } from './routes/v1/storage.js'
import { storeRoutes } from './routes/v1/store.js'
import { vaultRoutes } from './routes/v1/vault.js'
import { webdavRoutes } from './routes/webdav/index.js'
import { moduleRoutes } from './routes/v1/modules.js'
import { RbacService } from './services/rbac.service.js'
import { ModuleService } from './services/module.service.js'
export async function buildApp() {
const app = Fastify({
@@ -61,19 +63,48 @@ export async function buildApp() {
throw new Error('JWT_SECRET is required in production')
}
// Routes
// Module gate middleware — returns 403 if module is disabled
function requireModule(slug: string) {
return async (_request: any, reply: any) => {
const enabled = await ModuleService.isEnabled(app.db, slug)
if (!enabled) {
reply.status(403).send({ error: { message: `Module '${slug}' is not enabled`, statusCode: 403, module: slug } })
}
}
}
// Helper: wrap a route plugin so every route in it gets a module check.
// Uses fastify-plugin (fp) pattern to inherit parent decorators while adding the hook.
function withModule(slug: string, plugin: any) {
const wrapped = async (instance: any, opts: any) => {
instance.addHook('onRequest', requireModule(slug))
await plugin(instance, opts)
}
// Copy the plugin's symbol to maintain encapsulation behavior
if (plugin[Symbol.for('skip-override')]) {
wrapped[Symbol.for('skip-override')] = true
}
// Copy plugin name for debugging
Object.defineProperty(wrapped, 'name', { value: `${plugin.name || 'anonymous'}[${slug}]` })
return wrapped
}
// Core routes — always available
await app.register(healthRoutes, { prefix: '/v1' })
await app.register(authRoutes, { prefix: '/v1' })
await app.register(accountRoutes, { prefix: '/v1' })
await app.register(inventoryRoutes, { prefix: '/v1' })
await app.register(productRoutes, { prefix: '/v1' })
await app.register(lookupRoutes, { prefix: '/v1' })
await app.register(fileRoutes, { prefix: '/v1' })
await app.register(rbacRoutes, { prefix: '/v1' })
await app.register(repairRoutes, { prefix: '/v1' })
await app.register(storageRoutes, { prefix: '/v1' })
await app.register(storeRoutes, { prefix: '/v1' })
await app.register(vaultRoutes, { prefix: '/v1' })
await app.register(moduleRoutes, { prefix: '/v1' })
await app.register(lookupRoutes, { prefix: '/v1' })
// Module-gated routes
await app.register(withModule('inventory', inventoryRoutes), { prefix: '/v1' })
await app.register(withModule('inventory', productRoutes), { prefix: '/v1' })
await app.register(withModule('files', fileRoutes), { prefix: '/v1' })
await app.register(withModule('files', storageRoutes), { prefix: '/v1' })
await app.register(withModule('repairs', repairRoutes), { prefix: '/v1' })
await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' })
// Register WebDAV custom HTTP methods before routes
app.addHttpMethod('PROPFIND', { hasBody: true })
app.addHttpMethod('PROPPATCH', { hasBody: true })
@@ -84,7 +115,7 @@ export async function buildApp() {
app.addHttpMethod('UNLOCK')
await app.register(webdavRoutes, { prefix: '/webdav' })
// Auto-seed system permissions on startup
// Auto-seed system permissions and warm module cache on startup
app.addHook('onReady', async () => {
try {
await RbacService.seedPermissions(app.db)
@@ -92,6 +123,13 @@ export async function buildApp() {
} catch (err) {
app.log.error({ err }, 'Failed to seed permissions')
}
try {
await ModuleService.refreshCache(app.db)
const enabled = await ModuleService.getEnabledSlugs(app.db)
app.log.info({ modules: [...enabled] }, 'Module cache loaded')
} catch (err) {
app.log.error({ err }, 'Failed to load module cache')
}
})
return app

View File

@@ -0,0 +1,26 @@
import type { FastifyPluginAsync } from 'fastify'
import { ModuleService } from '../../services/module.service.js'
export const moduleRoutes: FastifyPluginAsync = async (app) => {
app.get('/modules', { preHandler: [app.authenticate, app.requirePermission('settings.view')] }, async (_request, reply) => {
const modules = await ModuleService.listAll(app.db)
return reply.send({ data: modules })
})
app.patch('/modules/:slug', { preHandler: [app.authenticate, app.requirePermission('settings.edit')] }, async (request, reply) => {
const { slug } = request.params as { slug: string }
const { enabled } = request.body as { enabled?: boolean }
if (typeof enabled !== 'boolean') {
return reply.status(400).send({ error: { message: 'enabled (boolean) is required', statusCode: 400 } })
}
try {
const updated = await ModuleService.setEnabled(app.db, slug, enabled)
if (!updated) return reply.status(404).send({ error: { message: 'Module not found', statusCode: 404 } })
return reply.send(updated)
} catch (err: any) {
return reply.status(400).send({ error: { message: err.message, statusCode: 400 } })
}
})
}

View File

@@ -0,0 +1,51 @@
import { eq } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { moduleConfig } from '../db/schema/modules.js'
// In-memory cache of enabled module slugs — avoids DB query per request
let enabledCache: Set<string> | null = null
export const ModuleService = {
async listAll(db: PostgresJsDatabase<any>) {
return db.select().from(moduleConfig).orderBy(moduleConfig.name)
},
async isEnabled(db: PostgresJsDatabase<any>, slug: string): Promise<boolean> {
if (!enabledCache) await this.refreshCache(db)
return enabledCache!.has(slug)
},
async getEnabledSlugs(db: PostgresJsDatabase<any>): Promise<Set<string>> {
if (!enabledCache) await this.refreshCache(db)
return new Set(enabledCache!)
},
async setEnabled(db: PostgresJsDatabase<any>, slug: string, enabled: boolean): Promise<typeof moduleConfig.$inferSelect | null> {
// Check that module exists and is licensed
const [mod] = await db.select().from(moduleConfig).where(eq(moduleConfig.slug, slug)).limit(1)
if (!mod) return null
if (enabled && !mod.licensed) {
throw new Error('Module is not licensed')
}
const [updated] = await db.update(moduleConfig)
.set({ enabled, updatedAt: new Date() })
.where(eq(moduleConfig.slug, slug))
.returning()
// Invalidate cache
enabledCache = null
return updated ?? null
},
async refreshCache(db: PostgresJsDatabase<any>) {
const modules = await db.select({ slug: moduleConfig.slug, enabled: moduleConfig.enabled, licensed: moduleConfig.licensed })
.from(moduleConfig)
enabledCache = new Set(modules.filter((m) => m.enabled && m.licensed).map((m) => m.slug))
},
invalidateCache() {
enabledCache = null
},
}