diff --git a/my-sonicjs-app/src/index.ts b/my-sonicjs-app/src/index.ts index 13fc6183d..5a7bfd392 100644 --- a/my-sonicjs-app/src/index.ts +++ b/my-sonicjs-app/src/index.ts @@ -60,3 +60,4 @@ export default { boot: app.boot, }), }; + diff --git a/my-sonicjs-app/wrangler.toml b/my-sonicjs-app/wrangler.toml index 83a518e70..b6c5bc82f 100644 --- a/my-sonicjs-app/wrangler.toml +++ b/my-sonicjs-app/wrangler.toml @@ -13,8 +13,8 @@ workers_dev = true # Note: database_name and database_id are automatically updated by GitHub Actions [[d1_databases]] binding = "DB" -database_name = "sonicjs-worktree-lane711-api-key-copy-before-redirect" -database_id = "a211809e-e558-4b82-b2f3-9b8de0d5e4d8" +database_name = "sonicjs-worktree-lane711-menu-plugin-plan" +database_id = "2bffa108-2c31-4710-9a04-8b4f97b16b72" migrations_dir = "./migrations" # R2 Bucket for media storage (using CI bucket) diff --git a/packages/core/src/__tests__/services/menu-repository.sqlite.test.ts b/packages/core/src/__tests__/services/menu-repository.sqlite.test.ts new file mode 100644 index 000000000..3dabd1763 --- /dev/null +++ b/packages/core/src/__tests__/services/menu-repository.sqlite.test.ts @@ -0,0 +1,199 @@ +// @ts-nocheck +// Real-SQLite coverage for menu-plugin data layer. +// Tests: idempotent seeding, plugin reconcile, soft-delete guards, reorder batch. +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { createTestD1 } from '../utils/d1-sqlite' +import { + listMenuItems, + buildSidebarTree, + upsertSystemItem, + upsertPluginItem, + reorderItems, + toggleVisibility, + updateItem, + deleteItem, +} from '../../plugins/core-plugins/menu-plugin/services/menu-repository' + +const MENU_TYPE_SQL = ` + INSERT INTO document_types (id, name, display_name, schema, queryable_fields, settings, source, schema_version, is_system, is_active, created_at, updated_at) + VALUES ('menu_item','menu_item','Menu Item','{}','[]','{}','plugin',1,0,1,1,1) +` + +const MENU_FIELDS = [ + { name: 'parent', path: '$.parent', kind: 'scalar', type: 'text', column: 'q_menu_parent' }, + { name: 'visible', path: '$.visible', kind: 'scalar', type: 'boolean', column: 'q_menu_visible' }, + { name: 'source', path: '$.source', kind: 'scalar', type: 'text', column: 'q_menu_source' }, + { name: 'pluginId', path: '$.pluginId', kind: 'scalar', type: 'text', column: 'q_menu_plugin_id' }, +] + +function systemItemData(overrides = {}) { + return { + label: 'Content', + url: '/admin/content', + icon: 'document', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url', 'parent'], + ...overrides, + } +} + +describe('menu-repository — real SQLite', () => { + let db + + beforeEach(async () => { + db = createTestD1() + db.raw.prepare(MENU_TYPE_SQL).run() + await db.applyScalarSchema('menu_item', MENU_FIELDS) + }) + + afterEach(() => db.close()) + + it('upsertSystemItem is idempotent across two boots — no duplicates', async () => { + const slug = 'menu:system:content' + const data = systemItemData() + + await upsertSystemItem(db, slug, data, 10) + await upsertSystemItem(db, slug, data, 10) + + const items = await listMenuItems(db) + const matching = items.filter((i) => i.slug === slug) + expect(matching).toHaveLength(1) + }) + + it('upsertPluginItem upserts on first call then only updates url+pluginId', async () => { + const slug = 'menu:plugin:email' + const data = { + label: 'Email', + url: '/admin/email', + icon: 'envelope', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'plugin', + pluginId: 'email', + permissions: [], + lockedFields: ['url', 'parent'], + } + + await upsertPluginItem(db, slug, data, 100) + + // Simulate admin renaming label — change label in DB directly + const items1 = await listMenuItems(db) + const item = items1.find((i) => i.slug === slug) + expect(item).toBeDefined() + db.raw.prepare(`UPDATE documents SET data = json_set(data, '$.label', ?) WHERE id = ?`) + .run('Email (Custom)', item.id) + + // Re-upsert with original data — should NOT overwrite admin's custom label + await upsertPluginItem(db, slug, data, 100) + + const items2 = await listMenuItems(db) + const updated = items2.find((i) => i.slug === slug) + expect(updated.label).toBe('Email (Custom)') + expect(updated.url).toBe('/admin/email') + }) + + it('reorderItems batch-updates sort_order (R1)', async () => { + await upsertSystemItem(db, 'menu:system:content', systemItemData({ label: 'Content' }), 10) + await upsertSystemItem(db, 'menu:system:users', systemItemData({ label: 'Users', url: '/admin/users' }), 20) + + const before = await listMenuItems(db) + const [a, b] = before.map((i) => ({ id: i.id, sortOrder: i.sortOrder })) + + // Swap sort orders + await reorderItems(db, [ + { id: a.id, sortOrder: b.sortOrder, parent: null }, + { id: b.id, sortOrder: a.sortOrder, parent: null }, + ]) + + const after = await listMenuItems(db) + const aAfter = after.find((i) => i.id === a.id) + const bAfter = after.find((i) => i.id === b.id) + expect(aAfter.sortOrder).toBe(b.sortOrder) + expect(bAfter.sortOrder).toBe(a.sortOrder) + }) + + it('deleteItem blocks system source, allows user source', async () => { + await upsertSystemItem(db, 'menu:system:content', systemItemData({ source: 'system' }), 10) + const items = await listMenuItems(db) + const systemItem = items[0] + + const sysResult = await deleteItem(db, systemItem.id) + expect(sysResult.ok).toBe(false) + expect(sysResult.reason).toBeDefined() + + // Create a user item + const userSlug = 'menu:user:test' + await upsertSystemItem(db, userSlug, systemItemData({ source: 'user', lockedFields: [] }), 50) + const items2 = await listMenuItems(db) + const userItem = items2.find((i) => i.slug === userSlug) + expect(userItem).toBeDefined() + + const userResult = await deleteItem(db, userItem.id) + expect(userResult.ok).toBe(true) + + const items3 = await listMenuItems(db) + expect(items3.find((i) => i.id === userItem.id)).toBeUndefined() + }) + + it('toggleVisibility updates both column and data JSON', async () => { + await upsertSystemItem(db, 'menu:system:settings', systemItemData({ label: 'Settings', url: '/admin/settings' }), 40) + const items = await listMenuItems(db) + const item = items[0] + + await toggleVisibility(db, item.id, false) + + const row = db.raw.prepare('SELECT visible, data FROM documents WHERE id = ?').get(item.id) + expect(row.visible).toBeFalsy() + const data = JSON.parse(row.data) + expect(data.visible).toBeFalsy() + }) + + it('updateItem rejects locked fields', async () => { + await upsertSystemItem(db, 'menu:system:content', systemItemData(), 10) + const items = await listMenuItems(db) + const item = items[0] + + // Should be rejected — url is locked + const rejected = await updateItem(db, item.id, { url: '/admin/other' }, ['url', 'parent']) + expect(rejected).toBe(false) + + // Label change should succeed + const ok = await updateItem(db, item.id, { label: 'My Content' }, ['url', 'parent']) + expect(ok).toBe(true) + + const updated = (await listMenuItems(db)).find((i) => i.id === item.id) + expect(updated.label).toBe('My Content') + // URL unchanged + expect(updated.url).toBe('/admin/content') + }) + + it('buildSidebarTree nests children under parents and sorts by sortOrder', async () => { + await upsertSystemItem(db, 'menu:system:parent', systemItemData({ label: 'Parent', url: '/admin/parent' }), 10) + const all = await listMenuItems(db) + const parentId = all[0].id + + // Add child + await upsertSystemItem(db, 'menu:system:child', systemItemData({ + label: 'Child', + url: '/admin/parent/child', + parent: parentId, + source: 'user', + lockedFields: [], + }), 5) + + const items = await listMenuItems(db) + const tree = buildSidebarTree(items) + + expect(tree).toHaveLength(1) + expect(tree[0].children).toHaveLength(1) + expect(tree[0].children[0].label).toBe('Child') + }) +}) diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index 566b362fe..48ea2d897 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -48,6 +48,8 @@ import { requireAuth, requireRole, requireRbac } from './middleware/auth' import { createAuth } from './auth/config' import { adminRbacRoutes } from './routes/admin-rbac' import { pluginMenuMiddleware } from './middleware/plugin-menu' +import { menuMiddleware } from './middleware/menu' +import { menuPlugin } from './plugins/core-plugins/menu-plugin' import { analyticsPlugin } from './plugins/core-plugins/analytics' import { eventsApiRoutes } from './plugins/core-plugins/analytics/routes/api' import { globalVariablesPlugin } from './plugins/core-plugins/global-variables-plugin' @@ -296,6 +298,7 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { multiTenantPlugin, lexicalEditorPlugin, versioningPlugin, + menuPlugin, ] const corePluginsAfterCatchAll = [emailPlugin, magicLinkPlugin, emailReconciliationPlugin] @@ -505,6 +508,8 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { // Plugin dynamic menu items for admin sidebar app.use('/admin/*', pluginMenuMiddleware()) + // Menu plugin sidebar replacement (DB-driven nav items) + app.use('/admin/*', menuMiddleware()) // RBAC-aware admin shell. Computes the signed-in user's effective permission // set once, then (1) redirects the dashboard landing to the first section the diff --git a/packages/core/src/db/migrations-bundle.ts b/packages/core/src/db/migrations-bundle.ts index 4f4f314fa..72e75f532 100644 --- a/packages/core/src/db/migrations-bundle.ts +++ b/packages/core/src/db/migrations-bundle.ts @@ -1,7 +1,7 @@ /** * AUTO-GENERATED FILE - DO NOT EDIT * Generated by: scripts/generate-migrations.ts - * Generated at: 2026-07-01T19:11:49.465Z + * Generated at: 2026-07-01T23:57:26.248Z * * This file contains all migration SQL bundled for use in Cloudflare Workers * where filesystem access is not available at runtime. diff --git a/packages/core/src/middleware/menu.ts b/packages/core/src/middleware/menu.ts new file mode 100644 index 000000000..55e5ff6e6 --- /dev/null +++ b/packages/core/src/middleware/menu.ts @@ -0,0 +1,179 @@ +import type { Context, Next } from 'hono' +import type { Bindings, Variables } from '../app' +import { listMenuItems, buildSidebarTree } from '../plugins/core-plugins/menu-plugin/services/menu-repository' +import type { SidebarItem } from '../plugins/core-plugins/menu-plugin/services/menu-repository' +import { resolveIcon } from '../services/menu-icons' +import { escapeHtml } from '../utils/sanitize' + +const MARKER_START = '' +const MARKER_END = '' + +const EXTERNAL_ICON = `` + +const CHEVRON = `` + +function renderTopLevelItem(item: SidebarItem, currentPath: string): string { + const isActive = + currentPath === item.url || + (item.url !== '/admin' && currentPath.startsWith(item.url)) + const icon = resolveIcon(item.icon) + const label = escapeHtml(item.label) + const targetAttr = item.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '' + const externalAffordance = item.isExternal ? EXTERNAL_ICON : '' + + if (item.children.length > 0) { + const accordionId = `acc-${item.id.replace(/[^a-z0-9]/gi, '-')}` + const isChildActive = item.children.some( + (c) => currentPath === c.url || currentPath.startsWith(c.url), + ) + const accordionActive = isActive || isChildActive + return ` +
+ ${accordionActive ? '' : ''} +
+ + ${icon} + ${label}${externalAffordance} + + +
+
+ ${item.children.map((child) => renderChildItem(child, currentPath)).join('')} +
+
` + } + + return ` + + ${isActive ? '' : ''} + + ${icon} + ${label}${externalAffordance} + + ` +} + +function renderChildItem(item: SidebarItem, currentPath: string): string { + const isActive = + currentPath === item.url || + (item.url !== '/admin' && currentPath.startsWith(item.url)) + const icon = resolveIcon(item.icon) + const label = escapeHtml(item.label) + const targetAttr = item.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '' + const externalAffordance = item.isExternal ? EXTERNAL_ICON : '' + + return ` + + ${isActive ? '' : ''} + + ${icon} + ${label}${externalAffordance} + + ` +} + +const ACCORDION_SCRIPT = ` +` + +async function isMenuPluginActive(db: any): Promise { + try { + // Plugins live in documents (type_id='plugin', slug=pluginId) — not the legacy plugins table + const row = await db + .prepare( + `SELECT json_extract(data, '$.status') AS status + FROM documents + WHERE slug = 'menu' AND type_id = 'plugin' AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL + LIMIT 1`, + ) + .first() as { status: string } | null + // Not yet in DB (never installed/activated) → off by default + if (!row) return false + return row.status === 'active' + } catch { + return false + } +} + +export function menuMiddleware() { + return async (c: Context<{ Bindings: Bindings; Variables: Variables }>, next: Next) => { + const path = new URL(c.req.url).pathname + if (!path.startsWith('/admin')) { + return next() + } + + await next() + + if (!c.res.headers.get('content-type')?.includes('text/html')) return + + // Revert to hardcoded sidebar when plugin is explicitly disabled + if (!(await isMenuPluginActive(c.env.DB))) return + + let items: Awaited> = [] + try { + items = await listMenuItems(c.env.DB) + } catch { + return + } + + if (items.length === 0) return + + const tree = buildSidebarTree(items) + const renderedItems = tree.map((item) => renderTopLevelItem(item, path)).join('') + const navItemsHtml = renderedItems + ACCORDION_SCRIPT + + const status = c.res.status + const headers = new Headers(c.res.headers) + const html = await c.res.text() + + // Replace ALL occurrences of the marker pair (desktop + mobile sidebars both use it) + const markerRegex = /[\s\S]*?/g + if (markerRegex.test(html)) { + const newHtml = html.replace( + /[\s\S]*?/g, + navItemsHtml, + ) + c.res = new Response(newHtml, { status, headers }) + } else { + c.res = new Response(html, { status, headers }) + } + } +} diff --git a/packages/core/src/plugins/core-plugins/index.ts b/packages/core/src/plugins/core-plugins/index.ts index 88910e555..1da3f345d 100644 --- a/packages/core/src/plugins/core-plugins/index.ts +++ b/packages/core/src/plugins/core-plugins/index.ts @@ -43,6 +43,7 @@ export { stripePlugin, createStripePlugin, SubscriptionService, StripeAPI, requi export { dashboardPlugin, createDashboardPlugin } from './dashboard-plugin' export { multiTenantPlugin, createMultiTenantPlugin, TenantService } from './multi-tenant-plugin' export { versioningPlugin, createVersioningPlugin } from './versioning-plugin' +export { menuPlugin, createMenuPlugin } from './menu-plugin' // Core plugins list - now imported from auto-generated registry export const CORE_PLUGIN_IDS = [ @@ -65,7 +66,8 @@ export const CORE_PLUGIN_IDS = [ 'user-profiles', 'stripe', 'multi-tenant', - 'versioning' + 'versioning', + 'menu', ] as const export type CorePluginNames = (typeof CORE_PLUGIN_IDS)[number] diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/index.ts b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts new file mode 100644 index 000000000..107ae1ba5 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts @@ -0,0 +1,92 @@ +import { definePlugin } from '../../sdk' +import { DocumentTypeRegistry } from '../../../services/document-type-registry' +import { SYSTEM_MENU_ITEMS } from './services/menu-defaults' +import { upsertSystemItem, listMenuItems, fetchPluginStatuses } from './services/menu-repository' +import { reconcileMenuFromPlugins } from './services/menu-reconcile' +import { adminMenuRoutes } from './routes/admin-menu' +import { renderMenuSettingsContent } from './templates/admin-menu-list.template' +import { z } from 'zod' +import type { D1Database } from '@cloudflare/workers-types' + +export const menuPlugin = definePlugin({ + id: 'menu', + version: '1.0.0', + name: 'Menu Manager', + description: 'Admin sidebar navigation manager.', + sonicjsVersionRange: '^3.0.0', + author: { name: 'SonicJS Team', email: 'team@sonicjs.com' }, + + register(app) { + app.route('/admin/menu', adminMenuRoutes as any) + }, + + async onBoot(ctx) { + const env = (ctx.env ?? {}) as Record + const db = env.DB as D1Database | undefined + if (!db) return + + try { + // 1. Register menu_item document type + const typeRegistry = new DocumentTypeRegistry(db) + await typeRegistry.register({ + id: 'menu_item', + name: 'menu_item', + displayName: 'Menu Item', + description: 'Admin sidebar navigation item', + schema: z.object({}), + pluginId: 'menu', + source: 'plugin', + queryableFields: [ + { name: 'parent', path: '$.parent', kind: 'scalar', type: 'text', column: 'q_menu_parent' }, + { name: 'visible', path: '$.visible', kind: 'scalar', type: 'boolean', column: 'q_menu_visible' }, + { name: 'source', path: '$.source', kind: 'scalar', type: 'text', column: 'q_menu_source' }, + { name: 'pluginId', path: '$.pluginId', kind: 'scalar', type: 'text', column: 'q_menu_plugin_id' }, + ], + settings: { + baseGrants: { admin: ['read', 'create', 'update', 'delete', 'manage'] }, + internal: true, + }, + }) + + // 2. Seed system menu items (insert-only, idempotent) + for (const item of SYSTEM_MENU_ITEMS) { + await upsertSystemItem(db, item.id, { + label: item.label, + url: item.url, + icon: item.icon, + target: item.target, + isExternal: item.isExternal, + visible: item.visible, + parent: item.parent, + source: item.source, + pluginId: item.pluginId, + permissions: [...item.permissions], + lockedFields: [...item.lockedFields], + }, item.sortOrder) + } + + // 3. Reconcile plugin-contributed menu items + await reconcileMenuFromPlugins(db) + } catch { + // DB might not be ready (pre-migration) — skip silently + } + }, + + settingsTabContent: { + async loadData(db: any) { + const items = await listMenuItems(db) + const pluginIds = [...new Set(items.filter(i => i.pluginId).map(i => i.pluginId as string))] + const pluginStatuses = await fetchPluginStatuses(db, pluginIds) + return { items, pluginStatuses } + }, + render({ data }) { + const items = data?.items ?? [] + const pluginStatuses = data?.pluginStatuses ?? {} + return renderMenuSettingsContent(items, pluginStatuses) + }, + }, +}) + +export function createMenuPlugin() { + return menuPlugin +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json b/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json new file mode 100644 index 000000000..cb066f3b7 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json @@ -0,0 +1,17 @@ +{ + "id": "menu", + "name": "Menu Manager", + "version": "1.0.0", + "description": "Manages the admin sidebar navigation. Admins can reorder, rename, hide built-in items, and add custom links.", + "author": "SonicJS Team", + "license": "MIT", + "category": "admin", + "tags": ["menu", "navigation", "admin", "sidebar"], + "dependencies": [], + "routes": [], + "permissions": {}, + "adminMenu": null, + "iconEmoji": "📋", + "is_core": true, + "defaultSettings": {} +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts b/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts new file mode 100644 index 000000000..18a8f6b2c --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts @@ -0,0 +1,245 @@ +import { Hono } from 'hono' +import type { Bindings, Variables } from '../../../../app' +import { requireAuth, requireRole } from '../../../../middleware' +import { escapeHtml } from '../../../../utils/sanitize' +import { + listMenuItems, + buildSidebarTree, + updateItem, + deleteItem, + toggleVisibility, + reorderItems, + fetchPluginStatuses, +} from '../services/menu-repository' +import { nanoid } from 'nanoid' +import { D1Database } from '@cloudflare/workers-types' + +function sanitizeUrl(url: string): string { + const trimmed = url.trim() + // Reject javascript: and data: schemes — XSS vectors via href injection + if (/^javascript:/i.test(trimmed) || /^data:/i.test(trimmed)) return '' + return trimmed +} + +// Template imports (will be created): +import { renderMenuListPage } from '../templates/admin-menu-list.template' +import { renderMenuFormPage } from '../templates/admin-menu-form.template' + +export const adminMenuRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>() + +adminMenuRoutes.use('*', requireAuth()) +adminMenuRoutes.use('*', requireRole(['admin'])) + +adminMenuRoutes.get('/', async (c) => { + const db = c.env.DB + const user = c.get('user') + const items = await listMenuItems(db) + const tree = buildSidebarTree(items) + const pluginIds = [...new Set(items.filter(i => i.pluginId).map(i => i.pluginId as string))] + const pluginStatuses = await fetchPluginStatuses(db, pluginIds) + return c.html(renderMenuListPage({ + items, + tree, + pluginStatuses, + user, + currentPath: '/admin/menu', + version: c.get('appVersion'), + dynamicMenuItems: c.get('pluginMenuItems'), + message: c.req.query('message'), + })) +}) + +adminMenuRoutes.get('/new', async (c) => { + const db = c.env.DB + const topLevelItems = await listMenuItems(db) + return c.html(renderMenuFormPage({ + item: null, + topLevelItems: topLevelItems.filter(i => !i.parent), + user: c.get('user'), + currentPath: '/admin/menu', + version: c.get('appVersion'), + dynamicMenuItems: c.get('pluginMenuItems'), + })) +}) + +adminMenuRoutes.get('/:id', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const items = await listMenuItems(db) + const item = items.find(i => i.id === id) + if (!item) return c.notFound() + const topLevelItems = items.filter(i => !i.parent && i.id !== id) + return c.html(renderMenuFormPage({ + item, + topLevelItems, + user: c.get('user'), + currentPath: '/admin/menu', + version: c.get('appVersion'), + dynamicMenuItems: c.get('pluginMenuItems'), + })) +}) + +adminMenuRoutes.post('/', async (c) => { + const db = c.env.DB + const form = await c.req.formData() + const label = String(form.get('label') ?? '').trim() + const url = String(form.get('url') ?? '').trim() + const icon = String(form.get('icon') ?? 'link').trim() + const target = form.get('target') === '_blank' ? '_blank' : '_self' + const parent = String(form.get('parent') ?? '').trim() || null + const visible = form.has('visible') + + if (!label) { + return c.html(renderMenuFormPage({ + item: null, + topLevelItems: [], + user: c.get('user'), + currentPath: '/admin/menu', + version: c.get('appVersion'), + dynamicMenuItems: c.get('pluginMenuItems'), + error: 'Label is required', + }), 400) + } + + const now = Math.floor(Date.now() / 1000) + const id = nanoid() + const slug = `menu:user:${id}` + const safeUrl = sanitizeUrl(url) + const isExternal = /^https?:\/\//.test(safeUrl) + const data = JSON.stringify({ + label, + url: safeUrl, + icon, + target, + isExternal, + visible, + parent, + source: 'user', + pluginId: null, + permissions: [], + lockedFields: [], + }) + + const items = await listMenuItems(db) + const maxSort = items.reduce((m, i) => Math.max(m, i.sortOrder), 0) + const sortOrder = maxSort + 10 + + await db.prepare( + `INSERT INTO documents (id, root_id, type_id, type_version, version_of_id, version_number, + is_current_draft, is_published, status, parent_root_id, slug, path, title, zone, + sort_order, visible, published_at, scheduled_at, expires_at, deleted_at, + tenant_id, locale, translation_group_id, data, metadata, + owner_id, created_by, updated_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` + ).bind( + id, id, 'menu_item', 1, null, 1, + 1, 1, 'published', '', slug, null, label, null, + sortOrder, visible ? 1 : 0, now, null, null, null, + 'default', 'default', '', data, '{}', + null, c.get('user')?.userId ?? 'system', c.get('user')?.userId ?? 'system', now, now + ).run() + + return c.redirect('/admin/menu?message=Item+created') +}) + +adminMenuRoutes.put('/reorder', async (c) => { + const db = c.env.DB + const body = await c.req.json>() + if (!Array.isArray(body)) return c.json({ error: 'Invalid body' }, 400) + await reorderItems(db, body) + return c.json({ ok: true }) +}) + +adminMenuRoutes.post('/:id/visibility', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const form = await c.req.formData() + const visible = form.get('visible') === 'true' + await toggleVisibility(db, id, visible) + return c.redirect('/admin/menu?message=Visibility+updated') +}) + +async function handleMenuItemUpdate(c: any) { + const db = c.env.DB + const id = c.req.param('id') + const form = await c.req.formData() + + const items = await listMenuItems(db) + const item = items.find((i: any) => i.id === id) + if (!item) return c.json({ error: 'Not found' }, 404) + + const locked = new Set(item.lockedFields) + const changes: Record = {} + if (form.has('label') && !locked.has('label')) changes.label = String(form.get('label')).trim() + if (form.has('icon') && !locked.has('icon')) changes.icon = String(form.get('icon')).trim() + if (form.has('target') && !locked.has('target')) changes.target = form.get('target') === '_blank' ? '_blank' : '_self' + if (form.has('url') && !locked.has('url')) changes.url = sanitizeUrl(String(form.get('url'))) + if (form.has('parent')) changes.parent = String(form.get('parent')).trim() || null + // Checkbox: present = true, absent = false + changes.visible = form.has('visible') + + const ok = await updateItem(db, id, changes, item.lockedFields) + if (!ok) return c.json({ error: 'Cannot modify locked fields' }, 403) + + return c.redirect('/admin/menu?message=Item+updated') +} + +adminMenuRoutes.put('/:id', handleMenuItemUpdate) + +// HTML form fallback (browsers can't PUT) +adminMenuRoutes.post('/:id/update', handleMenuItemUpdate) + +adminMenuRoutes.delete('/:id', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const result = await deleteItem(db, id) + if (!result.ok) return c.json({ error: result.reason }, 403) + return c.redirect('/admin/menu?message=Item+deleted') +}) + +// HTML form fallback: browsers can't send DELETE from a form +adminMenuRoutes.post('/:id/delete', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const result = await deleteItem(db, id) + if (!result.ok) return c.json({ error: result.reason }, 403) + return c.redirect('/admin/menu?message=Item+deleted') +}) + +adminMenuRoutes.post('/:id/move-up', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const allItems = await listMenuItems(db) + const cur = allItems.find((i) => i.id === id) + if (!cur) return c.redirect('/admin/menu') + const siblings = allItems.filter((i) => i.parent === cur.parent) + const idx = siblings.findIndex((i) => i.id === id) + const prev = siblings[idx - 1] + if (idx > 0 && prev) { + await reorderItems(db, [ + { id: cur.id, sortOrder: prev.sortOrder, parent: cur.parent }, + { id: prev.id, sortOrder: cur.sortOrder, parent: prev.parent }, + ]) + return c.redirect('/admin/menu?message=Reordered') + } + return c.redirect('/admin/menu') +}) + +adminMenuRoutes.post('/:id/move-down', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const allItems = await listMenuItems(db) + const cur = allItems.find((i) => i.id === id) + if (!cur) return c.redirect('/admin/menu') + const siblings = allItems.filter((i) => i.parent === cur.parent) + const idx = siblings.findIndex((i) => i.id === id) + const next = siblings[idx + 1] + if (idx !== -1 && idx < siblings.length - 1 && next) { + await reorderItems(db, [ + { id: cur.id, sortOrder: next.sortOrder, parent: cur.parent }, + { id: next.id, sortOrder: cur.sortOrder, parent: next.parent }, + ]) + return c.redirect('/admin/menu?message=Reordered') + } + return c.redirect('/admin/menu') +}) diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts new file mode 100644 index 000000000..9ae65a59d --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts @@ -0,0 +1,108 @@ +export interface SystemMenuItem { + id: string + label: string + url: string + icon: string + target: '_self' + isExternal: false + visible: true + parent: null + source: 'system' + pluginId: null + permissions: [] + lockedFields: ['url'] + sortOrder: number +} + +export const SYSTEM_MENU_ITEMS: readonly SystemMenuItem[] = [ + { + id: 'menu:system:content', + label: 'Content', + url: '/admin/content', + icon: 'document', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url'], + sortOrder: 10, + }, + { + id: 'menu:system:collections', + label: 'Collections', + url: '/admin/collections', + icon: 'collection', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url'], + sortOrder: 20, + }, + { + id: 'menu:system:users', + label: 'Users', + url: '/admin/users', + icon: 'users', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url'], + sortOrder: 30, + }, + { + id: 'menu:system:settings', + label: 'Settings', + url: '/admin/settings', + icon: 'cog', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url'], + sortOrder: 40, + }, + { + id: 'menu:system:plugins', + label: 'Plugins', + url: '/admin/plugins', + icon: 'collection', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url'], + sortOrder: 50, + }, + { + id: 'menu:system:menu', + label: 'Menu', + url: '/admin/menu', + icon: 'bars-3', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url'], + sortOrder: 60, + }, +] as const diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts new file mode 100644 index 000000000..b33539140 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts @@ -0,0 +1,198 @@ +import { D1Database } from '@cloudflare/workers-types' +import { nanoid } from 'nanoid' +import { getPluginMenu, type PluginMenuEntry } from '../../../../services/plugin-menu-singleton' +import { PLUGIN_REGISTRY, type PluginRegistryEntry } from '../../../../plugins/manifest-registry' + +interface ActivePluginEntry { + pluginId: string + label: string + url: string + icon: string + sortOrder: number +} + +async function upsertPluginRow( + db: D1Database, + slug: string, + entry: ActivePluginEntry, + visible: boolean, +): Promise { + const now = Math.floor(Date.now() / 1000) + + const existing = await db + .prepare( + `SELECT id FROM documents + WHERE slug = ? AND type_id = 'menu_item' AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL`, + ) + .bind(slug) + .first<{ id: string }>() + + if (existing) { + await db + .prepare( + `UPDATE documents + SET data = json_set(data, '$.url', ?, '$.pluginId', ?, '$.visible', ?), + visible = ?, + updated_at = ? + WHERE id = ? AND tenant_id = 'default'`, + ) + .bind(entry.url, entry.pluginId, visible ? 1 : 0, visible ? 1 : 0, now, existing.id) + .run() + return + } + + const id = nanoid() + const data = JSON.stringify({ + label: entry.label, + url: entry.url, + icon: entry.icon, + target: '_self', + isExternal: false, + visible, + parent: null, + source: 'plugin', + pluginId: entry.pluginId, + permissions: [], + lockedFields: ['url'], + sortOrder: entry.sortOrder, + }) + + // 30 columns — matches documents INSERT in DocumentsService.create (R5) + await db + .prepare( + `INSERT INTO documents (id, root_id, type_id, type_version, version_of_id, version_number, + is_current_draft, is_published, status, parent_root_id, slug, path, title, zone, + sort_order, visible, published_at, scheduled_at, expires_at, deleted_at, + tenant_id, locale, translation_group_id, data, metadata, + owner_id, created_by, updated_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ) + .bind( + id, id, 'menu_item', 1, null, 1, + 1, 0, 'draft', '', slug, null, entry.label, null, + entry.sortOrder, visible ? 1 : 0, null, null, null, null, + 'default', 'default', '', data, '{}', + null, null, null, now, now, + ) + .run() +} + +async function deactivateStalePluginRows( + db: D1Database, + activePluginIds: Set, +): Promise { + const now = Math.floor(Date.now() / 1000) + + const rows = await db + .prepare( + `SELECT id, data FROM documents + WHERE type_id = 'menu_item' AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL + AND json_extract(data, '$.source') = 'plugin'`, + ) + .all<{ id: string; data: string }>() + + const stale = (rows.results ?? []).filter((row) => { + let pluginId: string | undefined + try { + pluginId = JSON.parse(row.data)?.pluginId as string | undefined + } catch { + return false + } + return pluginId !== undefined && !activePluginIds.has(pluginId) + }) + + if (stale.length === 0) return + + await db.batch( + stale.map((row) => + db + .prepare( + `UPDATE documents + SET data = json_set(data, '$.visible', 0), visible = 0, updated_at = ? + WHERE id = ? AND tenant_id = 'default'`, + ) + .bind(now, row.id), + ), + ) +} + +async function fetchActivePluginIds(db: D1Database): Promise> { + try { + const rows = await db + .prepare( + `SELECT slug FROM documents + WHERE type_id = 'plugin' AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL + AND json_extract(data, '$.status') = 'active'`, + ) + .all<{ slug: string }>() + return new Set((rows.results ?? []).map((r) => r.slug)) + } catch { + return new Set() + } +} + +export async function reconcileMenuFromPlugins(db: D1Database): Promise { + try { + // Source A: code-declared plugins via definePlugin({ menu: [...] }) + const singletonEntries = getPluginMenu() + + // Source B: manifest-registered plugins with adminMenu + const manifestEntries = (Object.values(PLUGIN_REGISTRY) as PluginRegistryEntry[]).filter( + (p): p is PluginRegistryEntry & { adminMenu: NonNullable } => + p.adminMenu !== null, + ) + + // Merge into a map keyed by pluginId; manifest entries provide the base, + // singleton entries (code-declared) win if both define the same pluginId. + const byPluginId = new Map() + + // Index manifest entries first (lower priority) + manifestEntries.forEach((p: PluginRegistryEntry, idx: number) => { + const menu = p.adminMenu! + byPluginId.set(p.id, { + pluginId: p.id, + label: menu.label, + url: menu.path, + icon: menu.icon, + sortOrder: 100 + idx * 10, + }) + }) + + // Singleton entries override (higher priority — code-declared wins) + singletonEntries.forEach((entry: PluginMenuEntry, idx: number) => { + // Derive pluginId from path: last path segment after /admin/plugins/ + // or fall back to a slugified label. The slug uniqueness is enforced by + // the caller using `menu:plugin:` so we do the same derivation. + const pathParts = entry.path.replace(/\/$/, '').split('/') + const pluginId = + pathParts[pathParts.length - 1] || + entry.label.toLowerCase().replace(/\s+/g, '-') + + byPluginId.set(pluginId, { + pluginId, + label: entry.label, + url: entry.path, + icon: entry.icon ?? 'puzzle-piece', + sortOrder: 100 + idx * 10, + }) + }) + + // Only plugins explicitly active in DB get visible=true on first insert. + // Plugins not yet in DB (never installed) default to visible=false. + const activeIds = await fetchActivePluginIds(db) + + // Upsert all registry plugin entries; visible depends on active status + for (const [pluginId, entry] of byPluginId) { + const slug = `menu:plugin:${pluginId}` + await upsertPluginRow(db, slug, entry, activeIds.has(pluginId)) + } + + // Deactivate rows whose plugin is inactive or removed from registry + await deactivateStalePluginRows(db, activeIds) + } catch { + // DB may not be ready at bootstrap; swallow silently + } +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts new file mode 100644 index 000000000..b6bf57c72 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts @@ -0,0 +1,467 @@ +import { D1Database } from '@cloudflare/workers-types' +import { nanoid } from 'nanoid' + +export interface MenuItemData { + label: string + url: string + icon: string + target: '_self' | '_blank' + isExternal: boolean + visible: boolean + parent: string | null + source: 'system' | 'plugin' | 'user' + pluginId: string | null + permissions: string[] + lockedFields: string[] +} + +export interface MenuItem extends MenuItemData { + id: string + slug: string + sortOrder: number + tenantId: string +} + +export interface SidebarItem { + id: string + label: string + url: string + icon: string + target: '_self' | '_blank' + isExternal: boolean + visible: boolean + source: string + sortOrder: number + children: SidebarItem[] +} + +export async function listMenuItems( + db: D1Database, + opts?: { source?: string } +): Promise { + try { + let sql = ` + SELECT id, slug, sort_order, data + FROM documents + WHERE type_id = 'menu_item' + AND tenant_id = 'default' + AND is_current_draft = 1 + AND deleted_at IS NULL + ` + const binds: unknown[] = [] + + if (opts?.source) { + sql += ` AND q_menu_source = ?` + binds.push(opts.source) + } + + sql += ` ORDER BY sort_order ASC` + + const stmt = db.prepare(sql) + const result = await (binds.length ? stmt.bind(...binds) : stmt).all<{ + id: string + slug: string + sort_order: number + data: string + }>() + + return (result.results ?? []).map((row) => { + const data: MenuItemData = JSON.parse(row.data) + return { + ...data, + id: row.id, + slug: row.slug, + sortOrder: row.sort_order, + tenantId: 'default', + } + }) + } catch { + return [] + } +} + +/** + * Returns a map of pluginId → 'active'|'inactive' for the given plugin IDs. + * Uses the same query as PluginService.getAllPlugins — reads all plugin documents + * and maps slug → status, defaulting unrecognised slugs to 'active'. + */ +export async function fetchPluginStatuses( + db: D1Database, + pluginIds: string[], +): Promise> { + if (pluginIds.length === 0) return {} + try { + const rows = await db + .prepare( + `SELECT slug, json_extract(data, '$.status') AS status + FROM documents + WHERE type_id = 'plugin' AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL`, + ) + .all<{ slug: string; status: string | null }>() + + const statusMap = new Map() + for (const row of rows.results ?? []) { + statusMap.set(row.slug, row.status ?? 'inactive') + } + + const result: Record = {} + for (const id of pluginIds) { + const s = statusMap.get(id) + // Only explicitly active in DB → enabled; uninstalled/inactive → disabled + result[id] = s === 'active' ? 'active' : 'inactive' + } + return result + } catch { + return {} + } +} + +export function buildSidebarTree(items: MenuItem[]): SidebarItem[] { + const visible = items.filter((i) => i.visible) + visible.sort((a, b) => a.sortOrder - b.sortOrder) + + const byId = new Map() + const roots: SidebarItem[] = [] + + for (const item of visible) { + byId.set(item.id, { + id: item.id, + label: item.label, + url: item.url, + icon: item.icon, + target: item.target, + isExternal: item.isExternal, + visible: item.visible, + source: item.source, + sortOrder: item.sortOrder, + children: [], + }) + } + + for (const item of visible) { + const node = byId.get(item.id)! + if (item.parent && byId.has(item.parent)) { + byId.get(item.parent)!.children.push(node) + } else { + roots.push(node) + } + } + + for (const node of byId.values()) { + node.children.sort((a, b) => a.sortOrder - b.sortOrder) + } + + return roots +} + +export async function upsertSystemItem( + db: D1Database, + slug: string, + data: MenuItemData, + sortOrder: number +): Promise { + const now = Math.floor(Date.now() / 1000) + + const existing = await db + .prepare( + `SELECT id FROM documents WHERE slug = ? AND type_id = 'menu_item' AND tenant_id = 'default' AND is_current_draft = 1 AND deleted_at IS NULL` + ) + .bind(slug) + .first<{ id: string }>() + + if (existing) { + return + } + + const id = nanoid() + + await db + .prepare( + `INSERT INTO documents ( + id, root_id, type_id, type_version, version_of_id, version_number, + is_current_draft, is_published, status, parent_root_id, + slug, path, title, zone, sort_order, visible, + published_at, scheduled_at, expires_at, deleted_at, + tenant_id, locale, translation_group_id, + data, metadata, owner_id, created_by, updated_by, created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, ?, ?, ? + )` + ) + .bind( + id, // id + id, // root_id + 'menu_item', // type_id + 1, // type_version + null, // version_of_id + 1, // version_number + 1, // is_current_draft + 1, // is_published + 'published', // status + '', // parent_root_id + slug, // slug + null, // path + data.label, // title + null, // zone + sortOrder, // sort_order + data.visible ? 1 : 0, // visible + now, // published_at + null, // scheduled_at + null, // expires_at + null, // deleted_at + 'default', // tenant_id + 'default', // locale + '', // translation_group_id + JSON.stringify(data), // data + '{}', // metadata + null, // owner_id + 'system', // created_by + 'system', // updated_by + now, // created_at + now // updated_at + ) + .run() +} + +export async function upsertPluginItem( + db: D1Database, + slug: string, + data: MenuItemData, + sortOrder: number +): Promise { + const now = Math.floor(Date.now() / 1000) + + const existing = await db + .prepare( + `SELECT id, data FROM documents WHERE slug = ? AND type_id = 'menu_item' AND tenant_id = 'default' AND is_current_draft = 1 AND deleted_at IS NULL` + ) + .bind(slug) + .first<{ id: string; data: string }>() + + if (existing) { + const currentData: MenuItemData = JSON.parse(existing.data) + const updatedData: MenuItemData = { + ...currentData, + url: data.url, + pluginId: data.pluginId, + } + + await db + .prepare( + `UPDATE documents SET data = ?, updated_at = ? WHERE id = ? AND tenant_id = 'default'` + ) + .bind(JSON.stringify(updatedData), now, existing.id) + .run() + + return + } + + const id = nanoid() + + await db + .prepare( + `INSERT INTO documents ( + id, root_id, type_id, type_version, version_of_id, version_number, + is_current_draft, is_published, status, parent_root_id, + slug, path, title, zone, sort_order, visible, + published_at, scheduled_at, expires_at, deleted_at, + tenant_id, locale, translation_group_id, + data, metadata, owner_id, created_by, updated_by, created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, ?, ?, ? + )` + ) + .bind( + id, // id + id, // root_id + 'menu_item', // type_id + 1, // type_version + null, // version_of_id + 1, // version_number + 1, // is_current_draft + 1, // is_published + 'published', // status + '', // parent_root_id + slug, // slug + null, // path + data.label, // title + null, // zone + sortOrder, // sort_order + data.visible ? 1 : 0, // visible + now, // published_at + null, // scheduled_at + null, // expires_at + null, // deleted_at + 'default', // tenant_id + 'default', // locale + '', // translation_group_id + JSON.stringify(data), // data + '{}', // metadata + null, // owner_id + 'system', // created_by + 'system', // updated_by + now, // created_at + now // updated_at + ) + .run() +} + +export async function reorderItems( + db: D1Database, + items: Array<{ id: string; sortOrder: number; parent: string | null }> +): Promise { + if (items.length === 0) return + + const now = Math.floor(Date.now() / 1000) + + const stmts = items.map((item) => + db + .prepare( + `UPDATE documents SET + sort_order = ?, + data = json_set(data, '$.parent', ?), + updated_at = ? + WHERE id = ? AND tenant_id = 'default'` + ) + .bind(item.sortOrder, item.parent ?? null, now, item.id) + ) + + await db.batch(stmts) +} + +export async function toggleVisibility( + db: D1Database, + id: string, + visible: boolean +): Promise { + const now = Math.floor(Date.now() / 1000) + const visInt = visible ? 1 : 0 + + await db + .prepare( + `UPDATE documents SET + data = json_set(data, '$.visible', ?), + visible = ?, + updated_at = ? + WHERE id = ? AND tenant_id = 'default' AND type_id = 'menu_item'` + ) + .bind(visInt, visInt, now, id) + .run() +} + +export async function updateItem( + db: D1Database, + id: string, + changes: Partial< + Pick< + MenuItemData, + 'label' | 'icon' | 'target' | 'permissions' | 'url' | 'parent' | 'visible' + > & { sortOrder?: number } + >, + lockedFields: string[] +): Promise { + // parent is always editable — admins control layout regardless of source + const lockedKeys = new Set(lockedFields.filter(f => f !== 'parent')) + + for (const key of Object.keys(changes)) { + if (lockedKeys.has(key)) { + return false + } + } + + const now = Math.floor(Date.now() / 1000) + + const jsonSetParts: string[] = [] + const binds: unknown[] = [] + + const dataFields = ['label', 'icon', 'target', 'permissions', 'url', 'parent', 'visible'] as const + + for (const field of dataFields) { + if (field in changes) { + jsonSetParts.push(`'$.${field}'`, '?') + binds.push( + field === 'permissions' + ? JSON.stringify(changes[field]) + : changes[field] ?? null + ) + } + } + + const setParts: string[] = [] + + if (jsonSetParts.length > 0) { + setParts.push(`data = json_set(data, ${jsonSetParts.join(', ')})`) + } + + if ('sortOrder' in changes && changes.sortOrder !== undefined) { + setParts.push(`sort_order = ?`) + binds.push(changes.sortOrder) + } + + if ('visible' in changes && changes.visible !== undefined) { + setParts.push(`visible = ?`) + binds.push(changes.visible ? 1 : 0) + } + + if (setParts.length === 0) { + return true + } + + setParts.push(`updated_at = ?`) + binds.push(now) + binds.push(id) + + const sql = `UPDATE documents SET ${setParts.join(', ')} WHERE id = ? AND tenant_id = 'default' AND type_id = 'menu_item'` + + await db.prepare(sql).bind(...binds).run() + + return true +} + +export async function deleteItem( + db: D1Database, + id: string +): Promise<{ ok: boolean; reason?: string }> { + try { + const row = await db + .prepare( + `SELECT data FROM documents WHERE id = ? AND tenant_id = 'default' AND is_current_draft = 1 AND deleted_at IS NULL` + ) + .bind(id) + .first<{ data: string }>() + + if (!row) { + return { ok: false, reason: 'Item not found' } + } + + const data: MenuItemData = JSON.parse(row.data) + + if (data.source === 'system' || data.source === 'plugin') { + return { ok: false, reason: 'Cannot delete system or plugin items' } + } + + const now = Math.floor(Date.now() / 1000) + + await db + .prepare( + `UPDATE documents SET deleted_at = ? WHERE id = ? AND tenant_id = 'default'` + ) + .bind(now, id) + .run() + + return { ok: true } + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : 'Unknown error' } + } +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-form.template.ts b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-form.template.ts new file mode 100644 index 000000000..a845fcf08 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-form.template.ts @@ -0,0 +1,164 @@ +import { renderAdminLayoutCatalyst, AdminLayoutCatalystData } from '../../../../templates/layouts/admin-layout-catalyst.template' +import { escapeHtml } from '../../../../utils/sanitize' +import type { MenuItem } from '../services/menu-repository' +import { renderIconPicker } from './icon-picker.template' + +interface MenuFormPageData { + item: MenuItem | null + topLevelItems: MenuItem[] + user?: { name?: string; email?: string; role?: string } + currentPath?: string + version?: string + dynamicMenuItems?: Array<{ label: string; path: string; icon: string }> + error?: string +} + +export function renderMenuFormPage(data: MenuFormPageData): string { + const { item, topLevelItems } = data + const isEdit = item !== null + + const errorBanner = data.error + ? `
+ ${escapeHtml(data.error)} +
` + : '' + + const parentOptions = topLevelItems + .filter((t) => !isEdit || t.id !== item?.id) + .sort((a, b) => a.label.localeCompare(b.label)) + .map((t) => { + const selected = item?.parent === t.id ? 'selected' : '' + return `` + }) + .join('\n') + + const targetSelfSelected = !item || item.target === '_self' ? 'selected' : '' + const targetBlankSelected = item?.target === '_blank' ? 'selected' : '' + const visibleChecked = item ? (item.visible ? 'checked' : '') : 'checked' + + const formAttrs = isEdit + ? `method="POST" action="/admin/menu/${escapeHtml(item!.id)}/update"` + : `method="POST" action="/admin/menu"` + + const submitLabel = isEdit ? 'Save Changes' : 'Add Item' + const pageTitle = isEdit ? `Edit: ${escapeHtml(item!.label)}` : 'Add Menu Item' + + const pageContent = ` +
+ + + ${errorBanner} + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+

Icon

+ ${renderIconPicker(item?.icon ?? 'link')} +
+ +
+ + Cancel + + +
+ +
+
+
+ ` + + const layoutData: AdminLayoutCatalystData = { + title: isEdit ? 'Edit Menu Item' : 'Add Menu Item', + pageTitle: isEdit ? 'Edit Menu Item' : 'Add Menu Item', + currentPath: data.currentPath ?? '/admin/menu', + user: data.user as AdminLayoutCatalystData['user'], + version: data.version, + dynamicMenuItems: data.dynamicMenuItems, + content: pageContent + } + + return renderAdminLayoutCatalyst(layoutData) +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts new file mode 100644 index 000000000..205b4a36e --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts @@ -0,0 +1,313 @@ +import { renderAdminLayoutCatalyst, AdminLayoutCatalystData } from '../../../../templates/layouts/admin-layout-catalyst.template' +import { escapeHtml } from '../../../../utils/sanitize' +import type { MenuItem, SidebarItem } from '../services/menu-repository' + +interface MenuListPageData { + items: MenuItem[] + tree: SidebarItem[] + pluginStatuses?: Record + user?: { name?: string; email?: string; role?: string } + currentPath?: string + version?: string + dynamicMenuItems?: Array<{ label: string; path: string; icon: string }> + message?: string +} + +function pluginStatusBadge(status: 'active' | 'inactive'): string { + if (status === 'inactive') { + return `disabled` + } + return `enabled` +} + +function sourceBadge(source: string): string { + switch (source) { + case 'system': + return `system` + case 'plugin': + return `plugin` + default: + return `user` + } +} + +function iconPreview(icon: string): string { + if (!icon) return '' + if (icon.trim().startsWith('<')) { + return `${icon}` + } + return `${escapeHtml(icon)}` +} + +export function renderMenuListPage(data: MenuListPageData): string { + const messageBanner = data.message + ? `
+ ${escapeHtml(data.message)} +
` + : '' + + const pluginStatuses = data.pluginStatuses ?? {} + const rows = data.items.map((item, index) => { + const isFirst = index === 0 + const isLast = index === data.items.length - 1 + + const moveUpForm = !isFirst + ? `
+ +
` + : `` + + const moveDownForm = !isLast + ? `
+ +
` + : `` + + const deleteButton = item.source === 'user' + ? `
+ +
` + : '' + + return ` + + +
+ ${moveUpForm} + ${moveDownForm} +
+ + + ${iconPreview(item.icon)} + + + ${escapeHtml(item.label)} + ${item.parent ? `child` : ''} + + + ${escapeHtml(item.url)} + + + ${sourceBadge(item.source)} + ${item.source === 'plugin' && item.pluginId ? pluginStatusBadge(pluginStatuses[item.pluginId] ?? 'active') : ''} + + +
+ +
+ + +
+ + Edit + + ${deleteButton} +
+ + ` + }).join('\n') + + const emptyState = data.items.length === 0 + ? ` + + No menu items yet. Add a link to get started. + + ` + : '' + + const pageContent = ` +
+
+
+

Menu Manager

+

Manage navigation links and their order

+
+ +
+ + ${messageBanner} + +
+ + + + + + + + + + + + + + ${rows} + ${emptyState} + +
OrderIconLabelURLSourceVisibleActions
+
+
+ ` + + const layoutData: AdminLayoutCatalystData = { + title: 'Menu Manager', + pageTitle: 'Menu Manager', + currentPath: data.currentPath ?? '/admin/menu', + user: data.user as AdminLayoutCatalystData['user'], + version: data.version, + dynamicMenuItems: data.dynamicMenuItems, + content: pageContent + } + + return renderAdminLayoutCatalyst(layoutData) +} + +/** + * Renders just the menu items table for embedding in the plugin settings tab. + * No layout wrapper — called from the plugin's settingsTabContent.render(). + */ +export function renderMenuSettingsContent(items: MenuItem[], pluginStatuses: Record = {}, message?: string): string { + const messageBanner = message + ? `
${escapeHtml(message)}
` + : '' + + const rows = items.map((item, index) => { + const isFirst = index === 0 + const isLast = index === items.length - 1 + + const moveUpForm = !isFirst + ? `
+ +
` + : `` + + const moveDownForm = !isLast + ? `
+ +
` + : `` + + const deleteButton = item.source === 'user' + ? `
+ +
` + : '' + + return ` + + +
${moveUpForm}${moveDownForm}
+ + ${iconPreview(item.icon)} + + ${escapeHtml(item.label)} + ${item.parent ? `child` : ''} + + + ${escapeHtml(item.url)} + + ${sourceBadge(item.source)}${item.source === 'plugin' && item.pluginId ? pluginStatusBadge(pluginStatuses[item.pluginId] ?? 'active') : ''} + +
+ +
+ + +
+ Edit + ${deleteButton} +
+ + ` + }).join('\n') + + const emptyState = items.length === 0 + ? `No menu items yet. Add a link to get started.` + : '' + + return ` + +
+
+

Manage navigation links and their order in the sidebar.

+ + + Add Link + +
+ ${messageBanner} +
+ + + + + + + + + + + + + + ${rows} + ${emptyState} + +
OrderIconLabelURLSourceVisibleActions
+
+
+ ` +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/templates/icon-picker.template.ts b/packages/core/src/plugins/core-plugins/menu-plugin/templates/icon-picker.template.ts new file mode 100644 index 000000000..c6c4fdcab --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/templates/icon-picker.template.ts @@ -0,0 +1,207 @@ +import { escapeHtml } from '../../../../utils/sanitize' + +const ICONS: Array<{ name: string; svg: string }> = [ + { + name: 'puzzle-piece', + svg: '' + }, + { + name: 'document', + svg: '' + }, + { + name: 'collection', + svg: '' + }, + { + name: 'users', + svg: '' + }, + { + name: 'cog', + svg: '' + }, + { + name: 'envelope', + svg: '' + }, + { + name: 'chart', + svg: '' + }, + { + name: 'bolt', + svg: '' + }, + { + name: 'sparkles', + svg: '' + }, + { + name: 'photo', + svg: '' + }, + { + name: 'lock', + svg: '' + }, + { + name: 'magnifying-glass', + svg: '' + }, + { + name: 'chart-bar', + svg: '' + }, + { + name: 'image', + svg: '' + }, + { + name: 'key', + svg: '' + }, + { + name: 'shield-check', + svg: '' + }, + { + name: 'credit-card', + svg: '' + }, + { + name: 'document-text', + svg: '' + }, + { + name: 'pencil-square', + svg: '' + }, + { + name: 'server', + svg: '' + }, + { + name: 'building-office', + svg: '' + }, + { + name: 'home', + svg: '' + }, + { + name: 'star', + svg: '' + }, + { + name: 'tag', + svg: '' + }, + { + name: 'link', + svg: '' + }, + { + name: 'external-link', + svg: '' + }, + { + name: 'eye', + svg: '' + }, + { + name: 'bars-3', + svg: '' + }, + { + name: 'arrow-right', + svg: '' + } +] + +export function renderIconPicker(selectedIcon: string): string { + const safeSelected = escapeHtml(selectedIcon) + + const iconButtons = ICONS.map(({ name, svg }) => { + const isSelected = name === selectedIcon + const selectedClasses = isSelected + ? 'ring-2 ring-cyan-500 bg-cyan-50 dark:bg-cyan-900/30' + : 'ring-1 ring-zinc-200 dark:ring-zinc-700 hover:ring-cyan-300 dark:hover:ring-cyan-600 hover:bg-zinc-50 dark:hover:bg-zinc-800' + return ` + ` + }).join('\n') + + return ` +
+ + +
+

Choose an icon

+
+ ${iconButtons} +
+
+ +
+ + +

Paste raw SVG markup to use a custom icon.

+
+
+ + + ` +} diff --git a/packages/core/src/plugins/manifest-registry.ts b/packages/core/src/plugins/manifest-registry.ts index 0cfa52c5a..29b3148dc 100644 --- a/packages/core/src/plugins/manifest-registry.ts +++ b/packages/core/src/plugins/manifest-registry.ts @@ -458,6 +458,22 @@ export const PLUGIN_REGISTRY: Record = { "adminMenu": null }, + 'menu': { + "id": "menu", + "codeName": "menu", + "displayName": "Menu Manager", + "description": "Manages the admin sidebar navigation. Admins can reorder, rename, hide built-in items, and add custom links.", + "version": "1.0.0", + "author": "SonicJS Team", + "category": "admin", + "iconEmoji": "📋", + "is_core": true, + "permissions": [], + "dependencies": [], + "defaultSettings": {}, + "adminMenu": null + }, + 'multi-tenant': { "id": "multi-tenant", "codeName": "multi-tenant", diff --git a/packages/core/src/plugins/sdk/define-plugin.ts b/packages/core/src/plugins/sdk/define-plugin.ts index aedc222c7..1abf9e129 100644 --- a/packages/core/src/plugins/sdk/define-plugin.ts +++ b/packages/core/src/plugins/sdk/define-plugin.ts @@ -207,6 +207,17 @@ export interface DefinePluginInput Promise + render: (props: { plugin: any; settings: any; data?: any }) => string + } } /** @@ -241,6 +252,11 @@ export interface DefinedPlugin { menu?: PluginMenuEntry[] /** Schema-driven settings. Renders the admin settings form for this plugin. */ configSchema?: ConfigSchema + /** Custom settings-tab renderer. loadData runs server-side; render produces the tab HTML. */ + settingsTabContent?: { + loadData?: (db: any) => Promise + render: (props: { plugin: any; settings: any; data?: any }) => string + } /** Marker so tooling/tests can detect a v3-defined plugin. */ // eslint-disable-next-line @typescript-eslint/naming-convention -- intentional internal marker readonly __sonicV3: true @@ -362,6 +378,7 @@ export function definePlugin Promise + render: (props: { plugin: any; settings: any; data?: any }) => string + } } // ── Host context ───────────────────────────────────────────────────────────── diff --git a/packages/core/src/routes/admin-plugins.ts b/packages/core/src/routes/admin-plugins.ts index c34b0a25a..68f800034 100644 --- a/packages/core/src/routes/admin-plugins.ts +++ b/packages/core/src/routes/admin-plugins.ts @@ -12,6 +12,7 @@ import { parseFormDataToSettings, applySchemaDefaults, } from '../plugins/sdk/config-schema' +import { reconcileMenuFromPlugins } from '../plugins/core-plugins/menu-plugin/services/menu-reconcile' import type { Bindings, Variables } from '../app' const adminPluginRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>() @@ -62,14 +63,18 @@ adminPluginRoutes.get('/', async (c) => { // Continue with empty data } + // Filter out DB records whose IDs no longer exist in the registry (e.g. renamed plugins) + const registryIds = new Set(AVAILABLE_PLUGINS.map(p => p.id)) + const validInstalledPlugins = installedPlugins.filter(p => registryIds.has(p.id)) + // Get list of installed plugin IDs - const installedPluginIds = new Set(installedPlugins.map(p => p.id)) + const installedPluginIds = new Set(validInstalledPlugins.map(p => p.id)) // Find uninstalled plugins const uninstalledPlugins = AVAILABLE_PLUGINS.filter(p => !installedPluginIds.has(p.id)) // Map installed plugins to template format - const templatePlugins: Plugin[] = installedPlugins.map(p => ({ + const templatePlugins: Plugin[] = validInstalledPlugins.map(p => ({ id: p.id, name: p.name, displayName: p.display_name, @@ -111,7 +116,7 @@ adminPluginRoutes.get('/', async (c) => { // Update stats with uninstalled count stats.uninstalled = uninstalledPlugins.length - stats.total = installedPlugins.length + uninstalledPlugins.length + stats.total = validInstalledPlugins.length + uninstalledPlugins.length const pageData: PluginsListPageData = { plugins: allPlugins, @@ -240,6 +245,17 @@ adminPluginRoutes.get('/:id', async (c) => { user: item.userId || null })) + // Load plugin-specific settings tab data if the plugin definition declares loadData + const pluginDef = getPluginDefinition(pluginId) + let settingsTabData: any = undefined + if (pluginDef?.settingsTabContent?.loadData) { + try { + settingsTabData = await pluginDef.settingsTabContent.loadData(db) + } catch (e) { + console.error(`settingsTabContent.loadData failed for plugin "${pluginId}":`, e) + } + } + const pageData: PluginSettingsPageData = { plugin: templatePlugin, activity: templateActivity, @@ -247,7 +263,8 @@ adminPluginRoutes.get('/:id', async (c) => { name: user?.email || 'User', email: user?.email || '', role: user?.role || 'user' - } + }, + settingsTabData, } return c.html(renderPluginSettingsPage(pageData)) @@ -353,6 +370,7 @@ adminPluginRoutes.post('/:id/activate', async (c) => { const pluginService = new PluginService(db) await pluginService.activatePlugin(pluginId) + await reconcileMenuFromPlugins(db) return c.json({ success: true }) } catch (error) { @@ -376,6 +394,7 @@ adminPluginRoutes.post('/:id/deactivate', async (c) => { const pluginService = new PluginService(db) await pluginService.deactivatePlugin(pluginId) + await reconcileMenuFromPlugins(db) return c.json({ success: true }) } catch (error) { diff --git a/packages/core/src/services/menu-icons.ts b/packages/core/src/services/menu-icons.ts new file mode 100644 index 000000000..42ebfeb41 --- /dev/null +++ b/packages/core/src/services/menu-icons.ts @@ -0,0 +1,43 @@ +export const MENU_ICON_MAP: Readonly> = { + 'puzzle-piece': ``, + envelope: ``, + cog: ``, + chart: ``, + sparkles: ``, + bolt: ``, + document: ``, + lock: ``, + photo: ``, + 'magnifying-glass': ``, + 'chart-bar': ``, + image: ``, + palette: ``, + 'hand-raised': ``, + key: ``, + 'arrow-right': ``, + 'shield-check': ``, + 'credit-card': ``, + 'document-text': ``, + 'pencil-square': ``, + server: ``, + 'building-office': ``, + home: ``, + users: ``, + collection: ``, + 'bars-3': ``, + 'cog-6-tooth': ``, + star: ``, + tag: ``, + link: ``, + 'external-link': ``, + eye: ``, + 'eye-slash': ``, +} + +const FALLBACK = `` + +export function resolveIcon(nameOrSvg: string | undefined): string { + if (!nameOrSvg) return FALLBACK + if (nameOrSvg.startsWith('
+ ${allMenuItems .map((item) => { const isActive = @@ -776,6 +777,7 @@ function renderCatalystSidebar( ${pluginsSubItems}
+ diff --git a/packages/core/src/templates/pages/admin-plugin-settings.template.ts b/packages/core/src/templates/pages/admin-plugin-settings.template.ts index 32f4c056f..fdc822363 100644 --- a/packages/core/src/templates/pages/admin-plugin-settings.template.ts +++ b/packages/core/src/templates/pages/admin-plugin-settings.template.ts @@ -1,6 +1,7 @@ import { renderAdminLayout, AdminLayoutData } from '../layouts/admin-layout-v2.template' import { renderAuthSettingsForm } from '../components/auth-settings-form.template' import type { AuthSettings } from '../../services/auth-validation' +import { getPluginDefinition } from '../../services/plugin-definition-registry' /** * Escape HTML attribute values to prevent XSS @@ -56,15 +57,20 @@ export interface PluginSettingsPageData { email: string role: string } + /** Data returned by the plugin's settingsTabContent.loadData(), if defined. */ + settingsTabData?: any } export function renderPluginSettingsPage(data: PluginSettingsPageData): string { - const { plugin, activity = [], user } = data - // true when plugin has configurable settings or a custom renderer (which handles empty state itself) - const hasUserSettings = plugin.id in pluginSettingsComponents || + const { plugin, activity = [], user, settingsTabData } = data + // true only when the plugin has at least one user-configurable setting key + // (_-prefixed keys are internal metadata, not settings the user can edit) + const pluginDef = getPluginDefinition(plugin.id || plugin.name) + const hasUserSettings = pluginDef?.settingsTabContent != null || Object.keys(plugin.settings || {}).some(k => !k.startsWith('_')) const defaultTab = hasUserSettings ? 'settings' : 'info' + const pageContent = `
@@ -143,7 +149,7 @@ export function renderPluginSettingsPage(data: PluginSettingsPageData): string {
@@ -374,12 +380,18 @@ function renderToggleButton(plugin: any): string { : `` } -function renderSettingsTab(plugin: any): string { +function renderSettingsTab(plugin: any, settingsTabData?: any): string { const settings = plugin.settings || {} const pluginId = plugin.id || plugin.name const hasUserKeys = Object.keys(settings).some(k => !k.startsWith('_')) - // Check for custom settings component first + // Check plugin definition registry first (plugins self-register via definePlugin settingsTabContent) + const pluginDef = getPluginDefinition(pluginId) + if (pluginDef?.settingsTabContent) { + return pluginDef.settingsTabContent.render({ plugin, settings, data: settingsTabData }) + } + + // Legacy: hardcoded per-plugin renderers const customRenderer = pluginSettingsComponents[pluginId] if (customRenderer) { return ` @@ -768,7 +780,6 @@ type PluginSettingsRenderer = (plugin: any, settings: PluginSettings) => string const pluginSettingsComponents: Record = { 'otp-login': renderOTPLoginSettingsContent, 'email': renderEmailSettingsContent, - 'oauth-providers': renderOAuthProvidersSettingsContent, } /** @@ -1522,108 +1533,6 @@ defineUserProfile({ ` } -/** - * OAuth Providers plugin settings content - */ -function renderOAuthProvidersSettingsContent(_plugin: any, settings: PluginSettings): string { - const providers = (settings.providers as any) || {} - const github = providers.github || {} - const google = providers.google || {} - - const inputClass = 'w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 focus:border-blue-500 focus:outline-none text-white' - - function renderProviderCard(name: string, label: string, icon: string, data: any): string { - const clientId = escapeHtmlAttr(data.clientId || '') - const clientSecret = escapeHtmlAttr(data.clientSecret || '') - const enabled = data.enabled === true - - return ` -
-
-

- ${icon} ${label} -

- -
-
-
- - -
-
- - -
-
-
- ` - } - - return ` -
-
-

- Configure OAuth provider credentials below. Credentials are stored securely and used server-side only. - Make sure to add the callback URL /auth/oauth/[provider]/callback to your OAuth app's allowed redirect URIs. -

-
- - ${renderProviderCard('github', 'GitHub', '🐙', github)} - ${renderProviderCard('google', 'Google', '🔵', google)} -
- - - ` -} - /** * Check if a plugin has a custom settings component */ diff --git a/tests/e2e/82-menu-management.spec.ts b/tests/e2e/82-menu-management.spec.ts new file mode 100644 index 000000000..ab21e7dc2 --- /dev/null +++ b/tests/e2e/82-menu-management.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test' +import { loginAsAdmin, ensureAdminUserExists } from './utils/test-helpers' + +const BASE_URL = process.env.BASE_URL || 'http://localhost:8787' + +test.describe('Menu Management', () => { + test.beforeEach(async ({ page }) => { + await ensureAdminUserExists(page) + await loginAsAdmin(page) + }) + + test('admin menu page loads and shows seeded system items', async ({ page }) => { + const resp = await page.goto(`${BASE_URL}/admin/menu`) + await page.waitForLoadState('networkidle') + + expect(resp?.status()).not.toBe(404) + expect(resp?.status()).not.toBe(500) + + const body = (await page.locator('body').textContent()) || '' + // System items should be seeded and visible + expect(body).toContain('Content') + expect(body).toContain('Collections') + expect(body).toContain('Users') + expect(body).toContain('Settings') + }) + + test('can add a custom top-level link', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/menu/new`) + await page.waitForLoadState('networkidle') + + await page.fill('input[name="label"]', 'Resources') + await page.fill('input[name="url"]', 'https://docs.sonicjs.com') + + // Set target blank + await page.selectOption('select[name="target"]', '_blank') + + await page.click('button[type="submit"]') + await page.waitForLoadState('networkidle') + + // Should redirect back to list + expect(page.url()).toContain('/admin/menu') + const body = (await page.locator('body').textContent()) || '' + expect(body).toContain('Resources') + }) + + test('cannot delete a system menu item via API', async ({ page, request }) => { + // Get list to find a system item ID + await page.goto(`${BASE_URL}/admin/menu`) + await page.waitForLoadState('networkidle') + + // Try to delete a system item via API — should 403 + const sessionCookies = await page.context().cookies() + const cookieHeader = sessionCookies.map((c) => `${c.name}=${c.value}`).join('; ') + + // Get CSRF token from cookie + const csrfCookie = sessionCookies.find((c) => c.name === 'csrf_token') + const csrfToken = csrfCookie?.value || '' + + // Find a system item id from the page HTML + const html = await page.content() + const systemIdMatch = html.match(/\/admin\/menu\/([^"']+)\/move-up/) + if (systemIdMatch) { + const itemId = systemIdMatch[1] + const deleteResp = await request.delete(`${BASE_URL}/admin/menu/${itemId}`, { + headers: { + cookie: cookieHeader, + 'x-csrf-token': csrfToken, + }, + }) + // System items return 403 + expect([403, 302]).toContain(deleteResp.status()) + } + }) + + test('hide and show a menu item', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/menu`) + await page.waitForLoadState('networkidle') + + // Find the visibility toggle form for Settings + const settingsRow = page.locator('tr', { hasText: 'Settings' }).first() + const visibleCheckbox = settingsRow.locator('input[name="visible"]') + + if (await visibleCheckbox.count() > 0) { + // Toggle visibility + const toggleForm = settingsRow.locator('form').first() + await toggleForm.evaluate((f: HTMLFormElement) => f.submit()) + await page.waitForLoadState('networkidle') + + const body = (await page.locator('body').textContent()) || '' + expect(body).toContain('Visibility updated') + } + }) + + test('sidebar shows data-driven items after menu plugin seeds', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/content`) + await page.waitForLoadState('networkidle') + + const nav = page.locator('nav') + const navText = await nav.textContent() || '' + + // The data-driven sidebar should contain the seeded system items + expect(navText).toContain('Content') + }) + + test('add item form rejects empty label', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/menu/new`) + await page.waitForLoadState('networkidle') + + // Submit without label + await page.fill('input[name="url"]', '/some/path') + await page.click('button[type="submit"]') + await page.waitForLoadState('networkidle') + + const body = (await page.locator('body').textContent()) || '' + expect(body).toContain('Label is required') + }) +})