diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index e84b9a20..955f3049 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1050,5 +1050,52 @@ "backlinks.untitled": "Untitled", "wikilink.suggestion.no_results": "No matching pages", "wikilink.suggestion.type_to_search": "Type to search pages...", - "wikilink.broken": "Page not found or deleted" + "wikilink.broken": "Page not found or deleted", + "slash_commands.page_title": "Slash commands", + "slash_commands.create_button": "New command", + "slash_commands.create_title": "Create slash command", + "slash_commands.edit_title": "Edit slash command", + "slash_commands.col_keyword": "Keyword", + "slash_commands.col_label": "Label", + "slash_commands.col_action_type": "Action type", + "slash_commands.col_enabled": "Enabled", + "slash_commands.keyword_label": "Keyword", + "slash_commands.keyword_description": "Lowercase letters, numbers and hyphens only. Used as /keyword in the editor.", + "slash_commands.keyword_format_error": "Only lowercase letters, numbers and hyphens are allowed", + "slash_commands.label_label": "Label", + "slash_commands.label_required": "Label is required", + "slash_commands.description_label": "Description", + "slash_commands.description_placeholder": "Short description shown in the slash menu", + "slash_commands.icon_label": "Icon", + "slash_commands.icon_description": "Tabler icon name (e.g. IconNotes) or leave blank", + "slash_commands.action_type_label": "Action type", + "slash_commands.action_config_section": "Action configuration", + "slash_commands.enabled_label": "Enabled", + "slash_commands.template_label": "Template content", + "slash_commands.template_description": "Markdown text or Tiptap JSON to insert at cursor", + "slash_commands.rows_label": "Rows", + "slash_commands.cols_label": "Columns", + "slash_commands.header_row_label": "Include header row", + "slash_commands.url_label": "URL to embed", + "slash_commands.url_required": "A valid URL starting with http is required", + "slash_commands.webhook_url_label": "Webhook URL", + "slash_commands.webhook_https_required": "Webhook URL must start with https://", + "slash_commands.webhook_headers_label": "Additional headers (JSON)", + "slash_commands.webhook_headers_description": "Optional JSON object of extra HTTP headers to send", + "slash_commands.webhook_security_title": "Security note", + "slash_commands.webhook_security_note": "Never include secrets in stored headers. Use a secret-manager proxy in front of your webhook endpoint.", + "slash_commands.language_label": "Code language", + "slash_commands.language_required": "Language is required", + "slash_commands.snippet_code_label": "Starter code", + "slash_commands.snippet_code_description": "Optional starter code inserted with the snippet", + "slash_commands.enable_tooltip": "Enable this command", + "slash_commands.disable_tooltip": "Disable this command", + "slash_commands.delete_confirm": "Delete slash command \"{{label}}\"? This cannot be undone.", + "slash_commands.create_success": "Slash command created", + "slash_commands.update_success": "Slash command updated", + "slash_commands.delete_success": "Slash command deleted", + "slash_commands.load_error": "Could not load slash commands", + "slash_commands.empty_state": "No custom slash commands yet. Create one to get started.", + "slash_commands.access_denied_title": "Access denied", + "slash_commands.access_denied_description": "You need the slash_commands:manage permission to access this page." } \ No newline at end of file diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index eeff635b..9370a282 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -1005,5 +1005,52 @@ "backlinks.untitled": "Sans titre", "wikilink.suggestion.no_results": "Aucune page correspondante", "wikilink.suggestion.type_to_search": "Tapez pour rechercher des pages...", - "wikilink.broken": "Page introuvable ou supprimée" + "wikilink.broken": "Page introuvable ou supprimée", + "slash_commands.page_title": "Commandes slash", + "slash_commands.create_button": "Nouvelle commande", + "slash_commands.create_title": "Créer une commande slash", + "slash_commands.edit_title": "Modifier la commande slash", + "slash_commands.col_keyword": "Mot-clé", + "slash_commands.col_label": "Libellé", + "slash_commands.col_action_type": "Type d'action", + "slash_commands.col_enabled": "Activée", + "slash_commands.keyword_label": "Mot-clé", + "slash_commands.keyword_description": "Lettres minuscules, chiffres et tirets uniquement. Utilisé comme /mot-clé dans l'éditeur.", + "slash_commands.keyword_format_error": "Seuls les lettres minuscules, chiffres et tirets sont autorisés", + "slash_commands.label_label": "Libellé", + "slash_commands.label_required": "Le libellé est requis", + "slash_commands.description_label": "Description", + "slash_commands.description_placeholder": "Description courte affichée dans le menu slash", + "slash_commands.icon_label": "Icône", + "slash_commands.icon_description": "Nom d'icône Tabler (ex: IconNotes) ou laisser vide", + "slash_commands.action_type_label": "Type d'action", + "slash_commands.action_config_section": "Configuration de l'action", + "slash_commands.enabled_label": "Activée", + "slash_commands.template_label": "Contenu du template", + "slash_commands.template_description": "Texte Markdown ou JSON Tiptap à insérer au curseur", + "slash_commands.rows_label": "Lignes", + "slash_commands.cols_label": "Colonnes", + "slash_commands.header_row_label": "Inclure une ligne d'en-tête", + "slash_commands.url_label": "URL à intégrer", + "slash_commands.url_required": "Une URL valide commençant par http est requise", + "slash_commands.webhook_url_label": "URL du webhook", + "slash_commands.webhook_https_required": "L'URL du webhook doit commencer par https://", + "slash_commands.webhook_headers_label": "En-têtes supplémentaires (JSON)", + "slash_commands.webhook_headers_description": "Objet JSON optionnel d'en-têtes HTTP supplémentaires", + "slash_commands.webhook_security_title": "Note de sécurité", + "slash_commands.webhook_security_note": "Ne jamais inclure de secrets dans les en-têtes stockés. Utilisez un proxy gestionnaire de secrets devant votre endpoint webhook.", + "slash_commands.language_label": "Langage du code", + "slash_commands.language_required": "Le langage est requis", + "slash_commands.snippet_code_label": "Code de départ", + "slash_commands.snippet_code_description": "Code optionnel inséré avec le snippet", + "slash_commands.enable_tooltip": "Activer cette commande", + "slash_commands.disable_tooltip": "Désactiver cette commande", + "slash_commands.delete_confirm": "Supprimer la commande slash \"{{label}}\" ? Cette action est irréversible.", + "slash_commands.create_success": "Commande slash créée", + "slash_commands.update_success": "Commande slash mise à jour", + "slash_commands.delete_success": "Commande slash supprimée", + "slash_commands.load_error": "Impossible de charger les commandes slash", + "slash_commands.empty_state": "Aucune commande slash personnalisée pour l'instant. Créez-en une pour commencer.", + "slash_commands.access_denied_title": "Accès refusé", + "slash_commands.access_denied_description": "Vous avez besoin de la permission slash_commands:manage pour accéder à cette page." } \ No newline at end of file diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index f07a02c6..3fedbe39 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -48,6 +48,8 @@ import VerifyEmail from "@/ee/pages/verify-email.tsx"; import RolesListPage from "@/features/acadenice/rbac/pages/roles-list.page"; import RoleDetailPage from "@/features/acadenice/rbac/pages/role-detail.page"; import UserRolesPanelPage from "@/features/acadenice/rbac/pages/user-roles-panel"; +// Acadenice R3.3 — custom slash commands admin page +import SlashCommandsPage from "@/features/acadenice/slash-commands-admin/pages/slash-commands-page"; export default function App() { const { t } = useTranslation(); @@ -133,6 +135,8 @@ export default function App() { path={"users/:userId/roles"} element={} /> + {/* Acadenice R3.3 — custom slash commands admin */} + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 1f89b745..b9d9bf1a 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -16,6 +16,7 @@ import { IconHistory, IconShieldCheck, IconShieldLock, + IconSlash, } from "@tabler/icons-react"; import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions"; import { Link, useLocation } from "react-router-dom"; @@ -108,6 +109,13 @@ const groupedData: DataGroup[] = [ path: "/settings/roles", acadeniceCanManageRoles: true, }, + { + // Acadenice R3.3 — custom slash commands admin + label: "Slash commands", + icon: IconSlash, + path: "/settings/slash-commands", + acadeniceCanManageRoles: true, + }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, { diff --git a/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-command-list.test.tsx b/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-command-list.test.tsx new file mode 100644 index 00000000..04c52cfe --- /dev/null +++ b/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-command-list.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import React from "react"; +import { AllProviders } from "@/features/acadenice/rbac/__tests__/test-utils"; +import { SlashCommandList } from "../components/slash-command-list"; +import * as queries from "../queries/slash-commands-query"; +import * as rbacHook from "@/features/acadenice/rbac/hooks/use-acadenice-permissions"; + +vi.mock("../queries/slash-commands-query", () => ({ + useSlashCommandsQuery: vi.fn(), + useDeleteSlashCommandMutation: vi.fn(), + useToggleSlashCommandMutation: vi.fn(), + useCreateSlashCommandMutation: vi.fn(), + useUpdateSlashCommandMutation: vi.fn(), +})); + +vi.mock("@/features/acadenice/rbac/hooks/use-acadenice-permissions", () => ({ + useAcadenicePermissions: vi.fn(), +})); + +const mockPermissions = { + permissions: ["slash_commands:manage"], + hasPermission: (p: string) => p === "slash_commands:manage" || p === "admin:*", + canManageRoles: true, + isLoading: false, +}; + +const sampleCmd = { + id: "cmd-1", + workspaceId: "ws1", + keyword: "meeting-note", + label: "Meeting Note", + description: null, + icon: null, + actionType: "insert-template", + actionConfig: { template: "# Meeting\n\n" }, + isEnabled: true, + createdBy: "u1", + createdAt: "2026-05-08T00:00:00Z", + updatedAt: "2026-05-08T00:00:00Z", +}; + +const noopMutation = { + mutate: vi.fn(), + isPending: false, + variables: undefined, +}; + +function setup(overrides = {}) { + (rbacHook.useAcadenicePermissions as ReturnType).mockReturnValue(mockPermissions); + (queries.useDeleteSlashCommandMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useToggleSlashCommandMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useCreateSlashCommandMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useUpdateSlashCommandMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useSlashCommandsQuery as ReturnType).mockReturnValue({ + data: [sampleCmd], + isLoading: false, + error: null, + refetch: vi.fn(), + ...overrides, + }); +} + +describe("SlashCommandList", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the table with a command row", () => { + setup(); + render( + + + , + ); + expect(screen.getByText("/meeting-note")).toBeDefined(); + expect(screen.getByText("Meeting Note")).toBeDefined(); + }); + + it("shows loader while loading", () => { + setup({ isLoading: true, data: undefined }); + render( + + + , + ); + // Mantine Loader renders an SVG role="presentation" or an aria-busy element + const loader = document.querySelector("[data-testid], svg, [aria-busy]"); + expect(loader).toBeTruthy(); + }); + + it("shows error alert when query fails", () => { + setup({ isLoading: false, data: undefined, error: new Error("fail") }); + render( + + + , + ); + expect(screen.getByText(/error|fail/i)).toBeDefined(); + }); + + it("shows empty state when no commands", () => { + setup({ data: [] }); + render( + + + , + ); + // Empty state text should appear + const cell = document.querySelector("td"); + expect(cell).toBeTruthy(); + }); + + it("shows create button", () => { + setup(); + render( + + + , + ); + const btn = screen.getByRole("button", { name: /create|new|add/i }); + expect(btn).toBeDefined(); + }); +}); diff --git a/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-commands-page.test.tsx b/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-commands-page.test.tsx new file mode 100644 index 00000000..3053ae26 --- /dev/null +++ b/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-commands-page.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { AllProviders } from "@/features/acadenice/rbac/__tests__/test-utils"; +import SlashCommandsPage from "../pages/slash-commands-page"; +import * as rbacHook from "@/features/acadenice/rbac/hooks/use-acadenice-permissions"; +import * as queries from "../queries/slash-commands-query"; + +vi.mock("@/features/acadenice/rbac/hooks/use-acadenice-permissions", () => ({ + useAcadenicePermissions: vi.fn(), +})); + +vi.mock("../queries/slash-commands-query", () => ({ + useSlashCommandsQuery: vi.fn(), + useDeleteSlashCommandMutation: vi.fn(), + useToggleSlashCommandMutation: vi.fn(), + useCreateSlashCommandMutation: vi.fn(), + useUpdateSlashCommandMutation: vi.fn(), +})); + +vi.mock("@/lib/config.ts", () => ({ + getAppName: () => "DocAdenice", + isCloud: () => false, +})); + +const noopMutation = { mutate: vi.fn(), isPending: false, variables: undefined }; + +function setupMutations() { + (queries.useDeleteSlashCommandMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useToggleSlashCommandMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useCreateSlashCommandMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useUpdateSlashCommandMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useSlashCommandsQuery as ReturnType).mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: vi.fn(), + }); +} + +describe("SlashCommandsPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + setupMutations(); + }); + + it("renders the list when user has slash_commands:manage", () => { + (rbacHook.useAcadenicePermissions as ReturnType).mockReturnValue({ + permissions: ["slash_commands:manage"], + hasPermission: (p: string) => p === "slash_commands:manage", + canManageRoles: true, + isLoading: false, + }); + + render( + + + , + ); + + // The table should be rendered (even empty) + expect(document.querySelector("[data-testid='slash-commands-table']")).toBeTruthy(); + }); + + it("shows access denied when user lacks permission", () => { + (rbacHook.useAcadenicePermissions as ReturnType).mockReturnValue({ + permissions: [], + hasPermission: () => false, + canManageRoles: false, + isLoading: false, + }); + + render( + + + , + ); + + expect(screen.getByText(/access denied|permission/i)).toBeDefined(); + }); + + it("renders page title in document", () => { + (rbacHook.useAcadenicePermissions as ReturnType).mockReturnValue({ + permissions: ["admin:*"], + hasPermission: () => true, + canManageRoles: true, + isLoading: false, + }); + + render( + + + , + ); + + // SettingsTitle renders an h1 or heading + const heading = document.querySelector("h1, h2, [role='heading']"); + expect(heading).toBeTruthy(); + }); +}); diff --git a/apps/client/src/features/acadenice/slash-commands-admin/components/slash-command-form.tsx b/apps/client/src/features/acadenice/slash-commands-admin/components/slash-command-form.tsx new file mode 100644 index 00000000..26f4bf68 --- /dev/null +++ b/apps/client/src/features/acadenice/slash-commands-admin/components/slash-command-form.tsx @@ -0,0 +1,334 @@ +import React, { useEffect } from "react"; +import { + Modal, + TextInput, + Textarea, + Select, + NumberInput, + Button, + Stack, + Group, + Divider, + Switch, + Text, + Alert, + Code, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { useTranslation } from "react-i18next"; +import { SlashCommandDto, CreateSlashCommandPayload } from "../services/slash-commands-client"; + +const ACTION_TYPE_OPTIONS = [ + { value: "insert-template", label: "Insert Template" }, + { value: "insert-table", label: "Insert Table" }, + { value: "embed-url", label: "Embed URL" }, + { value: "run-webhook", label: "Run Webhook" }, + { value: "insert-snippet", label: "Insert Code Snippet" }, +]; + +interface Props { + opened: boolean; + onClose: () => void; + onSubmit: (payload: CreateSlashCommandPayload) => void; + initialValues?: SlashCommandDto | null; + isLoading?: boolean; +} + +interface FormValues { + keyword: string; + label: string; + description: string; + icon: string; + actionType: string; + isEnabled: boolean; + // insert-template + template: string; + // insert-table + rows: number; + cols: number; + withHeaderRow: boolean; + // embed-url + url: string; + // run-webhook + webhookUrl: string; + webhookHeaders: string; + // insert-snippet + language: string; + code: string; +} + +/** + * Polymorphic create/edit form for custom slash commands. + * + * The action_config section renders different fields depending on the selected + * actionType (discriminated union pattern). Validation is done client-side + * before submission; backend validates again with Zod. + */ +export function SlashCommandForm({ + opened, + onClose, + onSubmit, + initialValues, + isLoading, +}: Props) { + const { t } = useTranslation(); + + const form = useForm({ + initialValues: { + keyword: "", + label: "", + description: "", + icon: "", + actionType: "insert-template", + isEnabled: true, + template: "# Title\n\n", + rows: 3, + cols: 3, + withHeaderRow: true, + url: "", + webhookUrl: "", + webhookHeaders: "", + language: "typescript", + code: "", + }, + validate: { + keyword: (v) => + /^[a-z0-9-]+$/.test(v) + ? null + : t("slash_commands.keyword_format_error"), + label: (v) => (v.trim().length > 0 ? null : t("slash_commands.label_required")), + url: (v, values) => + values.actionType === "embed-url" && !v.startsWith("http") + ? t("slash_commands.url_required") + : null, + webhookUrl: (v, values) => + values.actionType === "run-webhook" && !v.startsWith("https://") + ? t("slash_commands.webhook_https_required") + : null, + language: (v, values) => + values.actionType === "insert-snippet" && !v.trim() + ? t("slash_commands.language_required") + : null, + }, + }); + + // Populate form when editing an existing command + useEffect(() => { + if (!initialValues) { + form.reset(); + return; + } + const cfg = initialValues.actionConfig as Record; + form.setValues({ + keyword: initialValues.keyword, + label: initialValues.label, + description: initialValues.description ?? "", + icon: initialValues.icon ?? "", + actionType: initialValues.actionType, + isEnabled: initialValues.isEnabled, + template: + typeof cfg["template"] === "string" + ? cfg["template"] + : JSON.stringify(cfg["template"], null, 2), + rows: (cfg["rows"] as number) ?? 3, + cols: (cfg["cols"] as number) ?? 3, + withHeaderRow: (cfg["withHeaderRow"] as boolean) ?? true, + url: (cfg["url"] as string) ?? "", + webhookUrl: (cfg["webhookUrl"] as string) ?? "", + webhookHeaders: cfg["headers"] + ? JSON.stringify(cfg["headers"], null, 2) + : "", + language: (cfg["language"] as string) ?? "typescript", + code: (cfg["code"] as string) ?? "", + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValues?.id]); + + function buildActionConfig(values: FormValues): Record { + switch (values.actionType) { + case "insert-template": + return { template: values.template }; + case "insert-table": + return { + rows: values.rows, + cols: values.cols, + withHeaderRow: values.withHeaderRow, + }; + case "embed-url": + return { url: values.url }; + case "run-webhook": { + let headers: Record | undefined; + if (values.webhookHeaders.trim()) { + try { + headers = JSON.parse(values.webhookHeaders) as Record; + } catch { + // Headers JSON is invalid — backend Zod will reject cleanly + } + } + return { webhookUrl: values.webhookUrl, ...(headers ? { headers } : {}) }; + } + case "insert-snippet": + return { language: values.language, code: values.code }; + default: + return {}; + } + } + + function handleSubmit(values: FormValues) { + onSubmit({ + keyword: values.keyword, + label: values.label, + description: values.description || undefined, + icon: values.icon || undefined, + actionType: values.actionType as CreateSlashCommandPayload["actionType"], + actionConfig: buildActionConfig(values), + isEnabled: values.isEnabled, + }); + } + + const actionType = form.values.actionType; + + return ( + +
+ + + +