Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(admin): add plugin management page with settings and dependency warnings (M13) (#14)

Phase 1 plugin management: list installed plugins with enable/disable
toggles, settings modal with auto-generated forms from JSON Schema,
uninstall button, source badges (Core/Official/Community/Experimental),
and dependency warnings when disabling plugins with dependents. Adds
Plugin types, API client functions, mock data, and MSW handlers.

authored by

Guido X Jansen and committed by
GitHub
41d380dd 34095950

+805
+120
src/app/admin/plugins/page.test.tsx
··· 1 + /** 2 + * Tests for admin plugins page. 3 + * @see specs/prd-web.md Section M13 4 + */ 5 + 6 + import { describe, it, expect } from 'vitest' 7 + import { render, screen, waitFor, within } from '@testing-library/react' 8 + import userEvent from '@testing-library/user-event' 9 + import { axe } from 'vitest-axe' 10 + import AdminPluginsPage from './page' 11 + 12 + // Mock next/navigation 13 + vi.mock('next/navigation', () => ({ 14 + usePathname: () => '/admin/plugins', 15 + })) 16 + 17 + describe('AdminPluginsPage', () => { 18 + it('renders page heading', async () => { 19 + render(<AdminPluginsPage />) 20 + await waitFor(() => { 21 + expect(screen.getByRole('heading', { name: /plugins/i, level: 1 })).toBeInTheDocument() 22 + }) 23 + }) 24 + 25 + it('renders plugin list from API', async () => { 26 + render(<AdminPluginsPage />) 27 + await waitFor(() => { 28 + expect(screen.getByText('Full-Text Search')).toBeInTheDocument() 29 + }) 30 + expect(screen.getByText('Markdown Editor')).toBeInTheDocument() 31 + expect(screen.getByText('Code Highlighting')).toBeInTheDocument() 32 + expect(screen.getByText('Community Analytics')).toBeInTheDocument() 33 + expect(screen.getByText('Webhook Notifications')).toBeInTheDocument() 34 + }) 35 + 36 + it('shows source badges (Core, Official, Community, Experimental)', async () => { 37 + render(<AdminPluginsPage />) 38 + await waitFor(() => { 39 + expect(screen.getAllByText('Core').length).toBeGreaterThanOrEqual(2) 40 + }) 41 + expect(screen.getByText('Official')).toBeInTheDocument() 42 + expect(screen.getByText('Community')).toBeInTheDocument() 43 + expect(screen.getByText('Experimental')).toBeInTheDocument() 44 + }) 45 + 46 + it('shows version numbers', async () => { 47 + render(<AdminPluginsPage />) 48 + await waitFor(() => { 49 + expect(screen.getByText('v1.0.0')).toBeInTheDocument() 50 + }) 51 + expect(screen.getByText('v1.2.0')).toBeInTheDocument() 52 + expect(screen.getByText('v0.2.0-beta')).toBeInTheDocument() 53 + }) 54 + 55 + it('shows enabled/disabled state for plugins', async () => { 56 + render(<AdminPluginsPage />) 57 + await waitFor(() => { 58 + expect(screen.getByText('Full-Text Search')).toBeInTheDocument() 59 + }) 60 + // Find toggle buttons - enabled plugins should have checked toggles 61 + const toggles = screen.getAllByRole('switch') 62 + expect(toggles.length).toBeGreaterThanOrEqual(5) 63 + }) 64 + 65 + it('shows dependency warning when attempting to disable a plugin with dependents', async () => { 66 + const user = userEvent.setup() 67 + render(<AdminPluginsPage />) 68 + await waitFor(() => { 69 + expect(screen.getByText('Markdown Editor')).toBeInTheDocument() 70 + }) 71 + 72 + // Markdown Editor has dependents: ['barazo-plugin-code-highlight'] 73 + // Find the Markdown Editor card and its toggle 74 + const markdownCard = screen.getByText('Markdown Editor').closest('article') 75 + expect(markdownCard).toBeTruthy() 76 + const toggle = within(markdownCard!).getByRole('switch') 77 + await user.click(toggle) 78 + 79 + // Should show dependency warning dialog 80 + await waitFor(() => { 81 + expect(screen.getByText('Dependency Warning')).toBeInTheDocument() 82 + }) 83 + expect(screen.getByText('Disable Anyway')).toBeInTheDocument() 84 + }) 85 + 86 + it('opens settings modal when settings button is clicked', async () => { 87 + const user = userEvent.setup() 88 + render(<AdminPluginsPage />) 89 + await waitFor(() => { 90 + expect(screen.getByText('Full-Text Search')).toBeInTheDocument() 91 + }) 92 + 93 + // Click settings button on Full-Text Search plugin 94 + const searchCard = screen.getByText('Full-Text Search').closest('article') 95 + expect(searchCard).toBeTruthy() 96 + const settingsBtn = within(searchCard!).getByRole('button', { name: /settings/i }) 97 + await user.click(settingsBtn) 98 + 99 + // Should show settings modal with schema fields 100 + await waitFor(() => { 101 + expect(screen.getByText('Enable semantic search')).toBeInTheDocument() 102 + }) 103 + }) 104 + 105 + it('shows plugin descriptions', async () => { 106 + render(<AdminPluginsPage />) 107 + await waitFor(() => { 108 + expect(screen.getByText(/full-text search for topics and replies/i)).toBeInTheDocument() 109 + }) 110 + }) 111 + 112 + it('passes axe accessibility check', async () => { 113 + const { container } = render(<AdminPluginsPage />) 114 + await waitFor(() => { 115 + expect(screen.getByText('Full-Text Search')).toBeInTheDocument() 116 + }) 117 + const results = await axe(container) 118 + expect(results).toHaveNoViolations() 119 + }) 120 + })
+428
src/app/admin/plugins/page.tsx
··· 1 + /** 2 + * Admin plugin management page. 3 + * URL: /admin/plugins 4 + * Lists installed plugins with enable/disable, settings, and uninstall controls. 5 + * Phase 1: manage installed plugins. Phase 2: marketplace search/install. 6 + * @see specs/prd-web.md Section M13 7 + */ 8 + 9 + 'use client' 10 + 11 + import { useState, useEffect, useCallback } from 'react' 12 + import { Gear, Trash, WarningCircle, X } from '@phosphor-icons/react' 13 + import { AdminLayout } from '@/components/admin/admin-layout' 14 + import { getPlugins, togglePlugin, updatePluginSettings, uninstallPlugin } from '@/lib/api/client' 15 + import { cn } from '@/lib/utils' 16 + import type { Plugin, PluginSettingsSchema } from '@/lib/api/types' 17 + 18 + // TODO: Replace with actual auth token from session 19 + const MOCK_TOKEN = 'mock-access-token' 20 + 21 + const SOURCE_STYLES: Record<string, string> = { 22 + core: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', 23 + official: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', 24 + community: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 25 + experimental: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', 26 + } 27 + 28 + const SOURCE_LABELS: Record<string, string> = { 29 + core: 'Core', 30 + official: 'Official', 31 + community: 'Community', 32 + experimental: 'Experimental', 33 + } 34 + 35 + function PluginSettingsModal({ 36 + plugin, 37 + onClose, 38 + onSave, 39 + }: { 40 + plugin: Plugin 41 + onClose: () => void 42 + onSave: (settings: Record<string, boolean | string | number>) => void 43 + }) { 44 + const [values, setValues] = useState<Record<string, boolean | string | number>>(() => ({ 45 + ...plugin.settings, 46 + })) 47 + 48 + const handleChange = (key: string, value: boolean | string | number) => { 49 + setValues((prev) => ({ ...prev, [key]: value })) 50 + } 51 + 52 + const handleSubmit = (e: React.FormEvent) => { 53 + e.preventDefault() 54 + onSave(values) 55 + } 56 + 57 + return ( 58 + <div 59 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 60 + role="dialog" 61 + aria-modal="true" 62 + aria-label={`${plugin.displayName} settings`} 63 + > 64 + <div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 65 + <div className="mb-4 flex items-center justify-between"> 66 + <h2 className="text-lg font-semibold text-foreground">{plugin.displayName} Settings</h2> 67 + <button 68 + type="button" 69 + onClick={onClose} 70 + className="rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground" 71 + aria-label="Close settings" 72 + > 73 + <X size={18} aria-hidden="true" /> 74 + </button> 75 + </div> 76 + 77 + <form onSubmit={handleSubmit} className="space-y-4"> 78 + {Object.entries(plugin.settingsSchema).map(([key, schema]) => ( 79 + <SettingsField 80 + key={key} 81 + fieldKey={key} 82 + schema={schema} 83 + value={values[key] ?? schema.default} 84 + onChange={(val) => handleChange(key, val)} 85 + /> 86 + ))} 87 + 88 + <div className="flex justify-end gap-2 pt-2"> 89 + <button 90 + type="button" 91 + onClick={onClose} 92 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 93 + > 94 + Cancel 95 + </button> 96 + <button 97 + type="submit" 98 + className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 99 + > 100 + Save 101 + </button> 102 + </div> 103 + </form> 104 + </div> 105 + </div> 106 + ) 107 + } 108 + 109 + function SettingsField({ 110 + fieldKey, 111 + schema, 112 + value, 113 + onChange, 114 + }: { 115 + fieldKey: string 116 + schema: PluginSettingsSchema[string] 117 + value: boolean | string | number 118 + onChange: (value: boolean | string | number) => void 119 + }) { 120 + if (schema.type === 'boolean') { 121 + return ( 122 + <label className="flex items-center justify-between gap-3"> 123 + <div> 124 + <span className="text-sm font-medium text-foreground">{schema.label}</span> 125 + {schema.description && ( 126 + <p className="text-xs text-muted-foreground">{schema.description}</p> 127 + )} 128 + </div> 129 + <input 130 + type="checkbox" 131 + checked={value as boolean} 132 + onChange={(e) => onChange(e.target.checked)} 133 + className="h-4 w-4 rounded border-border" 134 + /> 135 + </label> 136 + ) 137 + } 138 + 139 + if (schema.type === 'select') { 140 + return ( 141 + <label className="block"> 142 + <span className="text-sm font-medium text-foreground">{schema.label}</span> 143 + {schema.description && ( 144 + <p className="text-xs text-muted-foreground">{schema.description}</p> 145 + )} 146 + <select 147 + value={value as string} 148 + onChange={(e) => onChange(e.target.value)} 149 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground" 150 + > 151 + {schema.options?.map((opt) => ( 152 + <option key={opt} value={opt}> 153 + {opt || '(none)'} 154 + </option> 155 + ))} 156 + </select> 157 + </label> 158 + ) 159 + } 160 + 161 + if (schema.type === 'number') { 162 + return ( 163 + <label className="block"> 164 + <span className="text-sm font-medium text-foreground">{schema.label}</span> 165 + {schema.description && ( 166 + <p className="text-xs text-muted-foreground">{schema.description}</p> 167 + )} 168 + <input 169 + type="number" 170 + value={value as number} 171 + onChange={(e) => onChange(Number(e.target.value))} 172 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground" 173 + name={fieldKey} 174 + /> 175 + </label> 176 + ) 177 + } 178 + 179 + // string 180 + return ( 181 + <label className="block"> 182 + <span className="text-sm font-medium text-foreground">{schema.label}</span> 183 + {schema.description && <p className="text-xs text-muted-foreground">{schema.description}</p>} 184 + <input 185 + type="text" 186 + value={value as string} 187 + onChange={(e) => onChange(e.target.value)} 188 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground" 189 + name={fieldKey} 190 + /> 191 + </label> 192 + ) 193 + } 194 + 195 + export default function AdminPluginsPage() { 196 + const [plugins, setPlugins] = useState<Plugin[]>([]) 197 + const [loading, setLoading] = useState(true) 198 + const [settingsPlugin, setSettingsPlugin] = useState<Plugin | null>(null) 199 + const [dependencyWarning, setDependencyWarning] = useState<{ 200 + plugin: Plugin 201 + dependents: string[] 202 + } | null>(null) 203 + 204 + const fetchPlugins = useCallback(async () => { 205 + try { 206 + const response = await getPlugins(MOCK_TOKEN) 207 + setPlugins(response.plugins) 208 + } catch { 209 + // Silently handle 210 + } finally { 211 + setLoading(false) 212 + } 213 + }, []) 214 + 215 + useEffect(() => { 216 + void fetchPlugins() 217 + }, [fetchPlugins]) 218 + 219 + const findDependentNames = (plugin: Plugin): string[] => { 220 + return plugin.dependents.map((depId) => { 221 + const dep = plugins.find((p) => p.id === depId) 222 + return dep?.displayName ?? depId 223 + }) 224 + } 225 + 226 + const handleToggle = async (plugin: Plugin) => { 227 + if (plugin.enabled && plugin.dependents.length > 0) { 228 + const dependentNames = findDependentNames(plugin) 229 + setDependencyWarning({ plugin, dependents: dependentNames }) 230 + return 231 + } 232 + 233 + try { 234 + await togglePlugin(plugin.id, !plugin.enabled, MOCK_TOKEN) 235 + setPlugins((prev) => 236 + prev.map((p) => (p.id === plugin.id ? { ...p, enabled: !p.enabled } : p)) 237 + ) 238 + } catch { 239 + // Silently handle 240 + } 241 + } 242 + 243 + const confirmDisable = async () => { 244 + if (!dependencyWarning) return 245 + try { 246 + await togglePlugin(dependencyWarning.plugin.id, false, MOCK_TOKEN) 247 + setPlugins((prev) => 248 + prev.map((p) => (p.id === dependencyWarning.plugin.id ? { ...p, enabled: false } : p)) 249 + ) 250 + } catch { 251 + // Silently handle 252 + } 253 + setDependencyWarning(null) 254 + } 255 + 256 + const handleSaveSettings = async (settings: Record<string, boolean | string | number>) => { 257 + if (!settingsPlugin) return 258 + try { 259 + await updatePluginSettings(settingsPlugin.id, settings, MOCK_TOKEN) 260 + setPlugins((prev) => prev.map((p) => (p.id === settingsPlugin.id ? { ...p, settings } : p))) 261 + } catch { 262 + // Silently handle 263 + } 264 + setSettingsPlugin(null) 265 + } 266 + 267 + const handleUninstall = async (plugin: Plugin) => { 268 + try { 269 + await uninstallPlugin(plugin.id, MOCK_TOKEN) 270 + setPlugins((prev) => prev.filter((p) => p.id !== plugin.id)) 271 + } catch { 272 + // Silently handle 273 + } 274 + } 275 + 276 + return ( 277 + <AdminLayout> 278 + <div className="space-y-6"> 279 + <div className="flex items-center justify-between"> 280 + <h1 className="text-2xl font-bold text-foreground">Plugins</h1> 281 + <p className="text-sm text-muted-foreground"> 282 + {plugins.filter((p) => p.enabled).length} of {plugins.length} enabled 283 + </p> 284 + </div> 285 + 286 + {loading && <p className="text-sm text-muted-foreground">Loading plugins...</p>} 287 + 288 + {!loading && plugins.length === 0 && ( 289 + <p className="py-8 text-center text-muted-foreground">No plugins installed.</p> 290 + )} 291 + 292 + {!loading && plugins.length > 0 && ( 293 + <div className="space-y-3"> 294 + {plugins.map((plugin) => ( 295 + <article 296 + key={plugin.id} 297 + className={cn( 298 + 'rounded-lg border border-border bg-card p-4', 299 + !plugin.enabled && 'opacity-60' 300 + )} 301 + > 302 + <div className="flex items-start justify-between gap-4"> 303 + <div className="min-w-0 flex-1"> 304 + <div className="flex items-center gap-2"> 305 + <h2 className="text-sm font-semibold text-foreground"> 306 + {plugin.displayName} 307 + </h2> 308 + <span className="text-xs text-muted-foreground">v{plugin.version}</span> 309 + <span 310 + className={cn( 311 + 'rounded-full px-2 py-0.5 text-xs font-medium', 312 + SOURCE_STYLES[plugin.source] ?? SOURCE_STYLES.community 313 + )} 314 + > 315 + {SOURCE_LABELS[plugin.source] ?? plugin.source} 316 + </span> 317 + </div> 318 + <p className="mt-1 text-xs text-muted-foreground">{plugin.description}</p> 319 + {plugin.dependencies.length > 0 && ( 320 + <p className="mt-1 text-xs text-muted-foreground"> 321 + Depends on:{' '} 322 + {plugin.dependencies 323 + .map((depId) => { 324 + const dep = plugins.find((p) => p.id === depId) 325 + return dep?.displayName ?? depId 326 + }) 327 + .join(', ')} 328 + </p> 329 + )} 330 + </div> 331 + 332 + <div className="flex shrink-0 items-center gap-2"> 333 + {Object.keys(plugin.settingsSchema).length > 0 && ( 334 + <button 335 + type="button" 336 + onClick={() => setSettingsPlugin(plugin)} 337 + className="rounded-md border border-border p-1.5 text-muted-foreground transition-colors hover:text-foreground" 338 + aria-label={`${plugin.displayName} settings`} 339 + > 340 + <Gear size={16} aria-hidden="true" /> 341 + </button> 342 + )} 343 + {plugin.source !== 'core' && ( 344 + <button 345 + type="button" 346 + onClick={() => void handleUninstall(plugin)} 347 + className="rounded-md border border-border p-1.5 text-muted-foreground transition-colors hover:text-destructive" 348 + aria-label={`Uninstall ${plugin.displayName}`} 349 + > 350 + <Trash size={16} aria-hidden="true" /> 351 + </button> 352 + )} 353 + <button 354 + type="button" 355 + role="switch" 356 + aria-checked={plugin.enabled} 357 + aria-label={`${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.displayName}`} 358 + onClick={() => void handleToggle(plugin)} 359 + className={cn( 360 + 'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors', 361 + plugin.enabled ? 'bg-primary' : 'bg-muted' 362 + )} 363 + > 364 + <span 365 + aria-hidden="true" 366 + className={cn( 367 + 'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition-transform', 368 + plugin.enabled ? 'translate-x-5' : 'translate-x-0' 369 + )} 370 + /> 371 + </button> 372 + </div> 373 + </div> 374 + </article> 375 + ))} 376 + </div> 377 + )} 378 + 379 + {/* Dependency Warning Dialog */} 380 + {dependencyWarning && ( 381 + <div 382 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 383 + role="alertdialog" 384 + aria-modal="true" 385 + aria-label="Dependency warning" 386 + > 387 + <div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg"> 388 + <div className="mb-3 flex items-center gap-2 text-destructive"> 389 + <WarningCircle size={20} aria-hidden="true" /> 390 + <h2 className="font-semibold">Dependency Warning</h2> 391 + </div> 392 + <p className="text-sm text-muted-foreground"> 393 + Disabling <strong>{dependencyWarning.plugin.displayName}</strong> will affect the 394 + following plugins that depend on it:{' '} 395 + <strong>{dependencyWarning.dependents.join(', ')}</strong> 396 + </p> 397 + <div className="mt-4 flex justify-end gap-2"> 398 + <button 399 + type="button" 400 + onClick={() => setDependencyWarning(null)} 401 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 402 + > 403 + Cancel 404 + </button> 405 + <button 406 + type="button" 407 + onClick={() => void confirmDisable()} 408 + className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 409 + > 410 + Disable Anyway 411 + </button> 412 + </div> 413 + </div> 414 + </div> 415 + )} 416 + 417 + {/* Settings Modal */} 418 + {settingsPlugin && ( 419 + <PluginSettingsModal 420 + plugin={settingsPlugin} 421 + onClose={() => setSettingsPlugin(null)} 422 + onSave={(settings) => void handleSaveSettings(settings)} 423 + /> 424 + )} 425 + </div> 426 + </AdminLayout> 427 + ) 428 + }
+1
src/components/admin/admin-layout.test.tsx
··· 62 62 expect(screen.getByRole('link', { name: /settings/i })).toBeInTheDocument() 63 63 expect(screen.getByRole('link', { name: /content ratings/i })).toBeInTheDocument() 64 64 expect(screen.getByRole('link', { name: /users/i })).toBeInTheDocument() 65 + expect(screen.getByRole('link', { name: /plugins/i })).toBeInTheDocument() 65 66 }) 66 67 67 68 it('highlights current page link', () => {
+2
src/components/admin/admin-layout.tsx
··· 15 15 Gear, 16 16 Tag, 17 17 Users, 18 + PuzzlePiece, 18 19 ArrowLeft, 19 20 } from '@phosphor-icons/react' 20 21 import { cn } from '@/lib/utils' ··· 30 31 { href: '/admin/settings', label: 'Settings', icon: Gear }, 31 32 { href: '/admin/content-ratings', label: 'Content Ratings', icon: Tag }, 32 33 { href: '/admin/users', label: 'Users', icon: Users }, 34 + { href: '/admin/plugins', label: 'Plugins', icon: PuzzlePiece }, 33 35 ] 34 36 35 37 export function AdminLayout({ children }: AdminLayoutProps) {
+52
src/lib/api/client.ts
··· 25 25 ReportedUsersResponse, 26 26 AdminUsersResponse, 27 27 MaturityRating, 28 + PluginsResponse, 28 29 } from './types' 29 30 30 31 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' ··· 440 441 method: 'POST', 441 442 headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 442 443 body: { did, action: 'unban' }, 444 + }) 445 + } 446 + 447 + // --- Plugin endpoints --- 448 + 449 + export function getPlugins(accessToken: string, options?: FetchOptions): Promise<PluginsResponse> { 450 + return apiFetch<PluginsResponse>('/api/plugins', { 451 + ...options, 452 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 453 + }) 454 + } 455 + 456 + export function togglePlugin( 457 + id: string, 458 + enabled: boolean, 459 + accessToken: string, 460 + options?: FetchOptions 461 + ): Promise<void> { 462 + return apiFetch<void>( 463 + `/api/plugins/${encodeURIComponent(id)}/${enabled ? 'enable' : 'disable'}`, 464 + { 465 + ...options, 466 + method: 'PUT', 467 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 468 + } 469 + ) 470 + } 471 + 472 + export function updatePluginSettings( 473 + id: string, 474 + settings: Record<string, boolean | string | number>, 475 + accessToken: string, 476 + options?: FetchOptions 477 + ): Promise<void> { 478 + return apiFetch<void>(`/api/plugins/${encodeURIComponent(id)}/settings`, { 479 + ...options, 480 + method: 'PUT', 481 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 482 + body: settings, 483 + }) 484 + } 485 + 486 + export function uninstallPlugin( 487 + id: string, 488 + accessToken: string, 489 + options?: FetchOptions 490 + ): Promise<void> { 491 + return apiFetch<void>(`/api/plugins/${encodeURIComponent(id)}`, { 492 + ...options, 493 + method: 'DELETE', 494 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 443 495 }) 444 496 } 445 497
+34
src/lib/api/types.ts
··· 347 347 total: number 348 348 } 349 349 350 + // --- Plugins --- 351 + 352 + export type PluginSource = 'core' | 'official' | 'community' | 'experimental' 353 + 354 + export interface PluginSettingsSchema { 355 + [key: string]: { 356 + type: 'boolean' | 'string' | 'number' | 'select' 357 + label: string 358 + description?: string 359 + default: boolean | string | number 360 + options?: string[] 361 + } 362 + } 363 + 364 + export interface Plugin { 365 + id: string 366 + name: string 367 + displayName: string 368 + version: string 369 + description: string 370 + source: PluginSource 371 + enabled: boolean 372 + category: string 373 + dependencies: string[] 374 + dependents: string[] 375 + settingsSchema: PluginSettingsSchema 376 + settings: Record<string, boolean | string | number> 377 + installedAt: string 378 + } 379 + 380 + export interface PluginsResponse { 381 + plugins: Plugin[] 382 + } 383 + 350 384 // --- Shared --- 351 385 352 386 export type MaturityRating = 'safe' | 'mature' | 'adult'
+123
src/mocks/data.ts
··· 18 18 AdminUser, 19 19 CommunitySettings, 20 20 CommunityStats, 21 + Plugin, 21 22 } from '@/lib/api/types' 22 23 23 24 const COMMUNITY_DID = 'did:plc:test-community-123' 24 25 const NOW = '2026-02-14T12:00:00.000Z' 25 26 const YESTERDAY = '2026-02-13T12:00:00.000Z' 26 27 const TWO_DAYS_AGO = '2026-02-12T12:00:00.000Z' 28 + const LAST_WEEK = '2026-02-07T12:00:00.000Z' 27 29 28 30 // --- Users --- 29 31 ··· 700 702 lastActiveAt: YESTERDAY, 701 703 }, 702 704 ] 705 + 706 + // --- Plugins --- 707 + 708 + export const mockPlugins: Plugin[] = [ 709 + { 710 + id: 'barazo-plugin-search', 711 + name: 'barazo-plugin-search', 712 + displayName: 'Full-Text Search', 713 + version: '1.0.0', 714 + description: 'Full-text search for topics and replies with optional semantic search.', 715 + source: 'core', 716 + enabled: true, 717 + category: 'search', 718 + dependencies: [], 719 + dependents: [], 720 + settingsSchema: { 721 + enableSemanticSearch: { 722 + type: 'boolean', 723 + label: 'Enable semantic search', 724 + description: 'Use embeddings for semantic search alongside full-text.', 725 + default: false, 726 + }, 727 + embeddingProvider: { 728 + type: 'select', 729 + label: 'Embedding provider', 730 + description: 'Provider for generating embeddings.', 731 + default: '', 732 + options: ['', 'openai', 'ollama', 'openrouter'], 733 + }, 734 + }, 735 + settings: { enableSemanticSearch: false, embeddingProvider: '' }, 736 + installedAt: LAST_WEEK, 737 + }, 738 + { 739 + id: 'barazo-plugin-markdown', 740 + name: 'barazo-plugin-markdown', 741 + displayName: 'Markdown Editor', 742 + version: '1.2.0', 743 + description: 'Rich text editor with Markdown support, code highlighting, and preview.', 744 + source: 'core', 745 + enabled: true, 746 + category: 'editor', 747 + dependencies: [], 748 + dependents: ['barazo-plugin-code-highlight'], 749 + settingsSchema: { 750 + enablePreview: { 751 + type: 'boolean', 752 + label: 'Enable live preview', 753 + default: true, 754 + }, 755 + }, 756 + settings: { enablePreview: true }, 757 + installedAt: LAST_WEEK, 758 + }, 759 + { 760 + id: 'barazo-plugin-code-highlight', 761 + name: 'barazo-plugin-code-highlight', 762 + displayName: 'Code Highlighting', 763 + version: '0.9.1', 764 + description: 'Syntax highlighting for code blocks using Shiki.', 765 + source: 'official', 766 + enabled: true, 767 + category: 'editor', 768 + dependencies: ['barazo-plugin-markdown'], 769 + dependents: [], 770 + settingsSchema: { 771 + theme: { 772 + type: 'select', 773 + label: 'Code theme', 774 + default: 'flexoki', 775 + options: ['flexoki', 'github-dark', 'github-light', 'one-dark-pro'], 776 + }, 777 + }, 778 + settings: { theme: 'flexoki' }, 779 + installedAt: LAST_WEEK, 780 + }, 781 + { 782 + id: 'barazo-plugin-analytics', 783 + name: 'barazo-plugin-analytics', 784 + displayName: 'Community Analytics', 785 + version: '0.5.0', 786 + description: 'Topic trends, user engagement metrics, and growth analytics.', 787 + source: 'community', 788 + enabled: false, 789 + category: 'analytics', 790 + dependencies: [], 791 + dependents: [], 792 + settingsSchema: { 793 + trackingInterval: { 794 + type: 'number', 795 + label: 'Tracking interval (minutes)', 796 + description: 'How often to aggregate analytics data.', 797 + default: 60, 798 + }, 799 + }, 800 + settings: { trackingInterval: 60 }, 801 + installedAt: YESTERDAY, 802 + }, 803 + { 804 + id: 'barazo-plugin-webhooks', 805 + name: 'barazo-plugin-webhooks', 806 + displayName: 'Webhook Notifications', 807 + version: '0.2.0-beta', 808 + description: 'Send webhook notifications on new topics, replies, and moderation actions.', 809 + source: 'experimental', 810 + enabled: false, 811 + category: 'integrations', 812 + dependencies: [], 813 + dependents: [], 814 + settingsSchema: { 815 + webhookUrl: { 816 + type: 'string', 817 + label: 'Webhook URL', 818 + description: 'URL to send webhook payloads to.', 819 + default: '', 820 + }, 821 + }, 822 + settings: { webhookUrl: '' }, 823 + installedAt: YESTERDAY, 824 + }, 825 + ]
+45
src/mocks/handlers.ts
··· 20 20 mockModerationThresholds, 21 21 mockReportedUsers, 22 22 mockAdminUsers, 23 + mockPlugins, 23 24 } from './data' 24 25 25 26 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' ··· 388 389 389 390 // POST /api/moderation/ban 390 391 http.post(`${API_URL}/api/moderation/ban`, async ({ request }) => { 392 + const auth = request.headers.get('Authorization') 393 + if (!auth?.startsWith('Bearer ')) { 394 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 395 + } 396 + return new HttpResponse(null, { status: 204 }) 397 + }), 398 + 399 + // GET /api/plugins 400 + http.get(`${API_URL}/api/plugins`, ({ request }) => { 401 + const auth = request.headers.get('Authorization') 402 + if (!auth?.startsWith('Bearer ')) { 403 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 404 + } 405 + return HttpResponse.json({ plugins: mockPlugins }) 406 + }), 407 + 408 + // PUT /api/plugins/:id/enable or /disable 409 + http.put(`${API_URL}/api/plugins/:id/enable`, ({ request }) => { 410 + const auth = request.headers.get('Authorization') 411 + if (!auth?.startsWith('Bearer ')) { 412 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 413 + } 414 + return new HttpResponse(null, { status: 204 }) 415 + }), 416 + 417 + http.put(`${API_URL}/api/plugins/:id/disable`, ({ request }) => { 418 + const auth = request.headers.get('Authorization') 419 + if (!auth?.startsWith('Bearer ')) { 420 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 421 + } 422 + return new HttpResponse(null, { status: 204 }) 423 + }), 424 + 425 + // PUT /api/plugins/:id/settings 426 + http.put(`${API_URL}/api/plugins/:id/settings`, ({ request }) => { 427 + const auth = request.headers.get('Authorization') 428 + if (!auth?.startsWith('Bearer ')) { 429 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 430 + } 431 + return new HttpResponse(null, { status: 204 }) 432 + }), 433 + 434 + // DELETE /api/plugins/:id 435 + http.delete(`${API_URL}/api/plugins/:id`, ({ request }) => { 391 436 const auth = request.headers.get('Authorization') 392 437 if (!auth?.startsWith('Bearer ')) { 393 438 return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 })