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:
28
packages/admin/src/api/modules.ts
Normal file
28
packages/admin/src/api/modules.ts
Normal 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 }),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
24
packages/backend/src/db/migrations/0026_modules.sql
Normal file
24
packages/backend/src/db/migrations/0026_modules.sql
Normal 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);
|
||||
@@ -183,6 +183,13 @@
|
||||
"when": 1774850000000,
|
||||
"tag": "0025_vault",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1774860000000,
|
||||
"tag": "0026_modules",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
14
packages/backend/src/db/schema/modules.ts
Normal file
14
packages/backend/src/db/schema/modules.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
26
packages/backend/src/routes/v1/modules.ts
Normal file
26
packages/backend/src/routes/v1/modules.ts
Normal 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 } })
|
||||
}
|
||||
})
|
||||
}
|
||||
51
packages/backend/src/services/module.service.ts
Normal file
51
packages/backend/src/services/module.service.ts
Normal 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
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user