Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions components/admin-module-gate.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AdminGuard>
<DeniedState
title="Module Not Found"
message={`Admin module '${moduleId}' is not registered.`}
/>
</AdminGuard>
);
}

// Evaluate Feature Flag
const isFeatureOk = adminRegistry.isFeatureEnabled(module, { userIdentifier: address });
if (!isFeatureOk) {
return (
<AdminGuard>
<FeatureUnavailable name={module.navLabel || module.id} />
</AdminGuard>
);
}

// Evaluate User Role
const isRoleOk = adminRegistry.hasRequiredRole(module, session?.roles);
if (sessionStatus === 'authenticated' && session && !isRoleOk) {
return (
<AdminGuard>
<DeniedState
title="Role Required"
message={`Your account lacks the required role to access the ${module.navLabel || module.id} module.`}
/>
</AdminGuard>
);
}

const Component = module.component;

return (
<AdminGuard>
{Component ? <Component /> : children}
</AdminGuard>
);
}
32 changes: 13 additions & 19 deletions components/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -112,7 +113,7 @@ function CommunitySwitcher({ activeSlug }: { activeSlug: string }) {
);
}

function CommunitySwitcher() {
function DisabledCommunitySwitcher() {
return (
<div
className="relative inline-flex"
Expand Down Expand Up @@ -151,26 +152,19 @@ export function Nav() {
});

const prefix = features.multiCommunity ? `/${communitySlug}` : "";
const isAdmin = !!session?.roles?.includes("admin");

const adminNavItems = getNavAdminModules({
roles: session?.roles,
prefix,
userIdentifier: address,
}).map((item) => ({
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",
Expand Down Expand Up @@ -217,7 +211,7 @@ export function Nav() {
{it.label}
</Link>
))}
{features.multiCommunity && <CommunitySwitcher />}
{features.multiCommunity && <DisabledCommunitySwitcher />}
<ConnectButton />
</nav>
</div>
Expand Down
95 changes: 95 additions & 0 deletions lib/admin-modules/README.md
Original file line number Diff line number Diff line change
@@ -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<any>;
}
```

---

## 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)
36 changes: 36 additions & 0 deletions lib/admin-modules/index.ts
Original file line number Diff line number Diff line change
@@ -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();
11 changes: 11 additions & 0 deletions lib/admin-modules/modules/analytics.ts
Original file line number Diff line number Diff line change
@@ -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',
};
11 changes: 11 additions & 0 deletions lib/admin-modules/modules/governance.ts
Original file line number Diff line number Diff line change
@@ -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',
};
10 changes: 10 additions & 0 deletions lib/admin-modules/modules/members.ts
Original file line number Diff line number Diff line change
@@ -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',
};
10 changes: 10 additions & 0 deletions lib/admin-modules/modules/overview.ts
Original file line number Diff line number Diff line change
@@ -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',
};
11 changes: 11 additions & 0 deletions lib/admin-modules/modules/policies.ts
Original file line number Diff line number Diff line change
@@ -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',
};
11 changes: 11 additions & 0 deletions lib/admin-modules/modules/rewards.ts
Original file line number Diff line number Diff line change
@@ -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',
};
11 changes: 11 additions & 0 deletions lib/admin-modules/modules/settings.ts
Original file line number Diff line number Diff line change
@@ -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',
};
Loading