diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bccd203..470c767 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,6 @@ importers: .: dependencies: - '@barazo-forum/lexicons': - specifier: link:../barazo-lexicons - version: link:../barazo-lexicons '@dnd-kit/core': specifier: 6.3.1 version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -137,6 +134,9 @@ importers: '@radix-ui/react-tooltip': specifier: 1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@singi-labs/lexicons': + specifier: link:../barazo-lexicons + version: link:../barazo-lexicons '@tailwindcss/typography': specifier: 0.5.19 version: 0.5.19(tailwindcss@4.2.1) diff --git a/src/app/[handle]/[rkey]/page.tsx b/src/app/[handle]/[rkey]/page.tsx index 2d80ea5..74c0bcc 100644 --- a/src/app/[handle]/[rkey]/page.tsx +++ b/src/app/[handle]/[rkey]/page.tsx @@ -27,6 +27,7 @@ import { CategoryNav } from '@/components/category-nav' import { Breadcrumbs } from '@/components/breadcrumbs' import { TopicView } from '@/components/topic-view' import { TopicDetailClient } from '@/components/topic-detail-client' +import { PluginSlot } from '@/components/plugin-slot' import type { CategoriesResponse, RepliesResponse } from '@/lib/api/types' export const dynamic = 'force-dynamic' @@ -198,9 +199,15 @@ export default async function TopicPage({ params }: TopicPageProps) { 0 ? ( - - ) : undefined + <> + {categoriesResult.categories.length > 0 && ( + + )} + + } > {/* JSON-LD: omitted for Adult content */} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b641a23..a094298 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -14,6 +14,7 @@ import { ErrorAlert } from '@/components/error-alert' import { getCommunityStats } from '@/lib/api/client' import type { CommunityStats } from '@/lib/api/types' import { useAuth } from '@/hooks/use-auth' +import { PluginSlot } from '@/components/plugin-slot' interface StatCardProps { label: string @@ -101,6 +102,8 @@ export default function AdminDashboardPage() { /> )} + + ) diff --git a/src/app/admin/plugins/page.test.tsx b/src/app/admin/plugins/page.test.tsx index ad6c405..4614fed 100644 --- a/src/app/admin/plugins/page.test.tsx +++ b/src/app/admin/plugins/page.test.tsx @@ -1,12 +1,14 @@ /** - * Tests for admin plugins page (P3 placeholder). + * Tests for admin plugins page. * @see specs/prd-web.md Section M13 */ import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import { axe } from 'vitest-axe' import AdminPluginsPage from './page' +import { usePluginManagement } from '@/hooks/admin/use-plugin-management' +import type { Plugin } from '@/lib/api/types' vi.mock('next/navigation', () => ({ usePathname: () => '/admin/plugins', @@ -31,21 +33,82 @@ vi.mock('@/hooks/use-auth', () => { return { useAuth: () => mockAuth } }) +vi.mock('@/hooks/admin/use-plugin-management') + +const mockPlugin: Plugin = { + id: '1', + name: '@barazo/plugin-signatures', + displayName: 'User Signatures', + version: '1.0.0', + description: 'Portable user signatures', + source: 'core', + enabled: true, + category: 'social', + dependencies: [], + dependents: [], + settingsSchema: {}, + settings: {}, + installedAt: '2026-01-01T00:00:00Z', +} + +function mockPluginManagement(overrides: Partial> = {}) { + vi.mocked(usePluginManagement).mockReturnValue({ + plugins: [], + loading: false, + settingsPlugin: null, + setSettingsPlugin: vi.fn(), + dependencyWarning: null, + setDependencyWarning: vi.fn(), + loadError: null, + actionError: null, + setActionError: vi.fn(), + fetchPlugins: vi.fn(), + handleToggle: vi.fn(), + confirmDisable: vi.fn(), + handleSaveSettings: vi.fn(), + handleUninstall: vi.fn(), + settingsSaveStatus: 'idle', + ...overrides, + }) +} + describe('AdminPluginsPage', () => { it('renders page heading', () => { + mockPluginManagement() render() expect(screen.getByRole('heading', { name: /plugins/i, level: 1 })).toBeInTheDocument() }) - it('shows coming in P3 message', () => { + it('shows empty state when no plugins installed', () => { + mockPluginManagement({ plugins: [], loading: false }) + render() + expect(screen.getByText(/no plugins installed/i)).toBeInTheDocument() + }) + + it('shows loading skeletons', () => { + mockPluginManagement({ loading: true }) + render() + expect(screen.getByLabelText(/loading plugins/i)).toBeInTheDocument() + }) + + it('shows plugin list when plugins exist', () => { + mockPluginManagement({ plugins: [mockPlugin] }) + render() + expect(screen.getByText('User Signatures')).toBeInTheDocument() + }) + + it('shows load error', () => { + mockPluginManagement({ loadError: 'Failed to load plugins.' }) render() - expect(screen.getByRole('heading', { name: /coming in p3/i })).toBeInTheDocument() - expect(screen.getByText(/planned for the p3\.2 milestone/i)).toBeInTheDocument() + expect(screen.getByText(/failed to load plugins/i)).toBeInTheDocument() }) - it('passes axe accessibility check', async () => { + it('passes axe accessibility check with empty state', async () => { + mockPluginManagement() const { container } = render() - const results = await axe(container) - expect(results).toHaveNoViolations() + await waitFor(async () => { + const results = await axe(container) + expect(results).toHaveNoViolations() + }) }) }) diff --git a/src/app/admin/plugins/page.tsx b/src/app/admin/plugins/page.tsx index 63a7e21..1e46b9f 100644 --- a/src/app/admin/plugins/page.tsx +++ b/src/app/admin/plugins/page.tsx @@ -2,28 +2,104 @@ * Admin plugin management page. * URL: /admin/plugins * Lists installed plugins with enable/disable, settings, and uninstall controls. - * Backend endpoints not yet implemented (planned for P3.2). * @see specs/prd-web.md Section M13 */ -import { PuzzlePiece } from '@phosphor-icons/react/dist/ssr' +'use client' + +import { PuzzlePiece } from '@phosphor-icons/react' import { AdminLayout } from '@/components/admin/admin-layout' +import { ErrorAlert } from '@/components/error-alert' +import { PluginCard } from '@/components/admin/plugins/plugin-card' +import { PluginSettingsModal } from '@/components/admin/plugins/plugin-settings-modal' +import { DependencyWarningDialog } from '@/components/admin/plugins/dependency-warning-dialog' +import { usePluginManagement } from '@/hooks/admin/use-plugin-management' export default function AdminPluginsPage() { + const { + plugins, + loading, + settingsPlugin, + setSettingsPlugin, + dependencyWarning, + setDependencyWarning, + loadError, + actionError, + setActionError, + fetchPlugins, + handleToggle, + confirmDisable, + handleSaveSettings, + handleUninstall, + settingsSaveStatus, + } = usePluginManagement() + return (

