From 5a78fd1fa7f20a8434839e39b2cb018cc9752080 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:11:41 +0100 Subject: [PATCH] Add Wiki settings for the wiki's owner A wiki's owner gets a Wiki settings item in the source's menu. It opens a dialog with one switch, "Let API, widget and public-link users edit this wiki", which saves at once and flips back if the server refuses. Editors and viewers don't get the item; anyone without manage_settings who reaches the dialog sees the switch read-only. --- frontend/src/api/endpoints.ts | 1 + frontend/src/api/services/userService.ts | 11 ++ frontend/src/locale/de.json | 12 +- frontend/src/locale/en.json | 12 +- frontend/src/locale/es.json | 12 +- frontend/src/locale/jp.json | 10 ++ frontend/src/locale/ru.json | 12 +- frontend/src/locale/zh-TW.json | 10 ++ frontend/src/locale/zh.json | 10 ++ frontend/src/settings/Sources.test.tsx | 40 +++++ frontend/src/settings/Sources.tsx | 22 +++ .../src/settings/WikiSettingsModal.test.tsx | 124 +++++++++++++++ frontend/src/settings/WikiSettingsModal.tsx | 150 ++++++++++++++++++ 13 files changed, 422 insertions(+), 4 deletions(-) create mode 100644 frontend/src/settings/WikiSettingsModal.test.tsx create mode 100644 frontend/src/settings/WikiSettingsModal.tsx diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index f448cf7c..5eae77de 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -88,6 +88,7 @@ const endpoints = { WIKI_PAGES: (id: string) => `/api/sources/${id}/wiki/pages`, WIKI_PAGE: (id: string, path: string) => `/api/sources/${id}/wiki/page?path=${encodeURIComponent(path)}`, + WIKI_SETTINGS: (id: string) => `/api/sources/${id}/wiki/settings`, GET_AVAILABLE_TOOLS: '/api/available_tools', GET_USER_TOOLS: '/api/get_tools', CREATE_TOOL: '/api/create_tool', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index 54a1e9fa..cb075a7b 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -186,6 +186,17 @@ const userService = { token: string | null, ): Promise => apiClient.put(endpoints.USER.WIKI_PAGE(sourceId, data.path), data, token), + getWikiSettings: ( + sourceId: string, + token: string | null, + ): Promise => + apiClient.get(endpoints.USER.WIKI_SETTINGS(sourceId), token), + updateWikiSettings: ( + sourceId: string, + data: { allow_outside_edits: boolean }, + token: string | null, + ): Promise => + apiClient.put(endpoints.USER.WIKI_SETTINGS(sourceId), data, token), getAvailableTools: (token: string | null): Promise => apiClient.get(endpoints.USER.GET_AVAILABLE_TOOLS, token), getUserTools: (token: string | null): Promise => diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 7414336a..e0183ee0 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -179,7 +179,17 @@ "pagesTitle": "Seiten", "loadFailed": "Die Wiki-Seiten konnten nicht geladen werden.", "pageLoadFailed": "Diese Seite konnte nicht geladen werden.", - "editTitle": "Seite bearbeiten" + "editTitle": "Seite bearbeiten", + "settings": { + "action": "Wiki-Einstellungen", + "title": "Wiki-Einstellungen", + "outsideEdits": { + "label": "API-, Widget- und Public-Link-Nutzern erlauben, dieses Wiki zu bearbeiten", + "description": "Wenn das aus ist, können Personen, die einen Agenten über seinen API-Schlüssel, sein Widget oder seinen öffentlichen Link nutzen, dieses Wiki lesen, aber nicht ändern. Du und die Bearbeiter des Wikis können es immer bearbeiten." + }, + "loadError": "Die Wiki-Einstellungen konnten nicht geladen werden.", + "saveError": "Die Änderung konnte nicht gespeichert werden. Bitte versuche es erneut." + } }, "graphrag": { "badge": "GraphRAG", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index b7ca6a5f..29b15bb0 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -184,7 +184,17 @@ "pagesTitle": "Pages", "loadFailed": "Could not load the wiki pages.", "pageLoadFailed": "Could not load this page.", - "editTitle": "Edit page" + "editTitle": "Edit page", + "settings": { + "action": "Wiki settings", + "title": "Wiki settings", + "outsideEdits": { + "label": "Let API, widget and public-link users edit this wiki", + "description": "When this is off, people who use an agent through its API key, widget or public link can read this wiki but not change it. You and the wiki's editors can always edit it." + }, + "loadError": "Could not load the wiki settings.", + "saveError": "Could not save the change. Please try again." + } }, "graphrag": { "badge": "GraphRAG", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 81e20042..1218f07d 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -179,7 +179,17 @@ "pagesTitle": "Páginas", "loadFailed": "No se pudieron cargar las páginas del wiki.", "pageLoadFailed": "No se pudo cargar esta página.", - "editTitle": "Editar página" + "editTitle": "Editar página", + "settings": { + "action": "Configuración del wiki", + "title": "Configuración del wiki", + "outsideEdits": { + "label": "Permitir que los usuarios de la API, del widget y del enlace público editen este wiki", + "description": "Si está desactivado, quienes usan un agente mediante su clave de API, su widget o su enlace público pueden leer este wiki, pero no cambiarlo. Tú y los editores del wiki siempre pueden editarlo." + }, + "loadError": "No se pudo cargar la configuración del wiki.", + "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo." + } }, "graphrag": { "badge": "GraphRAG", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 29f6aeae..0d5b8cce 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -179,6 +179,16 @@ "loadFailed": "Wikiページを読み込めませんでした。", "pageLoadFailed": "このページを読み込めませんでした。", "editTitle": "ページを編集", + "settings": { + "action": "Wiki設定", + "title": "Wiki設定", + "outsideEdits": { + "label": "API、ウィジェット、公開リンクの利用者にこのWikiの編集を許可", + "description": "オフの場合、APIキー、ウィジェット、公開リンクからエージェントを利用する人はこのWikiを読めますが、変更はできません。あなたとWikiの編集者はいつでも編集できます。" + }, + "loadError": "Wiki設定を読み込めませんでした。", + "saveError": "変更を保存できませんでした。もう一度お試しください。" + }, "byline_one": "{{pages}} ページ · {{tokens}} トークン" }, "graphrag": { diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index d666d4f8..1a380983 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -185,7 +185,17 @@ "pagesTitle": "Страницы", "loadFailed": "Не удалось загрузить страницы вики.", "pageLoadFailed": "Не удалось загрузить эту страницу.", - "editTitle": "Редактировать страницу" + "editTitle": "Редактировать страницу", + "settings": { + "action": "Настройки вики", + "title": "Настройки вики", + "outsideEdits": { + "label": "Разрешить пользователям API, виджета и публичной ссылки редактировать эту вики", + "description": "Если выключено, те, кто пользуется агентом через его API-ключ, виджет или публичную ссылку, могут читать эту вики, но не изменять её. Вы и редакторы вики всегда могут её редактировать." + }, + "loadError": "Не удалось загрузить настройки вики.", + "saveError": "Не удалось сохранить изменение. Попробуйте ещё раз." + } }, "graphrag": { "badge": "GraphRAG", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 8c49ddba..55ebd622 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -179,6 +179,16 @@ "loadFailed": "無法載入 Wiki 頁面。", "pageLoadFailed": "無法載入此頁面。", "editTitle": "編輯頁面", + "settings": { + "action": "Wiki 設定", + "title": "Wiki 設定", + "outsideEdits": { + "label": "允許 API、小工具和公開連結使用者編輯此 Wiki", + "description": "關閉時,透過代理的 API 金鑰、小工具或公開連結使用它的人可以閱讀此 Wiki,但無法變更。你和此 Wiki 的編輯者隨時都能編輯它。" + }, + "loadError": "無法載入 Wiki 設定。", + "saveError": "無法儲存變更。請再試一次。" + }, "byline_one": "{{pages}} 個頁面 · {{tokens}} 個 token" }, "graphrag": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 11b22af5..b4e230bf 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -179,6 +179,16 @@ "loadFailed": "无法加载 Wiki 页面。", "pageLoadFailed": "无法加载此页面。", "editTitle": "编辑页面", + "settings": { + "action": "Wiki 设置", + "title": "Wiki 设置", + "outsideEdits": { + "label": "允许 API、小组件和公开链接用户编辑此 Wiki", + "description": "关闭时,通过智能体的 API 密钥、小组件或公开链接使用它的人可以阅读此 Wiki,但不能更改。你和此 Wiki 的编辑者始终可以编辑它。" + }, + "loadError": "无法加载 Wiki 设置。", + "saveError": "无法保存更改。请重试。" + }, "byline_one": "{{pages}} 个页面 · {{tokens}} 个 token" }, "graphrag": { diff --git a/frontend/src/settings/Sources.test.tsx b/frontend/src/settings/Sources.test.tsx index 582020c1..fb4a771b 100644 --- a/frontend/src/settings/Sources.test.tsx +++ b/frontend/src/settings/Sources.test.tsx @@ -62,6 +62,11 @@ vi.mock('../components/graph/GraphSourceView', () => ({ vi.mock('./SourceConfigModal', () => ({ default: () => null })); vi.mock('./TestRetrievalModal', () => ({ default: () => null })); vi.mock('./ConvertToWikiModal', () => ({ default: () => null })); +vi.mock('./WikiSettingsModal', () => ({ + default: ({ document }: { document: { name: string } }) => ( +
{document.name}
+ ), +})); vi.mock('./EnableGraphRAGModal', () => ({ default: () => null })); vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null })); vi.mock('../upload/Upload', () => ({ default: () => null })); @@ -231,6 +236,41 @@ describe('Sources access', () => { ]); }); + it('a wiki owner gets Wiki settings; editors and viewers do not', async () => { + const wiki = { type: 'wiki', config: { kind: 'wiki' } }; + await render( + doc({ ...wiki, access: 'owner', allowed_actions: [...OWNER, 'use'] }), + ); + expect(await menuItems()).toContain( + 'settings.sources.wiki.settings.action', + ); + await clickItem('settings.sources.wiki.settings.action'); + expect( + document.querySelector('[data-testid="wiki-settings"]')?.textContent, + ).toBe('Contracts'); + + for (const allowed of [EDITOR, VIEWER]) { + await act(async () => root.unmount()); + root = createRoot(container); + document.body + .querySelectorAll('[role="menu"]') + .forEach((m) => m.remove()); + await render( + doc({ ...wiki, access: 'editor', allowed_actions: allowed }), + ); + expect(await menuItems()).not.toContain( + 'settings.sources.wiki.settings.action', + ); + } + }); + + it('a classic source has no Wiki settings', async () => { + await render(doc({ access: 'owner', allowed_actions: [...OWNER, 'use'] })); + expect(await menuItems()).not.toContain( + 'settings.sources.wiki.settings.action', + ); + }); + it('sync and reingest are editor actions', async () => { const synced = { syncFrequency: 'daily', ingestStatus: 'failed' as const }; await render(doc({ ...synced, access: 'editor', allowed_actions: EDITOR })); diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx index 09dd44a3..a5486e9b 100644 --- a/frontend/src/settings/Sources.tsx +++ b/frontend/src/settings/Sources.tsx @@ -73,6 +73,7 @@ import EnableGraphRAGModal from './EnableGraphRAGModal'; import { clearGraphBuild, selectGraphBuilds } from './graphBuildSlice'; import SourceConfigModal from './SourceConfigModal'; import TestRetrievalModal from './TestRetrievalModal'; +import WikiSettingsModal from './WikiSettingsModal'; const formatTokens = (tokens: number): string => { const roundToTwoDecimals = (num: number): string => { @@ -129,6 +130,8 @@ export default function Sources({ ]; const [documentToView, setDocumentToView] = useState(); const [documentToShare, setDocumentToShare] = useState(null); + const [documentForWikiSettings, setDocumentForWikiSettings] = + useState(null); const [documentToConfigure, setDocumentToConfigure] = useState( null, ); @@ -438,6 +441,18 @@ export default function Sources({ }); } + // A wiki's own settings are the owner's (manage_settings). + if (document.id && isWiki && can(document, 'manage_settings')) { + actions.push({ + icon: SlidersHorizontal, + label: t('settings.sources.wiki.settings.action'), + onClick: () => { + setDocumentForWikiSettings(document); + }, + variant: 'default', + }); + } + if (document.id) { actions.push({ icon: Search, @@ -930,6 +945,13 @@ export default function Sources({ availableModels={availableModels} /> + {documentForWikiSettings && ( + setDocumentForWikiSettings(null)} + /> + )} + { diff --git a/frontend/src/settings/WikiSettingsModal.test.tsx b/frontend/src/settings/WikiSettingsModal.test.tsx new file mode 100644 index 00000000..01263b8b --- /dev/null +++ b/frontend/src/settings/WikiSettingsModal.test.tsx @@ -0,0 +1,124 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +const { service } = vi.hoisted(() => ({ + service: { getWikiSettings: vi.fn(), updateWikiSettings: vi.fn() }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('react-redux', () => ({ + useSelector: () => 'token', +})); + +vi.mock('../api/services/userService', () => ({ default: service })); + +import type { Doc } from '../models/misc'; +import WikiSettingsModal from './WikiSettingsModal'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const wiki: Doc = { + id: 'wiki-1', + name: 'Handbook', + date: '', + model: '', + type: 'wiki', + config: { kind: 'wiki' }, +}; + +const reply = (status: number, body: unknown) => + Promise.resolve({ ok: status < 400, status, json: async () => body }); + +const OWNER = ['use', 'edit', 'manage_settings']; + +describe('WikiSettingsModal', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + Object.values(service).forEach((fn) => fn.mockReset()); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + document.body.innerHTML = ''; + }); + + const render = async () => { + await act(async () => { + root.render(); + }); + }; + + const theSwitch = () => + document.body.querySelector('[role="switch"]'); + + it('shows the stored value, off by default', async () => { + service.getWikiSettings.mockReturnValue( + reply(200, { allow_outside_edits: false, allowed_actions: OWNER }), + ); + await render(); + expect(service.getWikiSettings).toHaveBeenCalledWith('wiki-1', 'token'); + expect(document.body.textContent).toContain( + 'settings.sources.wiki.settings.outsideEdits.label', + ); + expect(theSwitch()?.getAttribute('aria-checked')).toBe('false'); + expect(theSwitch()?.disabled).toBe(false); + }); + + it('the owner turns it on', async () => { + service.getWikiSettings.mockReturnValue( + reply(200, { allow_outside_edits: false, allowed_actions: OWNER }), + ); + service.updateWikiSettings.mockReturnValue( + reply(200, { allow_outside_edits: true, allowed_actions: OWNER }), + ); + await render(); + await act(async () => theSwitch()!.click()); + expect(service.updateWikiSettings).toHaveBeenCalledWith( + 'wiki-1', + { allow_outside_edits: true }, + 'token', + ); + expect(theSwitch()?.getAttribute('aria-checked')).toBe('true'); + }); + + it('flips back and says so when the save fails', async () => { + service.getWikiSettings.mockReturnValue( + reply(200, { allow_outside_edits: false, allowed_actions: OWNER }), + ); + service.updateWikiSettings.mockReturnValue(reply(403, { success: false })); + await render(); + await act(async () => theSwitch()!.click()); + expect(theSwitch()?.getAttribute('aria-checked')).toBe('false'); + expect(document.body.textContent).toContain( + 'settings.sources.wiki.settings.saveError', + ); + }); + + it('is read-only without manage_settings', async () => { + service.getWikiSettings.mockReturnValue( + reply(200, { allow_outside_edits: true, allowed_actions: ['use'] }), + ); + await render(); + expect(theSwitch()?.getAttribute('aria-checked')).toBe('true'); + expect(theSwitch()?.disabled).toBe(true); + expect(document.body.textContent).toContain('common.viewOnlyNotice'); + }); + + it('says when the settings could not load', async () => { + service.getWikiSettings.mockReturnValue(reply(500, { success: false })); + await render(); + expect(theSwitch()).toBeNull(); + expect(document.body.textContent).toContain( + 'settings.sources.wiki.settings.loadError', + ); + }); +}); diff --git a/frontend/src/settings/WikiSettingsModal.tsx b/frontend/src/settings/WikiSettingsModal.tsx new file mode 100644 index 00000000..f11f7db9 --- /dev/null +++ b/frontend/src/settings/WikiSettingsModal.tsx @@ -0,0 +1,150 @@ +import { CircleAlert } from 'lucide-react'; +import { useEffect, useId, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import userService from '../api/services/userService'; +import ViewOnlyNotice from '../components/ViewOnlyNotice'; +import { Alert, AlertDescription } from '../components/ui/alert'; +import { LoadingState } from '../components/ui/loading-state'; +import { Modal } from '../components/ui/modal'; +import { SettingRow, SettingRows } from '../components/ui/setting-row'; +import { Switch } from '../components/ui/switch'; +import { Doc } from '../models/misc'; +import { selectToken } from '../preferences/preferenceSlice'; +import { can } from '../utils/accessUtils'; + +type WikiSettings = { + allow_outside_edits: boolean; + allowed_actions?: string[]; +}; + +interface WikiSettingsModalProps { + document: Doc; + onClose: () => void; +} + +/** + * A wiki's owner settings. Today one switch: whether people using an agent + * through its API key, widget or public link may edit the wiki. It saves at + * once and flips back when the server refuses. + */ +export default function WikiSettingsModal({ + document, + onClose, +}: WikiSettingsModalProps) { + const { t } = useTranslation(); + const token = useSelector(selectToken); + const switchId = useId(); + + const [settings, setSettings] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [saving, setSaving] = useState(false); + const [saveFailed, setSaveFailed] = useState(false); + + const sourceId = document.id ?? ''; + // Read through a ref: a token refresh is the same user and must not reload. + const tokenRef = useRef(token); + tokenRef.current = token; + + useEffect(() => { + let cancelled = false; + setSettings(null); + setLoadFailed(false); + userService + .getWikiSettings(sourceId, tokenRef.current) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = (await response.json()) as WikiSettings; + if (!cancelled) setSettings(data); + }) + .catch(() => { + if (!cancelled) setLoadFailed(true); + }); + return () => { + cancelled = true; + }; + }, [sourceId]); + + const canManage = can(settings, 'manage_settings'); + + const toggle = async (value: boolean) => { + if (!settings) return; + const previous = settings; + setSettings({ ...previous, allow_outside_edits: value }); + setSaving(true); + setSaveFailed(false); + try { + const response = await userService.updateWikiSettings( + sourceId, + { allow_outside_edits: value }, + token, + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = (await response.json()) as WikiSettings; + setSettings(data); + } catch { + setSettings(previous); + setSaveFailed(true); + } finally { + setSaving(false); + } + }; + + const body = () => { + if (loadFailed) { + return ( + + + + {t('settings.sources.wiki.settings.loadError')} + + + ); + } + if (!settings) return ; + return ( +
+ {!canManage && } + {saveFailed && ( + + + + {t('settings.sources.wiki.settings.saveError')} + + + )} + + + + + +
+ ); + }; + + return ( + { + if (!open) onClose(); + }} + title={t('settings.sources.wiki.settings.title')} + description={document.name} + > + {body()} + + ); +}