From 00e650b775fa6dd39dd35f4ba386751fdb0d5f23 Mon Sep 17 00:00:00 2001 From: Truphile Date: Fri, 24 Jul 2026 10:38:06 -0400 Subject: [PATCH 1/3] feat(admin): add AdminModule plugin interface, registry, and first-party modules --- lib/admin-modules/index.ts | 36 ++++++ lib/admin-modules/modules/analytics.ts | 11 ++ lib/admin-modules/modules/governance.ts | 11 ++ lib/admin-modules/modules/members.ts | 10 ++ lib/admin-modules/modules/overview.ts | 10 ++ lib/admin-modules/modules/policies.ts | 11 ++ lib/admin-modules/modules/rewards.ts | 11 ++ lib/admin-modules/modules/settings.ts | 11 ++ lib/admin-modules/registry.ts | 143 ++++++++++++++++++++++++ lib/admin-modules/types.ts | 59 ++++++++++ test/admin-modules.test.ts | 135 ++++++++++++++++++++++ 11 files changed, 448 insertions(+) create mode 100644 lib/admin-modules/index.ts create mode 100644 lib/admin-modules/modules/analytics.ts create mode 100644 lib/admin-modules/modules/governance.ts create mode 100644 lib/admin-modules/modules/members.ts create mode 100644 lib/admin-modules/modules/overview.ts create mode 100644 lib/admin-modules/modules/policies.ts create mode 100644 lib/admin-modules/modules/rewards.ts create mode 100644 lib/admin-modules/modules/settings.ts create mode 100644 lib/admin-modules/registry.ts create mode 100644 lib/admin-modules/types.ts create mode 100644 test/admin-modules.test.ts diff --git a/lib/admin-modules/index.ts b/lib/admin-modules/index.ts new file mode 100644 index 0000000..7c463c6 --- /dev/null +++ b/lib/admin-modules/index.ts @@ -0,0 +1,36 @@ +import { adminRegistry } from './registry'; +import { overviewModule } from './modules/overview'; +import { membersModule } from './modules/members'; +import { policiesModule } from './modules/policies'; +import { analyticsModule } from './modules/analytics'; +import { rewardsModule } from './modules/rewards'; +import { settingsModule } from './modules/settings'; +import { governanceModule } from './modules/governance'; + +export * from './types'; +export * from './registry'; + +/** + * First-party admin modules. + */ +export const defaultAdminModules = [ + overviewModule, + membersModule, + policiesModule, + analyticsModule, + rewardsModule, + settingsModule, + governanceModule, +]; + +/** + * Register all standard first-party admin modules into the registry. + */ +export function registerDefaultAdminModules(): void { + for (const module of defaultAdminModules) { + adminRegistry.register(module); + } +} + +// Auto-register default first-party admin modules +registerDefaultAdminModules(); diff --git a/lib/admin-modules/modules/analytics.ts b/lib/admin-modules/modules/analytics.ts new file mode 100644 index 0000000..1269685 --- /dev/null +++ b/lib/admin-modules/modules/analytics.ts @@ -0,0 +1,11 @@ +import type { AdminModule } from '../types'; + +export const analyticsModule: AdminModule = { + id: 'analytics', + navLabel: 'Analytics', + route: '/admin/analytics', + featureFlag: 'analytics', + requiredRole: 'admin', + order: 40, + description: 'Community growth charts, signup trends, and membership tier analytics', +}; diff --git a/lib/admin-modules/modules/governance.ts b/lib/admin-modules/modules/governance.ts new file mode 100644 index 0000000..260fc5a --- /dev/null +++ b/lib/admin-modules/modules/governance.ts @@ -0,0 +1,11 @@ +import type { AdminModule } from '../types'; + +export const governanceModule: AdminModule = { + id: 'governance', + navLabel: 'Governance', + route: '/admin/governance', + featureFlag: 'governance', + requiredRole: 'admin', + order: 70, + description: 'Community governance proposals, voting power, and delegation policy', +}; diff --git a/lib/admin-modules/modules/members.ts b/lib/admin-modules/modules/members.ts new file mode 100644 index 0000000..be05fe9 --- /dev/null +++ b/lib/admin-modules/modules/members.ts @@ -0,0 +1,10 @@ +import type { AdminModule } from '../types'; + +export const membersModule: AdminModule = { + id: 'members', + navLabel: null, // Sub-feature accessed from admin dashboard or direct route + route: '/admin/members', + requiredRole: 'admin', + order: 20, + description: 'Member directory, search, pagination, and role assignment', +}; diff --git a/lib/admin-modules/modules/overview.ts b/lib/admin-modules/modules/overview.ts new file mode 100644 index 0000000..34cef4a --- /dev/null +++ b/lib/admin-modules/modules/overview.ts @@ -0,0 +1,10 @@ +import type { AdminModule } from '../types'; + +export const overviewModule: AdminModule = { + id: 'overview', + navLabel: 'Admin', + route: '/admin', + requiredRole: 'admin', + order: 10, + description: 'Ecosystem Webhook Logs, system telemetry, and operational events', +}; diff --git a/lib/admin-modules/modules/policies.ts b/lib/admin-modules/modules/policies.ts new file mode 100644 index 0000000..9a418c7 --- /dev/null +++ b/lib/admin-modules/modules/policies.ts @@ -0,0 +1,11 @@ +import type { AdminModule } from '../types'; + +export const policiesModule: AdminModule = { + id: 'policies', + navLabel: null, // Access policy editor + route: '/admin/policies', + featureFlag: 'adminPolicies', + requiredRole: 'admin', + order: 30, + description: 'Access policy rules, tier constraints, and role requirements', +}; diff --git a/lib/admin-modules/modules/rewards.ts b/lib/admin-modules/modules/rewards.ts new file mode 100644 index 0000000..128a64f --- /dev/null +++ b/lib/admin-modules/modules/rewards.ts @@ -0,0 +1,11 @@ +import type { AdminModule } from '../types'; + +export const rewardsModule: AdminModule = { + id: 'rewards', + navLabel: 'Rewards', + route: '/admin/rewards', + featureFlag: 'rewards', + requiredRole: 'admin', + order: 50, + description: 'Community reward distribution, token allocations, and claim rules', +}; diff --git a/lib/admin-modules/modules/settings.ts b/lib/admin-modules/modules/settings.ts new file mode 100644 index 0000000..39398fc --- /dev/null +++ b/lib/admin-modules/modules/settings.ts @@ -0,0 +1,11 @@ +import type { AdminModule } from '../types'; + +export const settingsModule: AdminModule = { + id: 'settings', + navLabel: 'Settings', + route: '/admin/settings', + featureFlag: 'adminSettings', + requiredRole: 'admin', + order: 60, + description: 'Community settings, branding, and integration gateway configuration', +}; diff --git a/lib/admin-modules/registry.ts b/lib/admin-modules/registry.ts new file mode 100644 index 0000000..611bf23 --- /dev/null +++ b/lib/admin-modules/registry.ts @@ -0,0 +1,143 @@ +import { isFeatureEnabledForIdentifier, features as defaultFeatures, type FeatureFlagKey } from '@/lib/features'; +import type { AdminModule, AdminModuleContext, AdminNavItem } from './types'; + +class AdminModuleRegistry { + private modules: Map = new Map(); + + /** + * Register a new admin module plugin. + * Overwrites any existing module with the same ID. + */ + public register(module: AdminModule): void { + if (!module.id) { + throw new Error('AdminModule registration failed: module must have a valid "id".'); + } + this.modules.set(module.id, module); + } + + /** + * Unregister an admin module by ID. + */ + public unregister(id: string): boolean { + return this.modules.delete(id); + } + + /** + * Retrieve a registered admin module by ID. + */ + public get(id: string): AdminModule | undefined { + return this.modules.get(id); + } + + /** + * Retrieve all registered admin modules sorted by order priority. + */ + public getAll(): AdminModule[] { + return Array.from(this.modules.values()).sort( + (a, b) => (a.order ?? 100) - (b.order ?? 100) + ); + } + + /** + * Check if a module's required feature flag is enabled. + */ + public isFeatureEnabled(module: AdminModule, context?: AdminModuleContext): boolean { + if (!module.featureFlag) { + return true; + } + + if (context?.featureFlags && module.featureFlag in context.featureFlags) { + return Boolean(context.featureFlags[module.featureFlag]); + } + + return isFeatureEnabledForIdentifier(module.featureFlag, context?.userIdentifier); + } + + /** + * Check if the user meets the role requirements for a module. + */ + public hasRequiredRole(module: AdminModule, roles?: string[]): boolean { + if (!module.requiredRole) { + return true; + } + + if (!roles || roles.length === 0) { + return false; + } + + const required = Array.isArray(module.requiredRole) + ? module.requiredRole + : [module.requiredRole]; + + return required.some((role) => roles.includes(role)); + } + + /** + * Check if a module is enabled for the given context (evaluates both feature flag & role). + */ + public isEnabled(module: AdminModule, context?: AdminModuleContext): boolean { + const featureOk = this.isFeatureEnabled(module, context); + const roleOk = this.hasRequiredRole(module, context?.roles); + return featureOk && roleOk; + } + + /** + * Resolve the full href route for a module given the community prefix. + */ + public resolveHref(module: AdminModule, prefix = ''): string { + if (typeof module.route === 'function') { + return module.route(prefix); + } + return prefix ? `${prefix}${module.route}` : module.route; + } + + /** + * Get all active and accessible modules for the provided context. + */ + public getEnabledModules(context?: AdminModuleContext): AdminModule[] { + return this.getAll().filter((mod) => this.isEnabled(mod, context)); + } + + /** + * Get all nav items derived from enabled modules for navigation rendering. + */ + public getNavItems(context?: AdminModuleContext): AdminNavItem[] { + const prefix = context?.prefix ?? ''; + const enabledModules = this.getEnabledModules(context); + + return enabledModules + .filter((mod) => mod.navLabel !== null && mod.navLabel !== undefined) + .map((mod) => ({ + id: mod.id, + href: this.resolveHref(mod, prefix), + label: mod.navLabel as string, + order: mod.order ?? 100, + })) + .sort((a, b) => a.order - b.order); + } + + /** + * Reset registry (clear all registered modules). + */ + public clear(): void { + this.modules.clear(); + } +} + +/** Global singleton instance of the Admin Module Registry */ +export const adminRegistry = new AdminModuleRegistry(); + +// Export convenient standalone helper functions for cleaner API usage +export const registerAdminModule = (module: AdminModule): void => adminRegistry.register(module); +export const unregisterAdminModule = (id: string): boolean => adminRegistry.unregister(id); +export const getAdminModule = (id: string): AdminModule | undefined => adminRegistry.get(id); +export const getAllAdminModules = (): AdminModule[] => adminRegistry.getAll(); +export const getEnabledAdminModules = (context?: AdminModuleContext): AdminModule[] => + adminRegistry.getEnabledModules(context); +export const getNavAdminModules = (context?: AdminModuleContext): AdminNavItem[] => + adminRegistry.getNavItems(context); +export const isAdminModuleEnabled = (module: AdminModule, context?: AdminModuleContext): boolean => + adminRegistry.isEnabled(module, context); +export const resolveAdminModuleHref = (module: AdminModule, prefix?: string): string => + adminRegistry.resolveHref(module, prefix); +export const clearAdminModuleRegistry = (): void => adminRegistry.clear(); diff --git a/lib/admin-modules/types.ts b/lib/admin-modules/types.ts new file mode 100644 index 0000000..81cd41f --- /dev/null +++ b/lib/admin-modules/types.ts @@ -0,0 +1,59 @@ +import type { ComponentType } from 'react'; +import type { FeatureFlagKey } from '@/lib/features'; + +export type AdminRole = string; + +/** + * Self-contained definition for an Admin Module plugin. + * Allows nav entries, routing, feature flags, role gating, and component rendering + * to be declared in a single, pluggable structure. + */ +export interface AdminModule { + /** Unique identifier for the module (e.g., 'overview', 'members', 'policies', 'analytics', 'rewards', 'settings') */ + id: string; + + /** Display label shown in the navigation bar. Set to null to omit from navigation. */ + navLabel?: string | null; + + /** Route path or route builder function (e.g., '/admin/policies' or (prefix) => `${prefix}/admin/policies`) */ + route: string | ((prefix: string) => string); + + /** Optional feature flag key controlling availability of this module */ + featureFlag?: FeatureFlagKey; + + /** Required role(s) to access this module (e.g., 'admin') */ + requiredRole?: AdminRole | AdminRole[]; + + /** Navigation display priority order (lower numbers appear first) */ + order?: number; + + /** Human-readable description of module functionality */ + description?: string; + + /** React Component rendered for this module route */ + component?: ComponentType; +} + +/** + * Evaluation context for checking module availability, navigation URLs, and access rights. + */ +export interface AdminModuleContext { + /** User roles (e.g. ['admin', 'member']) */ + roles?: string[]; + /** Wallet address or user ID (for feature rollout bucket calculations) */ + userIdentifier?: string | null; + /** Active community slug URL prefix (e.g., '/guildpass-demo' or '') */ + prefix?: string; + /** Custom feature flag state overrides (defaults to global features) */ + featureFlags?: Record; +} + +/** + * Computed navigation item derived from a registered AdminModule. + */ +export interface AdminNavItem { + id: string; + href: string; + label: string; + order: number; +} diff --git a/test/admin-modules.test.ts b/test/admin-modules.test.ts new file mode 100644 index 0000000..65dce03 --- /dev/null +++ b/test/admin-modules.test.ts @@ -0,0 +1,135 @@ +import './setup-env'; +import { describe, it, beforeEach } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { + adminRegistry, + registerAdminModule, + unregisterAdminModule, + getAdminModule, + getAllAdminModules, + getEnabledAdminModules, + getNavAdminModules, + clearAdminModuleRegistry, + registerDefaultAdminModules, + type AdminModule, +} from '../lib/admin-modules'; + +describe('AdminModule Plugin System & Registry', () => { + beforeEach(() => { + clearAdminModuleRegistry(); + registerDefaultAdminModules(); + }); + + it('registers default first-party modules correctly', () => { + const modules = getAllAdminModules(); + const ids = modules.map((m) => m.id); + + assert.ok(ids.includes('overview')); + assert.ok(ids.includes('members')); + assert.ok(ids.includes('policies')); + assert.ok(ids.includes('analytics')); + assert.ok(ids.includes('rewards')); + assert.ok(ids.includes('settings')); + assert.ok(ids.includes('governance')); + }); + + it('allows registering and looking up a custom admin module', () => { + const customModule: AdminModule = { + id: 'custom-audit', + navLabel: 'Audit Logs', + route: '/admin/audit', + requiredRole: 'admin', + order: 15, + description: 'Custom third-party audit logging plugin', + }; + + registerAdminModule(customModule); + const fetched = getAdminModule('custom-audit'); + + assert.ok(fetched); + assert.equal(fetched?.id, 'custom-audit'); + assert.equal(fetched?.navLabel, 'Audit Logs'); + }); + + it('filters enabled modules by user role', () => { + // Non-admin user should not receive admin-gated modules + const nonAdminModules = getEnabledAdminModules({ roles: ['member'] }); + assert.equal(nonAdminModules.length, 0); + + // Admin user should receive enabled admin modules + const adminModules = getEnabledAdminModules({ + roles: ['admin'], + featureFlags: { analytics: true, rewards: true, adminSettings: true, adminPolicies: true, governance: true }, + }); + assert.ok(adminModules.length >= 5); + }); + + it('filters enabled modules by feature flags', () => { + const enabledWithFlags = getEnabledAdminModules({ + roles: ['admin'], + featureFlags: { + analytics: true, + rewards: false, + adminSettings: false, + adminPolicies: true, + governance: false, + }, + }); + + const enabledIds = enabledWithFlags.map((m) => m.id); + assert.ok(enabledIds.includes('analytics')); + assert.ok(enabledIds.includes('policies')); + assert.equal(enabledIds.includes('rewards'), false); + assert.equal(enabledIds.includes('settings'), false); + assert.equal(enabledIds.includes('governance'), false); + }); + + it('generates correct nav items with community prefix', () => { + const navItems = getNavAdminModules({ + roles: ['admin'], + prefix: '/guildpass-demo', + featureFlags: { analytics: true, rewards: true, adminSettings: true }, + }); + + const overviewNav = navItems.find((item) => item.id === 'overview'); + const analyticsNav = navItems.find((item) => item.id === 'analytics'); + + assert.ok(overviewNav); + assert.equal(overviewNav?.href, '/guildpass-demo/admin'); + assert.equal(overviewNav?.label, 'Admin'); + + assert.ok(analyticsNav); + assert.equal(analyticsNav?.href, '/guildpass-demo/admin/analytics'); + assert.equal(analyticsNav?.label, 'Analytics'); + }); + + it('allows unregistering a module', () => { + assert.ok(getAdminModule('analytics')); + const unregistered = unregisterAdminModule('analytics'); + assert.equal(unregistered, true); + assert.equal(getAdminModule('analytics'), undefined); + }); + + it('adds a new admin module without modifying core nav or routes', () => { + const newPlugin: AdminModule = { + id: 'token-faucet', + navLabel: 'Faucet', + route: (prefix) => `${prefix}/admin/faucet`, + requiredRole: 'admin', + order: 45, + }; + + registerAdminModule(newPlugin); + + const navItems = getNavAdminModules({ + roles: ['admin'], + prefix: '/builders-collective', + featureFlags: { analytics: true }, + }); + + const faucetNav = navItems.find((item) => item.id === 'token-faucet'); + assert.ok(faucetNav); + assert.equal(faucetNav?.href, '/builders-collective/admin/faucet'); + assert.equal(faucetNav?.label, 'Faucet'); + }); +}); From 574cab568c065004991565c0dc9799441ea4742f Mon Sep 17 00:00:00 2001 From: Truphile Date: Fri, 24 Jul 2026 10:42:11 -0400 Subject: [PATCH 2/3] refactor(nav): render admin navigation items dynamically from module registry --- components/nav.tsx | 32 +++++++++++++------------------- lib/admin-modules/registry.ts | 3 ++- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/components/nav.tsx b/components/nav.tsx index f4ebaca..4b347de 100644 --- a/components/nav.tsx +++ b/components/nav.tsx @@ -12,6 +12,7 @@ import { queryKeys } from "@/lib/query"; import { features } from "@/lib/features"; import { config } from "@/lib/config"; import { useState, useRef, useEffect } from "react"; +import { getNavAdminModules } from "@/lib/admin-modules"; const AVAILABLE_COMMUNITIES = [ { id: 'guildpass-demo', name: 'GuildPass Demo' }, @@ -112,7 +113,7 @@ function CommunitySwitcher({ activeSlug }: { activeSlug: string }) { ); } -function CommunitySwitcher() { +function DisabledCommunitySwitcher() { return (
({ + href: item.href as Route, + label: item.label, + })); const items = [ { href: `${prefix}/dashboard` as Route, label: "Dashboard", enabled: true }, - { href: `${prefix}/admin` as Route, label: "Admin", enabled: isAdmin }, - { - href: `${prefix}/admin/analytics` as Route, - label: "Analytics", - enabled: isAdmin && features.analytics, - }, - { - href: `${prefix}/admin/rewards` as Route, - label: "Rewards", - enabled: isAdmin && features.rewards, - }, - { - href: `${prefix}/admin/settings` as Route, - label: "Settings", - enabled: isAdmin && features.adminSettings, - }, + ...adminNavItems.map((item) => ({ ...item, enabled: true })), { href: `${prefix}/resources/alpha` as Route, label: "Gated", @@ -217,7 +211,7 @@ export function Nav() { {it.label} ))} - {features.multiCommunity && } + {features.multiCommunity && }
diff --git a/lib/admin-modules/registry.ts b/lib/admin-modules/registry.ts index 611bf23..d1db1c1 100644 --- a/lib/admin-modules/registry.ts +++ b/lib/admin-modules/registry.ts @@ -1,4 +1,4 @@ -import { isFeatureEnabledForIdentifier, features as defaultFeatures, type FeatureFlagKey } from '@/lib/features'; +import type { FeatureFlagKey } from '@/lib/features'; import type { AdminModule, AdminModuleContext, AdminNavItem } from './types'; class AdminModuleRegistry { @@ -50,6 +50,7 @@ class AdminModuleRegistry { return Boolean(context.featureFlags[module.featureFlag]); } + const { isFeatureEnabledForIdentifier } = require('../features'); return isFeatureEnabledForIdentifier(module.featureFlag, context?.userIdentifier); } From 489fb37bffe026becdf6f063a243454e13b8b642 Mon Sep 17 00:00:00 2001 From: Truphile Date: Fri, 24 Jul 2026 13:57:05 -0400 Subject: [PATCH 3/3] docs(admin): add documentation and reusable gate component for AdminModule plugin system --- components/admin-module-gate.tsx | 86 +++++++++++++++++++++++++++++ lib/admin-modules/README.md | 95 ++++++++++++++++++++++++++++++++ test/member-cache.test.ts | 1 + 3 files changed, 182 insertions(+) create mode 100644 components/admin-module-gate.tsx create mode 100644 lib/admin-modules/README.md diff --git a/components/admin-module-gate.tsx b/components/admin-module-gate.tsx new file mode 100644 index 0000000..ebe0ab1 --- /dev/null +++ b/components/admin-module-gate.tsx @@ -0,0 +1,86 @@ +'use client'; + +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useAccount } from 'wagmi'; +import { useParams } from 'next/navigation'; +import { getApi } from '@/lib/api'; +import { queryKeys } from '@/lib/query'; +import { useSiweAuth } from '@/lib/wallet/providers'; +import { AdminGuard } from '@/components/admin-guard'; +import { FeatureUnavailable } from '@/components/feature-gate'; +import { + getAdminModule, + isAdminModuleEnabled, + adminRegistry, + type AdminModule, +} from '@/lib/admin-modules'; +import { DeniedState } from '@/components/ui/api-states'; + +interface AdminModuleGateProps { + moduleId: string; + children?: React.ReactNode; +} + +/** + * Gate component that enforces AdminModule plugin requirements (feature flags & user roles). + * Dynamically looks up module definition from the registry. + */ +export function AdminModuleGate({ moduleId, children }: AdminModuleGateProps) { + const { address } = useAccount(); + const { authSession, sessionStatus } = useSiweAuth(); + const params = useParams(); + const communitySlug = (params?.communitySlug as string) || 'guildpass-demo'; + + const { data: session } = useQuery({ + queryKey: queryKeys.session.byAddress(address ?? authSession?.address ?? '', communitySlug), + queryFn: () => getApi(address ?? authSession?.address, authSession?.token, communitySlug).getSession(), + enabled: sessionStatus === 'authenticated' && !!(address ?? authSession?.address), + staleTime: 10_000, + retry: 1, + }); + + const module = getAdminModule(moduleId); + + if (!module) { + return ( + + + + ); + } + + // Evaluate Feature Flag + const isFeatureOk = adminRegistry.isFeatureEnabled(module, { userIdentifier: address }); + if (!isFeatureOk) { + return ( + + + + ); + } + + // Evaluate User Role + const isRoleOk = adminRegistry.hasRequiredRole(module, session?.roles); + if (sessionStatus === 'authenticated' && session && !isRoleOk) { + return ( + + + + ); + } + + const Component = module.component; + + return ( + + {Component ? : children} + + ); +} diff --git a/lib/admin-modules/README.md b/lib/admin-modules/README.md new file mode 100644 index 0000000..2633abb --- /dev/null +++ b/lib/admin-modules/README.md @@ -0,0 +1,95 @@ +# Admin Module Plugin Architecture + +The Admin Module Plugin System provides a modular, self-contained architecture for extending the GuildPass Admin Dashboard. Community deployments and downstream forks can add or customize admin capabilities without modifying core navigation (`components/nav.tsx`) or central routing configurations. + +--- + +## Overview + +Each admin module is defined as a self-contained plugin conforming to the `AdminModule` interface. Modules specify their own: +- **Identification & Meta**: Unique ID, display label, and human-readable description. +- **Routing**: Internal path or dynamic route builder (supporting community URL prefixes). +- **Access Control**: Role requirements (e.g. `admin`) and feature flag gating. +- **Order Priority**: Display ordering in top-level or dashboard navigation bars. +- **UI Component**: React component rendered for the module. + +Modules register with a central `AdminModuleRegistry` singleton, which evaluates user roles and feature flag rollouts to dynamically supply valid navigation links and active views. + +--- + +## Interface Definition (`lib/admin-modules/types.ts`) + +```typescript +export interface AdminModule { + /** Unique identifier for the module (e.g., 'overview', 'members', 'policies') */ + id: string; + + /** Display label shown in navigation. Set to null or omit to hide from main nav. */ + navLabel?: string | null; + + /** Path or dynamic route builder function `(prefix: string) => string` */ + route: string | ((prefix: string) => string); + + /** Optional feature flag key controlling availability */ + featureFlag?: FeatureFlagKey; + + /** Required role(s) to access this module (e.g., 'admin') */ + requiredRole?: AdminRole | AdminRole[]; + + /** Navigation display priority order (lower numbers appear first) */ + order?: number; + + /** Human-readable description of module functionality */ + description?: string; + + /** Optional React component for rendering module UI */ + component?: ComponentType; +} +``` + +--- + +## Registering a Custom Admin Module + +To register a new module without modifying core app files: + +```typescript +import { registerAdminModule } from '@/lib/admin-modules'; + +registerAdminModule({ + id: 'audit-logs', + navLabel: 'Audit Logs', + route: '/admin/audit', + requiredRole: 'admin', + featureFlag: 'adminSettings', // Optional feature flag gate + order: 45, + description: 'Security audit telemetry and member access logs', +}); +``` + +When registered, the core navigation (`components/nav.tsx`) automatically includes "Audit Logs" for authorized admins when the specified feature flag is active. + +--- + +## API Reference (`lib/admin-modules/registry.ts`) + +- `registerAdminModule(module: AdminModule)`: Adds or replaces a module in the registry. +- `unregisterAdminModule(id: string)`: Removes a registered module by ID. +- `getAdminModule(id: string)`: Returns the module matching the ID. +- `getAllAdminModules()`: Returns all registered modules sorted by priority `order`. +- `getEnabledAdminModules(context)`: Evaluates user roles and feature flags, returning active modules. +- `getNavAdminModules(context)`: Returns computed `AdminNavItem[]` ready for top-level header or sidebar navigation. +- `clearAdminModuleRegistry()`: Resets the registry (useful for test isolation). + +--- + +## First-Party Modules + +The standard distribution includes default modules in `lib/admin-modules/modules/`: +1. `overview` (`/admin` - Ecosystem Webhook & Telemetry Logs) +2. `members` (`/admin/members` - Directory & Role Assignment) +3. `policies` (`/admin/policies` - Tier Constraints & Access Policies) +4. `analytics` (`/admin/analytics` - Growth & Tier Analytics) +5. `rewards` (`/admin/rewards` - Distribution & Token Allocations) +6. `settings` (`/admin/settings` - Gateway & Community Settings) +7. `governance` (`/admin/governance` - Proposal & Voting Policy) diff --git a/test/member-cache.test.ts b/test/member-cache.test.ts index 34d73e2..3059b06 100644 --- a/test/member-cache.test.ts +++ b/test/member-cache.test.ts @@ -1,3 +1,4 @@ +import './setup-env' import { describe, it } from 'node:test' import * as assert from 'node:assert/strict' import { QueryClient, QueryObserver } from '@tanstack/react-query'