Plugins

-
- -

Coming in P3

-

- Plugin management (install, enable/disable, configure) is planned for the P3.2 - milestone. The plugin API endpoints are not yet available. -

-
+ {loadError && ( + void fetchPlugins()} /> + )} + + {actionError && setActionError(null)} />} + + {loading && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {!loading && !loadError && plugins.length === 0 && ( +
+
+ )} + + {!loading && plugins.length > 0 && ( +
+ {plugins.map((plugin) => ( + setSettingsPlugin(p)} + onToggle={(p) => void handleToggle(p)} + onUninstall={(p) => void handleUninstall(p)} + /> + ))} +
+ )}
+ + {settingsPlugin && ( + setSettingsPlugin(null)} + onSave={(settings) => void handleSaveSettings(settings)} + saveStatus={settingsSaveStatus} + /> + )} + + {dependencyWarning && ( + void confirmDisable()} + onCancel={() => setDependencyWarning(null)} + /> + )} ) } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7ab2aa4..94507d7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { ThemeProvider } from '@/components/theme-provider' import { AuthProvider } from '@/context/auth-context' import { AppToastProvider } from '@/context/toast-context' import { OnboardingProvider } from '@/context/onboarding-context' +import { PluginProvider } from '@/context/plugin-context' import { SetupGuard } from '@/components/setup-guard' import { DynamicFavicon } from '@/components/dynamic-favicon' @@ -61,7 +62,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/src/app/profile/[handle]/page.tsx b/src/app/profile/[handle]/page.tsx index cbd0e4e..5f3520e 100644 --- a/src/app/profile/[handle]/page.tsx +++ b/src/app/profile/[handle]/page.tsx @@ -17,6 +17,7 @@ import { getUserProfile, getPublicSettings } from '@/lib/api/client' import { useAuth } from '@/hooks/use-auth' import { formatDateLong } from '@/lib/format' import type { PublicSettings, UserProfile } from '@/lib/api/types' +import { PluginSlot } from '@/components/plugin-slot' interface UserProfilePageProps { params: Promise<{ handle: string }> | { handle: string } @@ -144,6 +145,8 @@ export default function UserProfilePage({ params }: UserProfilePageProps) { viewerDid={user?.did ?? null} /> + + {/* Recent activity */}

Recent activity

diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 8ba1ba3..31866c4 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -25,6 +25,7 @@ import { SettingsSidebar } from '@/components/settings/settings-sidebar' import { AGE_OPTIONS } from '@/lib/constants' import { cn } from '@/lib/utils' import { useSettingsForm } from '@/hooks/use-settings-form' +import { PluginSlot } from '@/components/plugin-slot' export default function SettingsPage() { const [publicSettings, setPublicSettings] = useState(null) @@ -172,6 +173,11 @@ export default function SettingsPage() { onReactionsChange={(v) => setValues({ ...values, notifyReactions: v })} /> + + {/* Reports link -- scoped to this community's AppView */}
+ +
} />) + expect(screen.getByText('Fallback content')).toBeInTheDocument() + }) + + it('renders fallback when plugins are registered but none are enabled', () => { + vi.mocked(getPluginComponents).mockReturnValue([ + { + pluginName: 'disabled-plugin', + component: TestPluginComponent, + }, + ] as PluginRegistration[]) + + vi.mocked(usePlugins).mockReturnValue( + createMockPluginContext([ + { + id: '1', + name: 'disabled-plugin', + displayName: 'Disabled Plugin', + version: '1.0.0', + description: 'A disabled plugin', + source: 'core', + enabled: false, + category: 'content', + dependencies: [], + dependents: [], + settingsSchema: {}, + settings: {}, + installedAt: '2026-01-01T00:00:00Z', + }, + ]) + ) + + render(Fallback content
} />) + expect(screen.getByText('Fallback content')).toBeInTheDocument() + expect(screen.queryByTestId('test-plugin')).not.toBeInTheDocument() + }) + + it('renders plugin component when registered and plugin is enabled', () => { + vi.mocked(getPluginComponents).mockReturnValue([ + { + pluginName: 'test-plugin', + component: TestPluginComponent, + }, + ] as PluginRegistration[]) + + vi.mocked(usePlugins).mockReturnValue( + createMockPluginContext([ + { + id: '1', + name: 'test-plugin', + displayName: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin', + source: 'core', + enabled: true, + category: 'content', + dependencies: [], + dependents: [], + settingsSchema: {}, + settings: {}, + installedAt: '2026-01-01T00:00:00Z', + }, + ]) + ) + + render() + expect(screen.getByTestId('test-plugin')).toBeInTheDocument() + }) + + it('does not render plugin component when plugin is disabled', () => { + vi.mocked(getPluginComponents).mockReturnValue([ + { + pluginName: 'test-plugin', + component: TestPluginComponent, + }, + ] as PluginRegistration[]) + + vi.mocked(usePlugins).mockReturnValue( + createMockPluginContext([ + { + id: '1', + name: 'test-plugin', + displayName: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin', + source: 'core', + enabled: false, + category: 'content', + dependencies: [], + dependents: [], + settingsSchema: {}, + settings: {}, + installedAt: '2026-01-01T00:00:00Z', + }, + ]) + ) + + render() + expect(screen.queryByTestId('test-plugin')).not.toBeInTheDocument() + }) + + it('error boundary catches crashing plugin component and shows error message', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + vi.mocked(getPluginComponents).mockReturnValue([ + { + pluginName: 'crashing-plugin', + component: CrashingPluginComponent, + }, + ] as PluginRegistration[]) + + vi.mocked(usePlugins).mockReturnValue( + createMockPluginContext([ + { + id: '1', + name: 'crashing-plugin', + displayName: 'Crashing Plugin', + version: '1.0.0', + description: 'A crashing plugin', + source: 'core', + enabled: true, + category: 'content', + dependencies: [], + dependents: [], + settingsSchema: {}, + settings: {}, + installedAt: '2026-01-01T00:00:00Z', + }, + ]) + ) + + render() + expect(screen.getByText(/crashing-plugin/)).toBeInTheDocument() + expect(screen.getByText(/encountered an error/)).toBeInTheDocument() + + consoleSpy.mockRestore() + }) + + it('passes context props to plugin components', () => { + vi.mocked(getPluginComponents).mockReturnValue([ + { + pluginName: 'test-plugin', + component: TestPluginComponent, + }, + ] as PluginRegistration[]) + + vi.mocked(usePlugins).mockReturnValue( + createMockPluginContext([ + { + id: '1', + name: 'test-plugin', + displayName: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin', + source: 'core', + enabled: true, + category: 'content', + dependencies: [], + dependents: [], + settingsSchema: {}, + settings: {}, + installedAt: '2026-01-01T00:00:00Z', + }, + ]) + ) + + render() + expect(screen.getByText('Plugin content: did:plc:abc123')).toBeInTheDocument() + }) + + it('passes axe accessibility check', async () => { + vi.mocked(getPluginComponents).mockReturnValue([ + { + pluginName: 'test-plugin', + component: TestPluginComponent, + }, + ] as PluginRegistration[]) + + vi.mocked(usePlugins).mockReturnValue( + createMockPluginContext([ + { + id: '1', + name: 'test-plugin', + displayName: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin', + source: 'core', + enabled: true, + category: 'content', + dependencies: [], + dependents: [], + settingsSchema: {}, + settings: {}, + installedAt: '2026-01-01T00:00:00Z', + }, + ]) + ) + + const { container } = render( + + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + + it('passes axe accessibility check with error boundary fallback', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + vi.mocked(getPluginComponents).mockReturnValue([ + { + pluginName: 'crashing-plugin', + component: CrashingPluginComponent, + }, + ] as PluginRegistration[]) + + vi.mocked(usePlugins).mockReturnValue( + createMockPluginContext([ + { + id: '1', + name: 'crashing-plugin', + displayName: 'Crashing Plugin', + version: '1.0.0', + description: 'A crashing plugin', + source: 'core', + enabled: true, + category: 'content', + dependencies: [], + dependents: [], + settingsSchema: {}, + settings: {}, + installedAt: '2026-01-01T00:00:00Z', + }, + ]) + ) + + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + + vi.restoreAllMocks() + }) +}) diff --git a/src/components/plugin-slot.tsx b/src/components/plugin-slot.tsx new file mode 100644 index 0000000..3f9832b --- /dev/null +++ b/src/components/plugin-slot.tsx @@ -0,0 +1,71 @@ +'use client' + +import { Component, type ReactNode, type ComponentType } from 'react' +import { usePlugins } from '@/hooks/use-plugins' +import { getPluginComponents, type SlotName } from '@/lib/plugins/registry' + +interface PluginSlotProps { + name: SlotName + context?: Record + fallback?: ReactNode +} + +// Error boundary for individual plugin components +interface ErrorBoundaryProps { + pluginName: string + children: ReactNode +} + +interface ErrorBoundaryState { + hasError: boolean +} + +class PluginErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(): ErrorBoundaryState { + return { hasError: true } + } + + render() { + if (this.state.hasError) { + return ( +
+ Plugin “{this.props.pluginName}” encountered an error. +
+ ) + } + return this.props.children + } +} + +export function PluginSlot({ name, context = {}, fallback }: PluginSlotProps) { + const { plugins } = usePlugins() + + const registrations = getPluginComponents(name) + + // Filter to only enabled plugins + const activeRegistrations = registrations.filter((reg) => + plugins.some((p) => p.name === reg.pluginName && p.enabled) + ) + + if (activeRegistrations.length === 0) { + return fallback ?? null + } + + return ( + <> + {activeRegistrations.map((reg) => { + const PluginComponent = reg.component as ComponentType> + return ( + + + + ) + })} + + ) +} diff --git a/src/components/reply-card.tsx b/src/components/reply-card.tsx index de025a4..7d7a033 100644 --- a/src/components/reply-card.tsx +++ b/src/components/reply-card.tsx @@ -24,6 +24,7 @@ import { ReactionBar } from './reaction-bar' import { ReportDialog, type ReportSubmission } from './report-dialog' import { ConfirmDialog } from './confirm-dialog' import { SelfLabelIndicator } from './self-label-indicator' +import { PluginSlot } from '@/components/plugin-slot' interface ReactionData { type: string @@ -240,6 +241,18 @@ export function ReplyCard({ )}
+ {/* Plugin slot for post content extensions (e.g., signatures) */} + {/* TODO: Compute isFirstByAuthor properly when signatures plugin is built */} + + {/* Footer */}
{reactions && onReactionToggle && ( diff --git a/src/context/plugin-context.test.tsx b/src/context/plugin-context.test.tsx new file mode 100644 index 0000000..ad7161b --- /dev/null +++ b/src/context/plugin-context.test.tsx @@ -0,0 +1,359 @@ +/** + * Tests for PluginProvider context. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, waitFor, act } from '@testing-library/react' +import { PluginProvider, PluginContext } from './plugin-context' +import type { PluginContextValue } from './plugin-context' +import { getPlugins } from '@/lib/api/client' +import { useAuth } from '@/hooks/use-auth' +import { useContext, useEffect, useRef } from 'react' +import type { Plugin } from '@/lib/api/types' + +vi.mock('@/lib/api/client', () => ({ + getPlugins: vi.fn(), +})) + +vi.mock('@/hooks/use-auth', () => ({ + useAuth: vi.fn(), +})) + +// Mutable container to capture context without TS control-flow narrowing +interface ContextRef { + current: PluginContextValue | null +} + +function createContextRef(): ContextRef { + return { current: null } +} + +// Test consumer component that renders plugin context values +function PluginConsumer({ ctxRef }: { ctxRef: ContextRef }) { + const context = useContext(PluginContext) + const ref = useRef(ctxRef) + useEffect(() => { + ref.current.current = context + }) + return ( +
+ {String(context?.isLoading)} + {context?.plugins.length ?? 0} +
+ ) +} + +const mockPlugins: Plugin[] = [ + { + id: '1', + name: 'analytics', + displayName: 'Analytics', + version: '1.0.0', + description: 'Analytics plugin', + source: 'core', + enabled: true, + category: 'analytics', + dependencies: [], + dependents: [], + settingsSchema: {}, + settings: { trackPageViews: true, sampleRate: 0.5 }, + installedAt: '2026-01-01T00:00:00Z', + }, + { + id: '2', + name: 'spam-filter', + displayName: 'Spam Filter', + version: '2.1.0', + description: 'Spam filtering plugin', + source: 'official', + enabled: false, + category: 'moderation', + dependencies: [], + dependents: [], + settingsSchema: {}, + settings: { threshold: 0.8 }, + installedAt: '2026-01-15T00:00:00Z', + }, +] + +function mockAuthUnauthenticated() { + vi.mocked(useAuth).mockReturnValue({ + user: null, + isAuthenticated: false, + isLoading: false, + crossPostScopesGranted: false, + getAccessToken: () => null, + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + requestCrossPostAuth: vi.fn(), + authFetch: vi.fn(), + } as ReturnType) +} + +function mockAuthAuthenticated() { + vi.mocked(useAuth).mockReturnValue({ + user: { + did: 'did:plc:test', + handle: 'test.bsky.social', + displayName: 'Test User', + avatarUrl: null, + role: 'user', + }, + isAuthenticated: true, + isLoading: false, + crossPostScopesGranted: false, + getAccessToken: () => 'mock-token', + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + requestCrossPostAuth: vi.fn(), + authFetch: vi.fn(), + } as ReturnType) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('PluginProvider', () => { + it('provides empty plugins array when not authenticated', async () => { + mockAuthUnauthenticated() + + const ctxRef = createContextRef() + render( + + + + ) + + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + + expect(ctxRef.current?.plugins).toEqual([]) + expect(getPlugins).not.toHaveBeenCalled() + }) + + it('fetches plugins when authenticated', async () => { + mockAuthAuthenticated() + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) + + const ctxRef = createContextRef() + render( + + + + ) + + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + + expect(getPlugins).toHaveBeenCalledWith('mock-token') + expect(ctxRef.current?.plugins).toHaveLength(2) + expect(ctxRef.current?.plugins[0]!.name).toBe('analytics') + expect(ctxRef.current?.plugins[1]!.name).toBe('spam-filter') + }) + + it('isPluginEnabled returns true for enabled plugin', async () => { + mockAuthAuthenticated() + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) + + const ctxRef = createContextRef() + render( + + + + ) + + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + + expect(ctxRef.current?.isPluginEnabled('analytics')).toBe(true) + }) + + it('isPluginEnabled returns false for disabled plugin', async () => { + mockAuthAuthenticated() + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) + + const ctxRef = createContextRef() + render( + + + + ) + + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + + expect(ctxRef.current?.isPluginEnabled('spam-filter')).toBe(false) + }) + + it('isPluginEnabled returns false for unknown plugin', async () => { + mockAuthAuthenticated() + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) + + const ctxRef = createContextRef() + render( + + + + ) + + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + + expect(ctxRef.current?.isPluginEnabled('nonexistent')).toBe(false) + }) + + it('getPluginSettings returns settings for installed plugin', async () => { + mockAuthAuthenticated() + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) + + const ctxRef = createContextRef() + render( + + + + ) + + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + + const settings = ctxRef.current?.getPluginSettings('analytics') + expect(settings).toEqual({ trackPageViews: true, sampleRate: 0.5 }) + }) + + it('getPluginSettings returns a copy, not the original object', async () => { + mockAuthAuthenticated() + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) + + const ctxRef = createContextRef() + render( + + + + ) + + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + + const settings1 = ctxRef.current?.getPluginSettings('analytics') + const settings2 = ctxRef.current?.getPluginSettings('analytics') + expect(settings1).toEqual(settings2) + expect(settings1).not.toBe(settings2) + }) + + it('getPluginSettings returns null for unknown plugin', async () => { + mockAuthAuthenticated() + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) + + const ctxRef = createContextRef() + render( + + + + ) + + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + + expect(ctxRef.current?.getPluginSettings('nonexistent')).toBeNull() + }) + + it('refreshPlugins triggers re-fetch', async () => { + mockAuthAuthenticated() + vi.mocked(getPlugins) + .mockResolvedValueOnce({ plugins: [mockPlugins[0]!] }) + .mockResolvedValueOnce({ plugins: mockPlugins }) + + const ctxRef = createContextRef() + render( + + + + ) + + // Wait for initial fetch + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + expect(ctxRef.current?.plugins).toHaveLength(1) + + // Trigger refresh + await act(async () => { + await ctxRef.current?.refreshPlugins() + }) + + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + + expect(getPlugins).toHaveBeenCalledTimes(2) + expect(ctxRef.current?.plugins).toHaveLength(2) + }) + + it('keeps existing plugins on fetch error', async () => { + mockAuthAuthenticated() + vi.mocked(getPlugins) + .mockResolvedValueOnce({ plugins: mockPlugins }) + .mockRejectedValueOnce(new Error('Network error')) + + const ctxRef = createContextRef() + render( + + + + ) + + // Wait for initial fetch + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + expect(ctxRef.current?.plugins).toHaveLength(2) + + // Trigger refresh that fails + await act(async () => { + await ctxRef.current?.refreshPlugins() + }) + + await waitFor(() => { + expect(ctxRef.current?.isLoading).toBe(false) + }) + + // Plugins should be preserved + expect(ctxRef.current?.plugins).toHaveLength(2) + }) + + it('does not fetch while auth is still loading', () => { + vi.mocked(useAuth).mockReturnValue({ + user: null, + isAuthenticated: false, + isLoading: true, + crossPostScopesGranted: false, + getAccessToken: () => null, + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + requestCrossPostAuth: vi.fn(), + authFetch: vi.fn(), + } as ReturnType) + + const ctxRef = createContextRef() + render( + + + + ) + + expect(getPlugins).not.toHaveBeenCalled() + expect(ctxRef.current?.isLoading).toBe(true) + }) +}) diff --git a/src/context/plugin-context.tsx b/src/context/plugin-context.tsx new file mode 100644 index 0000000..f26ecd7 --- /dev/null +++ b/src/context/plugin-context.tsx @@ -0,0 +1,98 @@ +/** + * Plugin context provider. + * Fetches enabled plugins and their settings from the API on mount. + * Exposes helpers to check plugin state and a refresh method for admin pages. + */ + +'use client' + +import { createContext, useCallback, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import type { Plugin } from '@/lib/api/types' +import { getPlugins } from '@/lib/api/client' +import { useAuth } from '@/hooks/use-auth' + +export interface PluginContextValue { + /** List of all plugins (enabled and disabled) */ + plugins: Plugin[] + /** Check whether a plugin with the given name is enabled */ + isPluginEnabled: (name: string) => boolean + /** Get the settings for a plugin by name, or null if not found */ + getPluginSettings: (name: string) => Record | null + /** Whether the plugin list is still loading */ + isLoading: boolean + /** Re-fetch the plugin list (call after admin changes) */ + refreshPlugins: () => Promise +} + +export const PluginContext = createContext(null) + +interface PluginProviderProps { + children: ReactNode +} + +export function PluginProvider({ children }: PluginProviderProps) { + const { getAccessToken, isLoading: authLoading } = useAuth() + const [plugins, setPlugins] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + const fetchPlugins = useCallback(async () => { + const token = getAccessToken() + if (!token) { + // Unauthenticated: no plugin data available from API + setPlugins([]) + setIsLoading(false) + return + } + + try { + const response = await getPlugins(token) + setPlugins(response.plugins) + } catch { + // On error, keep existing plugins (or empty on first load) + setPlugins((prev) => prev) + } finally { + setIsLoading(false) + } + }, [getAccessToken]) + + const refreshPlugins = useCallback(async () => { + setIsLoading(true) + await fetchPlugins() + }, [fetchPlugins]) + + useEffect(() => { + if (authLoading) return + void fetchPlugins() + }, [authLoading, fetchPlugins]) + + const isPluginEnabled = useCallback( + (name: string): boolean => { + const plugin = plugins.find((p) => p.name === name) + return plugin?.enabled ?? false + }, + [plugins] + ) + + const getPluginSettings = useCallback( + (name: string): Record | null => { + const plugin = plugins.find((p) => p.name === name) + if (!plugin) return null + return { ...plugin.settings } + }, + [plugins] + ) + + const value = useMemo( + () => ({ + plugins, + isPluginEnabled, + getPluginSettings, + isLoading, + refreshPlugins, + }), + [plugins, isPluginEnabled, getPluginSettings, isLoading, refreshPlugins] + ) + + return {children} +} diff --git a/src/hooks/use-plugins.ts b/src/hooks/use-plugins.ts new file mode 100644 index 0000000..5cf620c --- /dev/null +++ b/src/hooks/use-plugins.ts @@ -0,0 +1,23 @@ +/** + * Hook to access plugin context. + * Returns a safe default when used outside PluginProvider (SSR, tests). + */ + +'use client' + +import { useContext } from 'react' +import { PluginContext } from '@/context/plugin-context' +import type { PluginContextValue } from '@/context/plugin-context' + +const defaultContext: PluginContextValue = { + plugins: [], + isPluginEnabled: () => false, + getPluginSettings: () => null, + isLoading: false, + refreshPlugins: async () => {}, +} + +export function usePlugins(): PluginContextValue { + const context = useContext(PluginContext) + return context ?? defaultContext +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 7d14554..4bf08de 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -47,7 +47,9 @@ import type { ReportedUsersResponse, AdminUsersResponse, MaturityRating, + Plugin, PluginsResponse, + RegistrySearchResponse, OnboardingField, AdminOnboardingFieldsResponse, CreateOnboardingFieldInput, @@ -81,7 +83,7 @@ const API_URL = interface FetchOptions { headers?: Record signal?: AbortSignal - method?: 'GET' | 'POST' | 'PUT' | 'DELETE' + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' body?: unknown } @@ -675,7 +677,7 @@ export function togglePlugin( `/api/plugins/${encodeURIComponent(id)}/${enabled ? 'enable' : 'disable'}`, { ...options, - method: 'PUT', + method: 'PATCH', headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, } ) @@ -689,7 +691,7 @@ export function updatePluginSettings( ): Promise { return apiFetch(`/api/plugins/${encodeURIComponent(id)}/settings`, { ...options, - method: 'PUT', + method: 'PATCH', headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, body: settings, }) @@ -707,6 +709,43 @@ export function uninstallPlugin( }) } +export function installPlugin( + packageName: string, + version: string | undefined, + accessToken: string, + options?: FetchOptions +): Promise<{ plugin: Plugin }> { + return apiFetch<{ plugin: Plugin }>('/api/plugins/install', { + ...options, + method: 'POST', + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, + body: { packageName, ...(version ? { version } : {}) }, + }) +} + +export function searchPluginRegistry( + params: { q?: string; category?: string; source?: string }, + options?: FetchOptions +): Promise { + const searchParams = new URLSearchParams() + if (params.q) searchParams.set('q', params.q) + if (params.category) searchParams.set('category', params.category) + if (params.source) searchParams.set('source', params.source) + const query = searchParams.toString() + return apiFetch( + `/api/plugins/registry/search${query ? `?${query}` : ''}`, + { + ...options, + } + ) +} + +export function getFeaturedPlugins(options?: FetchOptions): Promise { + return apiFetch('/api/plugins/registry/featured', { + ...options, + }) +} + // --- User Preference endpoints --- export function getPreferences( diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 811c442..cfd3b39 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -557,6 +557,27 @@ export interface PluginsResponse { plugins: Plugin[] } +export interface RegistryPlugin { + name: string + displayName: string + description: string + version: string + source: PluginSource + category: string + barazoVersion: string + author: { name: string; url?: string } + license: string + npmUrl: string + repositoryUrl?: string + approved: boolean + featured: boolean + downloads: number +} + +export interface RegistrySearchResponse { + plugins: RegistryPlugin[] +} + // --- User Preferences --- export interface UserPreferences { diff --git a/src/lib/plugins/registry.test.ts b/src/lib/plugins/registry.test.ts new file mode 100644 index 0000000..29b604e --- /dev/null +++ b/src/lib/plugins/registry.test.ts @@ -0,0 +1,97 @@ +/** + * Tests for plugin component registry. + */ + +import { describe, it, expect, beforeEach } from 'vitest' +import type { ComponentType } from 'react' +import { registerPluginComponent, getPluginComponents, clearPluginRegistry } from './registry' + +// Stub components for testing +const StubComponentA = (() => null) as ComponentType> +const StubComponentB = (() => null) as ComponentType> + +beforeEach(() => { + clearPluginRegistry() +}) + +describe('registerPluginComponent', () => { + it('adds a component to a slot', () => { + registerPluginComponent('post-content', 'test-plugin', StubComponentA) + + const components = getPluginComponents('post-content') + expect(components).toHaveLength(1) + expect(components[0]!.pluginName).toBe('test-plugin') + expect(components[0]!.component).toBe(StubComponentA) + }) + + it('allows multiple plugins in the same slot', () => { + registerPluginComponent('post-content', 'plugin-a', StubComponentA) + registerPluginComponent('post-content', 'plugin-b', StubComponentB) + + const components = getPluginComponents('post-content') + expect(components).toHaveLength(2) + expect(components[0]!.pluginName).toBe('plugin-a') + expect(components[1]!.pluginName).toBe('plugin-b') + }) + + it('prevents duplicate registration for same plugin and slot', () => { + registerPluginComponent('post-content', 'test-plugin', StubComponentA) + registerPluginComponent('post-content', 'test-plugin', StubComponentB) + + const components = getPluginComponents('post-content') + expect(components).toHaveLength(1) + expect(components[0]!.component).toBe(StubComponentA) + }) + + it('allows same plugin in different slots', () => { + registerPluginComponent('post-content', 'test-plugin', StubComponentA) + registerPluginComponent('topic-sidebar', 'test-plugin', StubComponentA) + + expect(getPluginComponents('post-content')).toHaveLength(1) + expect(getPluginComponents('topic-sidebar')).toHaveLength(1) + }) +}) + +describe('getPluginComponents', () => { + it('returns registered components for a slot', () => { + registerPluginComponent('admin-dashboard', 'dashboard-plugin', StubComponentA) + + const result = getPluginComponents('admin-dashboard') + expect(result).toHaveLength(1) + expect(result[0]!.pluginName).toBe('dashboard-plugin') + }) + + it('returns empty array for unregistered slot', () => { + const result = getPluginComponents('user-profile') + expect(result).toEqual([]) + }) + + it('returns empty array for slot with no registrations', () => { + // Register in a different slot, then query an unused one + registerPluginComponent('post-content', 'some-plugin', StubComponentA) + const result = getPluginComponents('settings-community') + expect(result).toEqual([]) + }) +}) + +describe('clearPluginRegistry', () => { + it('removes all registrations', () => { + registerPluginComponent('post-content', 'plugin-a', StubComponentA) + registerPluginComponent('topic-sidebar', 'plugin-b', StubComponentB) + + clearPluginRegistry() + + expect(getPluginComponents('post-content')).toEqual([]) + expect(getPluginComponents('topic-sidebar')).toEqual([]) + }) + + it('allows re-registration after clearing', () => { + registerPluginComponent('post-content', 'test-plugin', StubComponentA) + clearPluginRegistry() + registerPluginComponent('post-content', 'test-plugin', StubComponentB) + + const components = getPluginComponents('post-content') + expect(components).toHaveLength(1) + expect(components[0]!.component).toBe(StubComponentB) + }) +}) diff --git a/src/lib/plugins/registry.ts b/src/lib/plugins/registry.ts new file mode 100644 index 0000000..73475aa --- /dev/null +++ b/src/lib/plugins/registry.ts @@ -0,0 +1,35 @@ +import type { ComponentType } from 'react' + +export type SlotName = + | 'settings-community' + | 'settings-global' + | 'post-content' + | 'admin-dashboard' + | 'topic-sidebar' + | 'user-profile' + +export interface PluginRegistration { + pluginName: string + component: ComponentType> +} + +const registry = new Map() + +export function registerPluginComponent( + slot: SlotName, + pluginName: string, + component: ComponentType> +): void { + const existing = registry.get(slot) ?? [] + // Prevent duplicate registration + if (existing.some((r) => r.pluginName === pluginName)) return + registry.set(slot, [...existing, { pluginName, component }]) +} + +export function getPluginComponents(slot: SlotName): PluginRegistration[] { + return registry.get(slot) ?? [] +} + +export function clearPluginRegistry(): void { + registry.clear() +}