From a03941e558efb31a2e6af50c21a934f5dcd73260 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 04:45:19 +0000 Subject: [PATCH] feat(org): Phase 3 in-host Organization Settings org-ui slice Add /organization/settings as org-module settings only: catalog toggles (reuse GET/PUT /api/org-module/modules) and org-local prefs ({FREEOS_HOME}/org-os/prefs.json). Link out to FreeOS system settings for LLM keys, users, and timezone. No sidecar and no system-settings duplication. --- CHANGELOG.md | 1 + dashboard/src/locales/en.json | 6 +- dashboard/src/locales/zh.json | 6 +- dashboard/src/org-ui/api/createClient.test.ts | 21 ++ dashboard/src/org-ui/api/createClient.ts | 79 ++++++ dashboard/src/org-ui/contract.ts | 1 + dashboard/src/org-ui/index.ts | 8 + .../pages/settings/SettingsPage.module.css | 152 +++++++++++ .../pages/settings/SettingsPage.test.tsx | 90 +++++++ .../org-ui/pages/settings/SettingsPage.tsx | 250 ++++++++++++++++++ dashboard/src/org-ui/pages/settings/labels.ts | 73 +++++ .../src/pages/Organization/index.test.tsx | 11 + dashboard/src/pages/Organization/index.tsx | 17 ++ .../src/pages/Organization/orgPathTabs.tsx | 13 +- .../src/pages/Organization/settings/index.tsx | 46 ++++ dashboard/src/routes/controlAdminPath.test.ts | 2 + dashboard/src/routes/index.tsx | 8 + docs/architecture-integration.md | 2 +- docs/org-merge-plan.md | 44 ++- scripts/org-export/README.md | 7 +- src/octop/api/routers/org_module.py | 2 + src/octop/api/routers/org_settings.py | 101 +++++++ src/octop/i18n/en.json | 4 + src/octop/i18n/zh.json | 4 + src/octop/modules/org_os/contract.py | 1 + src/octop/modules/org_os/export_standalone.py | 32 ++- src/octop/modules/org_os/prefs.py | 58 ++++ tests/unit/cli/test_org_export_standalone.py | 10 + tests/unit/test_org_settings.py | 109 ++++++++ 29 files changed, 1144 insertions(+), 14 deletions(-) create mode 100644 dashboard/src/org-ui/pages/settings/SettingsPage.module.css create mode 100644 dashboard/src/org-ui/pages/settings/SettingsPage.test.tsx create mode 100644 dashboard/src/org-ui/pages/settings/SettingsPage.tsx create mode 100644 dashboard/src/org-ui/pages/settings/labels.ts create mode 100644 dashboard/src/pages/Organization/settings/index.tsx create mode 100644 src/octop/api/routers/org_settings.py create mode 100644 src/octop/modules/org_os/prefs.py create mode 100644 tests/unit/test_org_settings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 30a86093..1b5b4c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 新增 +- Organization Phase 3(设置切片):宿主内 Organization Settings。Dashboard `/organization/settings` 使用共享 `dashboard/src/org-ui` 的 `SettingsPage`。本页只编辑 `org_os` 目录模块开关(复用 `GET/PUT /api/org-module/modules`)与组织本地偏好(`GET /api/org-module/settings` · `PUT /api/org-module/prefs`,`{FREEOS_HOME}/org-os/prefs.json`)。**不是** FreeOS 系统设置:大模型密钥、用户、时区仍链到 `/system-settings`。没有边车,也不复制 sidecar 的 AI/用户/数据库页。`freeos org export-standalone` 同时列出 Settings。未迁:Agents、Chat、Workspace、边车系统设置。 - Organization Phase 3(反思切片):宿主内 Reflections。Dashboard `/organization/reflections` 使用共享 `dashboard/src/org-ui` 的 `ReflectionsPage`。列表/创建/删除走 `/api/org-module/reflections*`,SQLite 在 `{FREEOS_HOME}/org/reflections.sqlite`。可选 `employee_id` / `task_id` 指向同一租户的目录员工与组织任务。这是组织复盘,不是 Chat,也不是第二套技能运行时。`freeos org export-standalone` 同时列出 Reflections。未迁:Chat、边车技能统计、边车部署铁律样例、其余 Open-12。 - Organization Phase 3(任务切片):宿主内 Tasks。Dashboard `/organization/tasks` 与 `/organization/tasks/:id` 使用共享 `dashboard/src/org-ui` 的 `TasksPage` / `TaskDetailPage`。CRUD、状态流转、子任务与人工评论走 `/api/org-module/tasks*`,SQLite 在 `{FREEOS_HOME}/org/tasks.sqlite`。这是组织待办,不是 Octop cron,也不是项目/Chat 对话。`freeos org export-standalone` 同时列出 Tasks。未迁:Chat、附件上传、边车任务库、其余 Open-12。 - Organization Phase 3(知识切片):宿主内 Knowledge。Dashboard `/organization/knowledge` 使用共享 `dashboard/src/org-ui` 的 `KnowledgePage`。列表/详情/笔记走 `/api/org-module/knowledge*`,包装已有 `KnowledgeService`(Octop `knowledge_bases` / `knowledge_documents`),与 Chat 检索同一套行。不克隆 openXYOS sidecar notes/files DB。上传、文件夹、嵌入模型仍在 `/knowledge-bases`。`freeos org export-standalone` 同时列出 Knowledge。未迁:Chat、边车知识库、其余 Open-12。 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index f672ec99..9a0ab534 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -190,6 +190,7 @@ "navKnowledge": "Knowledge", "navTasks": "Tasks", "navReflections": "Reflections", + "navSettings": "Settings", "announcementsTitle": "Announcements", "announcementsBody": "Publish notices and track who has read them. This page lives in the host — no sidecar or second port.", "openAnnouncements": "Open announcements", @@ -213,7 +214,10 @@ "openTasks": "Open tasks", "reflectionsTitle": "Reflections", "reflectionsBody": "Capture lessons and reusable experience on the host org store (FREEOS_HOME/org). Not a sidecar and not Chat.", - "openReflections": "Open reflections" + "openReflections": "Open reflections", + "settingsTitle": "Organization settings", + "settingsBody": "Org-module catalog toggles and local preferences. LLM keys, users, and timezone stay on FreeOS system settings — no sidecar.", + "openSettings": "Open settings" }, "systemSettings": { "subtitle": "Workbench, remote desktop, and host administration in one place." diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index b55ce0b8..b5d43cfe 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -190,6 +190,7 @@ "navKnowledge": "知识", "navTasks": "任务", "navReflections": "反思", + "navSettings": "设置", "announcementsTitle": "通知公告", "announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。", "openAnnouncements": "打开通知公告", @@ -213,7 +214,10 @@ "openTasks": "打开任务", "reflectionsTitle": "反思引擎", "reflectionsBody": "在宿主组织库里整理复盘与经验(FREEOS_HOME/org)。不是边车,也不是对话。", - "openReflections": "打开反思" + "openReflections": "打开反思", + "settingsTitle": "组织设置", + "settingsBody": "组织模块的目录开关与本地偏好。大模型密钥、用户和时区仍在 FreeOS 系统设置,没有边车。", + "openSettings": "打开设置" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts index ab78ca34..a6217b69 100644 --- a/dashboard/src/org-ui/api/createClient.test.ts +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -81,6 +81,22 @@ describe("createOrgApiClient", () => { ], }; } + if (path === "/org-module/settings") { + return { + success: true, + data: { + scope: "org_module", + catalog: [{ key: "settings", locked: true }], + modules: { settings: true, chat: false }, + prefs: { name: "Acme", description: "" }, + system_settings: { overview: "/system-settings" }, + not_on_this_page: ["llm_keys"], + }, + }; + } + if (path === "/org-module/modules") { + return { updates: { settings: true, chat: false } }; + } throw new Error(`unexpected ${path}`); }); const client = createOrgApiClient({ fetchJson }); @@ -101,6 +117,11 @@ describe("createOrgApiClient", () => { expect((await client.reflections.list())[0]?.failure_reasons).toBe( "const dead zone", ); + expect((await client.settings.snapshot()).prefs.name).toBe("Acme"); + expect(await client.settings.saveModules({ chat: false })).toEqual({ + settings: true, + chat: false, + }); expect(fetchJson).toHaveBeenCalled(); }); diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index 5f626b4d..869ee673 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -501,6 +501,48 @@ export interface OrgReflectionsClient { remove(id: number): Promise; } +export interface OrgCapability { + key: string; + label: string; + label_zh: string; + description: string; + description_zh: string; + locked: boolean; +} + +export interface OrgPrefs { + name: string; + description: string; +} + +export interface OrgSystemSettingsLinks { + overview: string; + models: string; + users: string; +} + +export interface OrgSettingsSnapshot { + scope: string; + catalog: OrgCapability[]; + modules: Record; + prefs: OrgPrefs; + system_settings: OrgSystemSettingsLinks; + not_on_this_page: string[]; +} + +export interface OrgPrefsWrite { + name?: string; + description?: string; +} + +export interface OrgSettingsClient { + snapshot(): Promise; + savePrefs(body: OrgPrefsWrite): Promise; + saveModules( + updates: Record, + ): Promise>; +} + export interface OrgApiClient { announcements: OrgAnnouncementsClient; org: OrgChartClient; @@ -510,6 +552,7 @@ export interface OrgApiClient { knowledge: OrgKnowledgeClient; tasks: OrgTasksClient; reflections: OrgReflectionsClient; + settings: OrgSettingsClient; } async function unwrap(payload: OrgEnvelope): Promise { @@ -532,6 +575,7 @@ export function createOrgApiClient(opts: { knowledgePrefix?: string; tasksPrefix?: string; reflectionsPrefix?: string; + settingsPrefix?: string; }): OrgApiClient { const prefix = opts.announcementsPrefix ?? "/org-module/announcements"; const orgPrefix = opts.orgPrefix ?? "/org-module/org"; @@ -540,6 +584,7 @@ export function createOrgApiClient(opts: { const knowledgePrefix = opts.knowledgePrefix ?? "/org-module/knowledge"; const tasksPrefix = opts.tasksPrefix ?? "/org-module/tasks"; const reflectionsPrefix = opts.reflectionsPrefix ?? "/org-module/reflections"; + const settingsPrefix = opts.settingsPrefix ?? "/org-module"; const fetchJson = opts.fetchJson; const announcements: OrgAnnouncementsClient = { @@ -985,6 +1030,39 @@ export function createOrgApiClient(opts: { }, }; + const settings: OrgSettingsClient = { + async snapshot() { + return unwrap( + await fetchJson>( + `${settingsPrefix}/settings`, + ), + ); + }, + async savePrefs(body) { + return unwrap( + await fetchJson>(`${settingsPrefix}/prefs`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + }, + async saveModules(updates) { + const raw = await fetchJson<{ + updates?: Record; + success?: boolean; + data?: { updates?: Record }; + }>(`${settingsPrefix}/modules`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates }), + }); + if (raw.updates) return raw.updates; + if (raw.data?.updates) return raw.data.updates; + throw new Error("request failed"); + }, + }; + return { announcements, org, @@ -994,5 +1072,6 @@ export function createOrgApiClient(opts: { knowledge, tasks, reflections, + settings, }; } diff --git a/dashboard/src/org-ui/contract.ts b/dashboard/src/org-ui/contract.ts index e939c04c..4468cbbc 100644 --- a/dashboard/src/org-ui/contract.ts +++ b/dashboard/src/org-ui/contract.ts @@ -8,6 +8,7 @@ export const SHARED_ORG_UI_MODULES = [ "knowledge", "tasks", "reflections", + "settings", ] as const; export type SharedOrgUiModule = (typeof SHARED_ORG_UI_MODULES)[number]; diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts index f7673a4f..a24352c3 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -48,6 +48,12 @@ export type { OrgReflectionType, OrgReflectionWrite, OrgReflectionsClient, + OrgCapability, + OrgPrefs, + OrgPrefsWrite, + OrgSettingsClient, + OrgSettingsSnapshot, + OrgSystemSettingsLinks, OrgSkill, OrgSkillCatalogRow, OrgSkillGenerateResult, @@ -75,6 +81,8 @@ export { TaskDetailPage } from "./pages/tasks/TaskDetailPage"; export type { TaskDetailPageProps } from "./pages/tasks/TaskDetailPage"; export { ReflectionsPage } from "./pages/reflections/ReflectionsPage"; export type { ReflectionsPageProps } from "./pages/reflections/ReflectionsPage"; +export { SettingsPage } from "./pages/settings/SettingsPage"; +export type { SettingsPageProps } from "./pages/settings/SettingsPage"; export type { IdentityBridge, OrgFetcher, diff --git a/dashboard/src/org-ui/pages/settings/SettingsPage.module.css b/dashboard/src/org-ui/pages/settings/SettingsPage.module.css new file mode 100644 index 00000000..130fb7d3 --- /dev/null +++ b/dashboard/src/org-ui/pages/settings/SettingsPage.module.css @@ -0,0 +1,152 @@ +.page { + max-width: 880px; + margin: 0 auto; + padding: 8px 4px 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.titleRow { + display: flex; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-size: 20px; + font-weight: 650; +} + +.subtitle { + margin: 4px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; + max-width: 640px; + line-height: 1.5; +} + +.hint { + margin: 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; + line-height: 1.5; +} + +.callout { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + padding: 14px 16px; + background: var(--fn-bg, #f9fafb); + display: flex; + flex-direction: column; + gap: 8px; +} + +.calloutTitle { + margin: 0; + font-size: 13px; + font-weight: 650; +} + +.links { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.card { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + padding: 16px; + background: var(--fn-bg-container, #fff); + display: flex; + flex-direction: column; + gap: 12px; +} + +.cardTitle { + margin: 0; + font-size: 15px; + font-weight: 650; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fieldLabel { + font-size: 12px; + font-weight: 600; + color: var(--fn-text-secondary, #667085); +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.status { + font-size: 12px; + color: var(--fn-text-secondary, #667085); +} + +.error { + margin: 0; + color: #b42318; + font-size: 13px; +} + +.moduleList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.moduleRow { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 10px; +} + +.moduleMeta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.moduleName { + font-size: 13px; + font-weight: 600; +} + +.moduleDesc { + font-size: 12px; + color: var(--fn-text-secondary, #667085); + line-height: 1.4; +} + +.empty { + text-align: center; + padding: 48px 12px; + color: var(--fn-text-secondary, #667085); +} diff --git a/dashboard/src/org-ui/pages/settings/SettingsPage.test.tsx b/dashboard/src/org-ui/pages/settings/SettingsPage.test.tsx new file mode 100644 index 00000000..ba080ae5 --- /dev/null +++ b/dashboard/src/org-ui/pages/settings/SettingsPage.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SettingsPage } from "./SettingsPage"; +import type { OrgSettingsClient } from "../../api/createClient"; + +function mockClient(): OrgSettingsClient { + return { + snapshot: vi.fn(async () => ({ + scope: "org_module", + catalog: [ + { + key: "settings", + label: "Settings", + label_zh: "系统设置", + description: "Tenant, model, module, and user settings.", + description_zh: "租户、模型、模块与用户设置", + locked: true, + }, + { + key: "chat", + label: "Collaboration", + label_zh: "沟通协作", + description: "Human-agent chat.", + description_zh: "人机协作", + locked: false, + }, + ], + modules: { settings: true, chat: true }, + prefs: { name: "Acme", description: "Ops" }, + system_settings: { + overview: "/system-settings", + models: "/system-settings/models", + users: "/system-settings/users", + }, + not_on_this_page: ["llm_keys", "users"], + })), + savePrefs: vi.fn(async (body) => ({ + name: body.name ?? "", + description: body.description ?? "", + })), + saveModules: vi.fn(async (updates) => ({ + settings: true, + chat: updates.chat ?? true, + })), + }; +} + +const admin = { + userId: 1, + displayName: "Ada", + role: "admin", + isAdmin: true, +}; + +describe("SettingsPage", () => { + it("renders org-module settings without an iframe or system settings form", async () => { + const client = mockClient(); + render(); + expect(await screen.findByTestId("org-ui-settings")).toBeInTheDocument(); + expect(screen.getByText("Organization settings")).toBeInTheDocument(); + expect(screen.getByTestId("org-settings-boundary")).toBeInTheDocument(); + expect(screen.getByTestId("org-settings-system")).toHaveAttribute( + "href", + "/system-settings", + ); + expect(screen.getByTestId("org-settings-name")).toHaveValue("Acme"); + expect(screen.getByText("Always on")).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(screen.queryByText("API Key")).toBeNull(); + expect(client.snapshot).toHaveBeenCalled(); + }); + + it("saves org prefs and catalog toggles through the injected client", async () => { + const client = mockClient(); + render(); + fireEvent.change(await screen.findByTestId("org-settings-name"), { + target: { value: "Northwind" }, + }); + fireEvent.click(screen.getByTestId("org-settings-save-prefs")); + await waitFor(() => { + expect(client.savePrefs).toHaveBeenCalledWith( + expect.objectContaining({ name: "Northwind" }), + ); + }); + fireEvent.click(screen.getByRole("switch")); + await waitFor(() => { + expect(client.saveModules).toHaveBeenCalledWith({ chat: false }); + }); + }); +}); diff --git a/dashboard/src/org-ui/pages/settings/SettingsPage.tsx b/dashboard/src/org-ui/pages/settings/SettingsPage.tsx new file mode 100644 index 00000000..6b0e085e --- /dev/null +++ b/dashboard/src/org-ui/pages/settings/SettingsPage.tsx @@ -0,0 +1,250 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button, Input, Switch, Tag, Typography } from "antd"; +import { ExternalLink, Settings } from "lucide-react"; +import type { + OrgCapability, + OrgPrefs, + OrgSettingsClient, + OrgSettingsSnapshot, + OrgSystemSettingsLinks, +} from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { settingsLabels } from "./labels"; +import styles from "./SettingsPage.module.css"; + +export interface SettingsPageProps { + client: OrgSettingsClient; + session: OrgSession; + locale: OrgLocale; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; + workbenchHref?: string; +} + +const EMPTY_PREFS: OrgPrefs = { name: "", description: "" }; +const EMPTY_LINKS: OrgSystemSettingsLinks = { + overview: "/system-settings", + models: "/system-settings/models", + users: "/system-settings/users", +}; + +export function SettingsPage({ + client, + session, + locale, + workbenchHref = "/organization", +}: SettingsPageProps) { + const labels = settingsLabels(locale); + const [catalog, setCatalog] = useState([]); + const [toggles, setToggles] = useState>({}); + const [prefs, setPrefs] = useState(EMPTY_PREFS); + const [links, setLinks] = useState(EMPTY_LINKS); + const [loading, setLoading] = useState(true); + const [savingPrefs, setSavingPrefs] = useState(false); + const [toggling, setToggling] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + const applySnapshot = (snap: OrgSettingsSnapshot) => { + setCatalog(snap.catalog); + setToggles(snap.modules); + setPrefs(snap.prefs); + if (snap.system_settings) setLinks(snap.system_settings); + }; + + const fetchAll = useCallback(async () => { + setLoading(true); + setError(""); + try { + applySnapshot(await client.snapshot()); + } catch { + setCatalog([]); + setToggles({}); + setPrefs(EMPTY_PREFS); + setError(labels.loadFailed); + } finally { + setLoading(false); + } + }, [client, labels.loadFailed]); + + useEffect(() => { + void fetchAll(); + }, [fetchAll]); + + const savePrefs = async () => { + if (!session.isAdmin) return; + setSavingPrefs(true); + setError(""); + setMessage(""); + try { + const saved = await client.savePrefs({ + name: prefs.name, + description: prefs.description, + }); + setPrefs(saved); + setMessage(labels.saved); + } catch { + setError(labels.saveFailed); + } finally { + setSavingPrefs(false); + } + }; + + const toggleModule = async (key: string, enabled: boolean) => { + if (!session.isAdmin) return; + const previous = toggles; + setToggles({ ...toggles, [key]: enabled }); + setToggling(key); + setError(""); + try { + const saved = await client.saveModules({ [key]: enabled }); + setToggles(saved); + } catch { + setToggles(previous); + setError(labels.saveFailed); + } finally { + setToggling(""); + } + }; + + const capLabel = (row: OrgCapability) => + locale === "zh" ? row.label_zh || row.label : row.label; + const capDesc = (row: OrgCapability) => + locale === "zh" ? row.description_zh || row.description : row.description; + + if (loading) { + return ( +
+
{labels.loading}
+
+ ); + } + + return ( +
+
+
+
+ + + {labels.title} + +
+

{labels.subtitle}

+
+ {workbenchHref ? ( + + ) : null} +
+ +
+

{labels.notHereTitle}

+

{labels.notHereBody}

+

{labels.hostHint}

+
+ + + +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+

{labels.profileTitle}

+

{labels.profileHint}

+ + +
+ + {message ? {message} : null} + {!session.isAdmin ? ( + {labels.readOnly} + ) : null} +
+
+ +
+

{labels.modulesTitle}

+

{labels.modulesHint}

+
+ {catalog.map((row) => { + const on = toggles[row.key] !== false; + return ( +
+
+
{capLabel(row)}
+
{capDesc(row)}
+
+ {row.locked ? ( + {labels.locked} + ) : ( + void toggleModule(row.key, enabled)} + checkedChildren={labels.enabled} + unCheckedChildren={labels.disabled} + /> + )} +
+ ); + })} +
+
+
+ ); +} diff --git a/dashboard/src/org-ui/pages/settings/labels.ts b/dashboard/src/org-ui/pages/settings/labels.ts new file mode 100644 index 00000000..36485549 --- /dev/null +++ b/dashboard/src/org-ui/pages/settings/labels.ts @@ -0,0 +1,73 @@ +import type { OrgLocale } from "../../shell"; + +const EN = { + title: "Organization settings", + subtitle: + "Catalog module toggles and org-local preferences on the host. This is not FreeOS system settings.", + hostHint: + "LLM keys, users, timezone, and models stay on FreeOS system settings. This page only edits org_os catalog enablement and org-local prefs.", + notHereTitle: "Not on this page", + notHereBody: + "FreeOS already has system Settings. Open those for models, users, timezone, and security.", + openSystem: "FreeOS system settings", + openModels: "Models & keys", + openUsers: "Users", + openWorkbench: "Organization workbench", + profileTitle: "Organization profile", + profileHint: + "Shown on this Organization module. Not the FreeOS instance name.", + name: "Organization name", + namePlaceholder: "e.g. Northwind", + description: "Description", + descriptionPlaceholder: "Optional short description", + savePrefs: "Save preferences", + saving: "Saving…", + saved: "Saved", + modulesTitle: "Catalog modules", + modulesHint: + "When off, the module is hidden from the Organization catalog. Workspace and Settings stay on.", + locked: "Always on", + enabled: "Shown", + disabled: "Hidden", + loading: "Loading organization settings…", + loadFailed: "Could not load organization settings", + saveFailed: "Could not save", + readOnly: "Only administrators can change organization settings.", +}; + +const ZH: typeof EN = { + title: "组织设置", + subtitle: "宿主内的目录模块开关与组织本地偏好。这里不是 FreeOS 系统设置。", + hostHint: + "大模型密钥、用户、时区和模型仍在 FreeOS 系统设置。本页只改 org_os 目录启用与组织本地偏好。", + notHereTitle: "本页没有这些", + notHereBody: "FreeOS 已有系统设置。模型、用户、时区和安全请到那里改。", + openSystem: "FreeOS 系统设置", + openModels: "模型与密钥", + openUsers: "用户", + openWorkbench: "组织工作台", + profileTitle: "组织资料", + profileHint: "只用于本组织模块,不是 FreeOS 实例名。", + name: "组织名称", + namePlaceholder: "例如 北风", + description: "简介", + descriptionPlaceholder: "可选短简介", + savePrefs: "保存偏好", + saving: "保存中…", + saved: "已保存", + modulesTitle: "目录模块", + modulesHint: "关闭后,该模块从组织目录隐藏。工作台与设置保持开启。", + locked: "始终开启", + enabled: "显示", + disabled: "隐藏", + loading: "正在加载组织设置…", + loadFailed: "无法加载组织设置", + saveFailed: "保存失败", + readOnly: "只有管理员可以更改组织设置。", +}; + +export type SettingsLabels = typeof EN; + +export function settingsLabels(locale: OrgLocale): SettingsLabels { + return locale === "zh" ? ZH : EN; +} diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx index acab8891..44a1ec79 100644 --- a/dashboard/src/pages/Organization/index.test.tsx +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -44,6 +44,10 @@ function renderOrg() { path="/organization/reflections" element={
reflections
} /> + settings} + /> experts} @@ -287,6 +291,13 @@ describe("OrganizationPage", () => { expect(screen.queryByTestId("org-browser-frame")).toBeNull(); }); + it("opens in-host organization settings without a sidecar iframe", async () => { + renderOrg(); + fireEvent.click(await screen.findByTestId("org-open-settings")); + expect(await screen.findByTestId("org-settings-page")).toBeInTheDocument(); + expect(screen.queryByTestId("org-browser-frame")).toBeNull(); + }); + it("imports into FreeOS and opens Experts so colleagues are selectable", async () => { vi.mocked(orgModuleApi.assemble).mockResolvedValue({ sidecar_reachable: false, diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index 79e5e1f3..49185478 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -10,6 +10,7 @@ import { ListTodo, Package, Play, + Settings, Settings2, Shield, Users, @@ -531,6 +532,22 @@ export default function OrganizationPage() { {t("organization.openReflections")} +
+ +

+ {t("organization.settingsTitle")} +

+

+ {t("organization.settingsBody")} +

+ +
{firstRun && ( diff --git a/dashboard/src/pages/Organization/orgPathTabs.tsx b/dashboard/src/pages/Organization/orgPathTabs.tsx index 96941a56..41668c5b 100644 --- a/dashboard/src/pages/Organization/orgPathTabs.tsx +++ b/dashboard/src/pages/Organization/orgPathTabs.tsx @@ -6,6 +6,7 @@ import { Megaphone, Network, Package, + Settings, Shield, Users, } from "lucide-react"; @@ -23,7 +24,8 @@ export function useOrgPathTabs( | "governance" | "knowledge" | "tasks" - | "reflections", + | "reflections" + | "settings", ): PathTabsConfig { const { t } = useTranslation(); const navigate = useNavigate(); @@ -75,6 +77,11 @@ export function useOrgPathTabs( label: t("organization.navReflections"), icon: , }, + { + value: "settings", + label: t("organization.navSettings"), + icon: , + }, ], onChange: (value) => { if (value === "announcements") { @@ -109,6 +116,10 @@ export function useOrgPathTabs( navigate("/organization/reflections"); return; } + if (value === "settings") { + navigate("/organization/settings"); + return; + } navigate("/organization"); }, }; diff --git a/dashboard/src/pages/Organization/settings/index.tsx b/dashboard/src/pages/Organization/settings/index.tsx new file mode 100644 index 00000000..e4ee92a7 --- /dev/null +++ b/dashboard/src/pages/Organization/settings/index.tsx @@ -0,0 +1,46 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { request } from "../../../api/request"; +import { useCurrentUser } from "../../../hooks/useCurrentUser"; +import PageShell from "../../../layouts/PageShell"; +import { + SettingsPage, + createOrgApiClient, + type OrgSession, +} from "../../../org-ui"; +import { useOrgPathTabs } from "../orgPathTabs"; + +export default function OrganizationSettingsPage() { + const { t, i18n } = useTranslation(); + const user = useCurrentUser(); + const pathTabs = useOrgPathTabs("settings"); + const client = useMemo( + () => + createOrgApiClient({ + fetchJson: (path, init) => request(path, init), + }), + [], + ); + const locale = i18n.language?.toLowerCase().startsWith("zh") ? "zh" : "en"; + const session: OrgSession = { + userId: user?.id ?? 0, + displayName: user?.display_name || user?.username || "", + role: user?.role || "user", + isAdmin: user?.role === "admin", + }; + + return ( + + + + ); +} diff --git a/dashboard/src/routes/controlAdminPath.test.ts b/dashboard/src/routes/controlAdminPath.test.ts index 5bc6bbcf..5ef220f8 100644 --- a/dashboard/src/routes/controlAdminPath.test.ts +++ b/dashboard/src/routes/controlAdminPath.test.ts @@ -75,6 +75,7 @@ describe("pathPermissionKeys", () => { expect(pathPermissionKeys("/organization/knowledge")).toBeNull(); expect(pathPermissionKeys("/organization/tasks")).toBeNull(); expect(pathPermissionKeys("/organization/reflections")).toBeNull(); + expect(pathPermissionKeys("/organization/settings")).toBeNull(); expect(pathPermissionKeys("/projects")).toBeNull(); expect(pathPermissionKeys("/tasks")).toBeNull(); expect(pathPermissionKeys("/token-usage")).toBeNull(); @@ -168,6 +169,7 @@ describe("unknown dashboard paths", () => { expect(resolveSelectedKey("/organization/reflections")).toBe( "organization", ); + expect(resolveSelectedKey("/organization/settings")).toBe("organization"); }); it("are caught by the not-found route", () => { diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index f7828d2c..4ad2b7bc 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -44,6 +44,9 @@ const OrganizationTaskDetailPage = lazy( const OrganizationReflectionsPage = lazy( () => import("../pages/Organization/reflections"), ); +const OrganizationSettingsPage = lazy( + () => import("../pages/Organization/settings"), +); const ProjectsPage = lazy(() => import("../pages/Projects")); // Misc @@ -105,6 +108,7 @@ export const pathToKey: Record = { "/organization/knowledge": "organization", "/organization/tasks": "organization", "/organization/reflections": "organization", + "/organization/settings": "organization", "/projects": "projects", "/tasks": "projects", "/connectors": "personalization", @@ -275,6 +279,10 @@ export const routeConfigs: RouteConfig[] = [ path: "/organization/reflections", element: , }, + { + path: "/organization/settings", + element: , + }, { path: "/projects", element: }, { path: "/tasks", element: }, { diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index 1003612f..cb6f9be1 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -290,7 +290,7 @@ The Phase A work sits on the existing FreeOS bootstrap: | Self-growth loop | Bridge | `freeos org loop run` · `/api/org-module/loop/run` | | Colleague agents | Host agents table | `org-` · `{home}/org-agents/` · tenant `routing.json` | | Org APIs | Host BFF + optional sidecar | `/api/org-module/*` in-host (announcements + org chart CRUD are in-host); sidecar `/api/org`, `/api/employees`, … until each remaining CRUD slice moves (see [org-merge-plan.md](org-merge-plan.md)) | -| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + `/organization/announcements` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `/organization/tasks` + `/organization/reflections` (shared `dashboard/src/org-ui`; directory employees reuse org-chart SQLite; skills list host `org-skills` + Agent skill packages; governance reads host pauses/audit; knowledge bridges host KBs; tasks/reflections live under `{FREEOS_HOME}/org`). Standalone site is **exported** from the same source (`freeos org export-standalone`). Sidecar `:3780` iframe is opt-in compat only | +| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + `/organization/announcements` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `/organization/tasks` + `/organization/reflections` + `/organization/settings` (shared `dashboard/src/org-ui`; directory employees reuse org-chart SQLite; skills list host `org-skills` + Agent skill packages; governance reads host pauses/audit; knowledge bridges host KBs; tasks/reflections live under `{FREEOS_HOME}/org`; settings edit `org-os` catalog toggles + prefs only). Standalone site is **exported** from the same source (`freeos org export-standalone`). Sidecar `:3780` iframe is opt-in compat only | ### Auth and tenant mapping diff --git a/docs/org-merge-plan.md b/docs/org-merge-plan.md index c39c007d..2811f77c 100644 --- a/docs/org-merge-plan.md +++ b/docs/org-merge-plan.md @@ -1,6 +1,6 @@ # 组织模块合并计划(openXYOS → FreeOS Dashboard) -**状态:** Phase 3 组织架构、员工目录、技能、治理 UI、知识、任务与反思切片已落地(宿主 org tree / 员工 CRUD + skill_bridge list/read + 治理 pauses/audit + 宿主知识库桥接 + `{FREEOS_HOME}/org/tasks.sqlite` + `{FREEOS_HOME}/org/reflections.sqlite` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `/organization/tasks` + `/organization/reflections` + `org-ui` OrgChartPage / EmployeesPage / SkillsPage / GovernancePage / KnowledgePage / TasksPage / ReflectionsPage + export-standalone 模块列表)。其余 Open-12 仍待切片。 +**状态:** Phase 3 组织架构、员工目录、技能、治理 UI、知识、任务、反思与设置切片已落地(宿主 org tree / 员工 CRUD + skill_bridge list/read + 治理 pauses/audit + 宿主知识库桥接 + `{FREEOS_HOME}/org/tasks.sqlite` + `{FREEOS_HOME}/org/reflections.sqlite` + `{FREEOS_HOME}/org-os/prefs.json` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `/organization/tasks` + `/organization/reflections` + `/organization/settings` + `org-ui` OrgChartPage / EmployeesPage / SkillsPage / GovernancePage / KnowledgePage / TasksPage / ReflectionsPage / SettingsPage + export-standalone 模块列表)。其余 Open-12 仍待切片。 **日期:** 2026-09-19 **依据:** Phase 0 迁移图、[architecture-integration.md](architecture-integration.md)、[asset-loop.md](asset-loop.md)、[ADR 001](adr/001-single-process-model.md)、#55 宿主内 Organization @@ -25,7 +25,7 @@ Phase 1 冻结了合同。Phase 2(通知公告)与 Phase 3 的组织架构 | **0** 宿主内地基 | `org_os` 控制面、`/api/org-module/*` BFF、Dashboard 原生工作台、sidecar 改为可选 | **已完成**(#55) | | **1** 合同冻结 | ADR + 本计划:单源双交付、包布局、API 归属、IdentityBridge、Phase 2 验收 | **本文** | | **2** 垂直切片 | **通知公告 Announcements** 按合同落地(Dashboard 路由 + 可导出同一页面) | **已完成** | -| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:架构 / 员工 / 技能 / 治理 / 知识 / 任务 / 反思已迁;智能体、设置、工作台扩展未开始 | +| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:架构 / 员工 / 技能 / 治理 / 知识 / 任务 / 反思 / 设置已迁;智能体、工作台扩展未开始 | | **4** 身份与 CRUD | 已迁页面走 IdentityBridge;业务 CRUD 按切片迁入宿主;未迁路由仍可代理到可选 sidecar | 与 2–3 交叉推进 | | **5** 导出与安装器 | `freeos org export-standalone` 产出独立站;默认安装器保持零 Node | 未开始 | @@ -65,7 +65,7 @@ Catalog 十二键(`catalog.py` ↔ `open-module-catalog.ts`): | knowledge | OpenApp `/knowledge` | sidecar `/api/knowledge` | **Phase 3 切片已落地**:`/organization/knowledge` 列出宿主 `/api/knowledge-bases` 同一套行;不克隆 sidecar notes/files DB | | reflections | OpenApp `/reflections` | sidecar `/api/reflections` | **Phase 3 切片已落地**:`/organization/reflections` + `/api/org-module/reflections*`,`{FREEOS_HOME}/org/reflections.sqlite`。不是 Chat,也不是第二套技能运行时。未迁:边车技能统计、部署铁律样例 | | governance UI | OpenApp `/governance` | sidecar `/api/governance` + 宿主 `org_os/governance` | **Phase 3 切片已落地**:`/organization/governance` 展示待审批 / 审计 / 裁决;PEP/PDP 与 durable pause **已在宿主,勿重写引擎**。未迁:边车权限矩阵、通信规则、流程模板 | -| settings | OpenApp `/settings` | sidecar `/api/settings`、`/api/module-settings` | Phase 3:租户/模块开关;宿主已有 `PATCH /api/org-module` 与 `/modules` | +| settings | OpenApp `/settings` | sidecar `/api/settings`、`/api/module-settings` | **Phase 3 切片已落地**:`/organization/settings` 只改 `org_os` 目录开关(复用 `PUT /api/org-module/modules`)与组织本地偏好(`/prefs`)。**不是** FreeOS 系统设置(密钥、用户、时区)。未迁:边车公司/AI/用户/数据库页 | **商业 `App.tsx` 多出来的路由(本波不迁):** workflows、contracts、assets、attendance/leave、expense、daily-report、goals、budgets、performance、efficiency、audit 等。它们不是 Open-12。 @@ -121,6 +121,7 @@ uv run freeos org export-standalone --out dist/openxyos-web | `knowledge` list/read/create/notes | 宿主知识库桥接 | **已挂 UI**。包装 `KnowledgeService`;写操作 `knowledge_bases` 门控。不克隆 sidecar notes DB | | `tasks` list/stats/detail/CRUD/transition/subtasks/comments | 宿主组织待办 | **已挂 UI**。`{FREEOS_HOME}/org/tasks.sqlite`。不是 cron / 项目任务 / Chat | | `reflections` list/stats/detail/create/delete | 宿主组织复盘 | **已挂 UI**。`{FREEOS_HOME}/org/reflections.sqlite`。不是 Chat / 第二套技能运行时 | +| `settings` snapshot · `prefs` · `modules` | 宿主组织模块设置 | **已挂 UI**。复用 `/modules`;prefs 在 `{FREEOS_HOME}/org-os/prefs.json`。不是系统设置 | | `sidecar/*` · `source/download` | 可选兼容 | 留在 Advanced;默认路径不依赖 | ### 业务 CRUD(当前 sidecar Express → 按切片迁入宿主) @@ -132,7 +133,7 @@ uv run freeos org export-standalone --out dist/openxyos-web | 员工目录 | sidecar `/api/employees` | **已迁目录 CRUD。** 宿主 `/api/org-module/org/employees*`,与 org chart **同一** `{FREEOS_HOME}/org/org_chart.sqlite`。lifecycle 仍是 `GET /api/org-module/employees`(同事 registry)。未迁:人才市场、备选入职、资产离职清算、绩效、汇报线、技能绑定、头像上传 | | 任务 | sidecar `/api/tasks` | **已迁。** 宿主 `/api/org-module/tasks*`,`{FREEOS_HOME}/org/tasks.sqlite`。不桥接 cron,不迁 Chat | | 反思 | sidecar `/api/reflections` | **已迁。** 宿主 `/api/org-module/reflections*`,`{FREEOS_HOME}/org/reflections.sqlite`。不迁 Chat,不迁边车技能统计 | -| 设置 | sidecar `/api/settings` 等 | Phase 3 其余页:同一模式,迁完才摘代理 | +| 设置 | sidecar `/api/settings` 等 | **已迁组织模块设置。** `GET /api/org-module/settings` + `PUT /prefs` + 复用 `PUT /modules`。不迁 sidecar 公司/AI/用户/数据库,也不复制 FreeOS 系统设置 | | 知识 | sidecar `/api/knowledge` | **已迁为宿主桥接。** `GET /api/org-module/knowledge*` 读 Octop `knowledge_bases` / `knowledge_documents`;笔记写成宿主 markdown 文档。sidecar notes DB 仍是可选兼容 | | 未迁资源 | sidecar 或 `/api/org-module/sidecar/api/` 代理 | 可选 sidecar 仍可用;默认安装不启动它 | | Chat | sidecar `/api/chats` | **永久不迁** | @@ -415,7 +416,40 @@ openXYOS sidecar `/api/governance`(权限矩阵、通信规则、流程模板 | Chat / 从反思页发起会话 | 对话运行时留在 FreeOS/Octop | | 边车 `employee_skill_stats` / 技能使用统计 | 组织技能已有独立切片;本页不另造技能库 | | 边车「系统部署铁律」样例文案 | openXYOS 自身部署备忘,不是组织复盘数据 | -| 其余 Open-12(智能体、设置、工作台扩展) | 后续切片 | +| 其余 Open-12(智能体、工作台扩展) | 后续切片 | +| `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | + +--- + +## Phase 3 进度:设置(本切片) + +按同一缝落地,**只迁组织模块设置**。不复制 FreeOS 系统设置(大模型密钥、用户、时区、模型、安全)。 + +### 数据模型(单一 SoT) + +| 表面 | 存储 | API | 含义 | +|---|---|---|---| +| 目录模块开关 | `{FREEOS_HOME}/org-os/module-toggles.json` | `GET/PUT /api/org-module/modules`(本页复用,不另造) | openXYOS catalog 十二键的显示/隐藏;`workspace` / `settings` 锁定为开 | +| 组织本地偏好 | `{FREEOS_HOME}/org-os/prefs.json` | `GET /api/org-module/settings` · `PUT /api/org-module/prefs` | 组织显示名与简介;不是实例名 | +| FreeOS 系统设置 | Octop 用户/供应商/config.json | `/system-settings` 等 | **本页不编辑。** 只外链 | +| sidecar 设置 | openXYOS SQL.js | 可选 `/api/settings` | **不迁、不克隆。** 默认路径不读它 | + +本页明确 **没有**:LLM API Key、用户注册、空中模式、数据库备份、边车角色矩阵。 + +### 已落地 + +1. 壳无关组件:`dashboard/src/org-ui/pages/settings`(`SettingsPage`),数据经 `createOrgApiClient().settings`。 +2. Dashboard 子路由:`/organization/settings`(工作台 path tabs + 入口卡)。 +3. 宿主 API:snapshot / prefs;模块开关复用已有 `/modules`。写操作仅管理员。 +4. 导出骨架:`freeos org export-standalone` 模块列表与 `App.tsx` 同时导入 `SettingsPage`。 + +### 本切片明确推迟 + +| 推迟项 | 原因 | +|---|---| +| 把系统设置搬进组织页 | FreeOS 已有系统设置;本页只链出去 | +| 边车公司/AI/用户/数据库 Tab | sidecar SQL.js;密钥与用户在宿主 | +| Chat / Agents / Workspace 批量搬迁 | 不在本 PR | | `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | --- diff --git a/scripts/org-export/README.md b/scripts/org-export/README.md index 636d8864..d8217f6f 100644 --- a/scripts/org-export/README.md +++ b/scripts/org-export/README.md @@ -4,7 +4,7 @@ **imports the same org-ui pages** Dashboard mounts under `/organization/...`. Phase 3 includes **Announcements**, **Org chart**, **Employees**, **Skills**, -**Governance**, **Knowledge**, **Tasks**, and **Reflections**: +**Governance**, **Knowledge**, **Tasks**, **Reflections**, and **Settings**: | Delivery | Route | Component | |---|---|---| @@ -26,6 +26,8 @@ Phase 3 includes **Announcements**, **Org chart**, **Employees**, **Skills**, | Standalone (this export) | `/tasks` | same import | | Dashboard | `/organization/reflections` | `dashboard/src/org-ui` → `ReflectionsPage` | | Standalone (this export) | `/reflections` | same import | +| Dashboard | `/organization/settings` | `dashboard/src/org-ui` → `SettingsPage` | +| Standalone (this export) | `/settings` | same import | Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org chart. Organization skills live under `{FREEOS_HOME}/org-skills/` (skill_bridge). @@ -36,6 +38,9 @@ Organization Tasks live in `{FREEOS_HOME}/org/tasks.sqlite` — not Octop cron and not agent/project chat. Organization Reflections live in `{FREEOS_HOME}/org/reflections.sqlite` — lessons learned, not Chat and not a second skill runtime. +Organization Settings are org-module only: catalog toggles and +`{FREEOS_HOME}/org-os/prefs.json`. They do **not** duplicate FreeOS +system settings (LLM keys, users, timezone). Do not copy the pages into a second tree. ## Phase 5 TODO diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index 13279e25..fa8ee92d 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -15,6 +15,7 @@ from octop.api.routers.org_chart import router as org_chart_router from octop.api.routers.org_knowledge import router as knowledge_router from octop.api.routers.org_reflections import router as reflections_router +from octop.api.routers.org_settings import router as settings_router from octop.api.routers.org_tasks import router as tasks_router from octop.infra.server import OctopServer from octop.infra.utils.locale import resolve_request_locale @@ -44,6 +45,7 @@ router.include_router(knowledge_router) router.include_router(tasks_router) router.include_router(reflections_router) +router.include_router(settings_router) class OrgModulePatch(BaseModel): diff --git a/src/octop/api/routers/org_settings.py b/src/octop/api/routers/org_settings.py new file mode 100644 index 00000000..70d57050 --- /dev/null +++ b/src/octop/api/routers/org_settings.py @@ -0,0 +1,101 @@ +"""In-host organization-module settings — catalog toggles + org-local prefs. + +This is **not** FreeOS system settings (LLM keys, users, timezone, models). +Module on/off flags reuse ``GET/PUT /api/org-module/modules``. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from octop.api.deps import current_user, get_server +from octop.i18n import tr +from octop.infra.server import OctopServer +from octop.infra.utils.locale import resolve_request_locale +from octop.modules.org_os.catalog import OPENXYOS_MODULES +from octop.modules.org_os.module_toggles import load_module_toggles +from octop.modules.org_os.prefs import load_org_prefs, save_org_prefs +from octop.modules.org_os.service import org_module_from_paths + +router = APIRouter() + +SYSTEM_SETTINGS = { + "overview": "/system-settings", + "models": "/system-settings/models", + "users": "/system-settings/users", +} + +NOT_ON_THIS_PAGE = ("llm_keys", "users", "timezone", "models", "security") + + +class OrgPrefsBody(BaseModel): + name: str | None = Field(default=None, max_length=200) + description: str | None = Field(default=None, max_length=800) + + +def _locale(request: Request) -> str: + return resolve_request_locale(request) + + +def _fail(request: Request, key: str, status: int) -> JSONResponse: + return JSONResponse( + status_code=status, + content={"success": False, "error": tr(key, _locale(request))}, + ) + + +def _ok(data: Any = None) -> dict[str, Any]: + payload: dict[str, Any] = {"success": True} + if data is not None: + payload["data"] = data + return payload + + +def _home(server: OctopServer) -> Path: + return Path(org_module_from_paths(server.paths).home) + + +def _can_write(user: Any) -> bool: + return bool(getattr(user, "is_admin", False)) + + +def _snapshot(server: OctopServer) -> dict[str, Any]: + home = _home(server) + return { + "scope": "org_module", + "catalog": list(OPENXYOS_MODULES), + "modules": load_module_toggles(home), + "prefs": load_org_prefs(home), + "system_settings": dict(SYSTEM_SETTINGS), + "not_on_this_page": list(NOT_ON_THIS_PAGE), + } + + +@router.get("/settings", summary="Organization-module settings snapshot") +async def get_org_settings( + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any]: + """Catalog, module toggles, and org-local prefs. Not FreeOS system settings.""" + return _ok(_snapshot(server)) + + +@router.put("/prefs", summary="Save organization-local preferences", response_model=None) +async def put_org_prefs( + body: OrgPrefsBody, + request: Request, + server: OctopServer = Depends(get_server), + user: Any = Depends(current_user), +) -> dict[str, Any] | JSONResponse: + if not _can_write(user): + return _fail(request, "org.settings.forbidden", 403) + try: + saved = save_org_prefs(_home(server), body.model_dump(exclude_unset=True)) + except ValueError: + return _fail(request, "org.settings.invalid", 400) + return _ok(saved) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index 44cd027d..eb5c72e4 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -779,6 +779,10 @@ "task_invalid": "The linked organization task does not exist", "unavailable": "Reflection service is temporarily unavailable. Please try again" }, + "settings": { + "forbidden": "Permission denied", + "invalid": "Organization name or description is too long or invalid" + }, "chart": { "forbidden": "Permission denied", "name_required": "Department or employee name is required", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 1a9e9ef7..8986b8b4 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -779,6 +779,10 @@ "task_invalid": "关联的组织任务不存在", "unavailable": "反思服务暂时不可用,请稍后重试" }, + "settings": { + "forbidden": "无权限", + "invalid": "组织名称或简介过长或无效" + }, "chart": { "forbidden": "无权限", "name_required": "部门或员工名称不能为空", diff --git a/src/octop/modules/org_os/contract.py b/src/octop/modules/org_os/contract.py index d567162c..3e8f6f1d 100644 --- a/src/octop/modules/org_os/contract.py +++ b/src/octop/modules/org_os/contract.py @@ -14,6 +14,7 @@ "knowledge", "tasks", "reflections", + "settings", ) diff --git a/src/octop/modules/org_os/export_standalone.py b/src/octop/modules/org_os/export_standalone.py index 6a5c7246..621a8588 100644 --- a/src/octop/modules/org_os/export_standalone.py +++ b/src/octop/modules/org_os/export_standalone.py @@ -3,7 +3,7 @@ Full Vite + Node server packaging is Phase 5. This writes a snapshot that lists the shared org-ui modules and points at the same AnnouncementPage, OrgChartPage, EmployeesPage, SkillsPage, GovernancePage, KnowledgePage, -TasksPage, and ReflectionsPage sources Dashboard mounts under +TasksPage, ReflectionsPage, and SettingsPage sources Dashboard mounts under ``/organization/...``. """ @@ -33,6 +33,7 @@ | knowledge | `/organization/knowledge` | `/knowledge` | `dashboard/src/org-ui` → `KnowledgePage` | | tasks | `/organization/tasks` | `/tasks` | `dashboard/src/org-ui` → `TasksPage` / `TaskDetailPage` | | reflections | `/organization/reflections` | `/reflections` | `dashboard/src/org-ui` → `ReflectionsPage` | +| settings | `/organization/settings` | `/settings` | `dashboard/src/org-ui` → `SettingsPage` | Do **not** copy those pages into a second tree. Edit `dashboard/src/org-ui`. @@ -47,6 +48,10 @@ Octop cron jobs and **not** agent/project chat. Organization Reflections live in `{FREEOS_HOME}/org/reflections.sqlite`. They are lessons learned, not Chat and not a second skill runtime. +Organization Settings are **org-module only**: catalog toggles +(`{FREEOS_HOME}/org-os/module-toggles.json`) and org-local prefs +(`{FREEOS_HOME}/org-os/prefs.json`). LLM keys, users, timezone, and +models stay on FreeOS system settings (`/system-settings`). ## Phase 3 vs Phase 5 @@ -55,7 +60,7 @@ - Shared module list (`src/modules.json`) - Thin `src/App.tsx` that imports `AnnouncementPage`, `OrgChartPage`, `EmployeesPage`, `SkillsPage`, `GovernancePage`, `KnowledgePage`, - `TasksPage`, and `ReflectionsPage` + `TasksPage`, `ReflectionsPage`, and `SettingsPage` - Documents IdentityBridge: embedded mode uses FreeOS JWT; standalone uses local JWT Phase 5 (TODO — not implemented here): @@ -84,6 +89,7 @@ OrgChartPage, ReflectionsPage, SHARED_ORG_UI_MODULES, + SettingsPage, SkillsPage, TaskDetailPage, TasksPage, @@ -96,6 +102,7 @@ OrgKnowledgeClient, OrgReflectionsClient, OrgSession, + OrgSettingsClient, OrgSkillsClient, OrgTasksClient, } from "org-ui"; @@ -105,7 +112,8 @@ * Page components are the same ones Dashboard mounts at * /organization/announcements, /organization/org, /organization/employees, * /organization/skills, /organization/governance, /organization/knowledge, - * /organization/tasks, and /organization/reflections. + * /organization/tasks, /organization/reflections, and + * /organization/settings. */ const session: OrgSession = { userId: 0, @@ -123,6 +131,7 @@ knowledgeClient: OrgKnowledgeClient; tasksClient: OrgTasksClient; reflectionsClient: OrgReflectionsClient; + settingsClient: OrgSettingsClient; locale?: "zh" | "en"; }) { return ( @@ -189,6 +198,12 @@ locale={props.locale ?? "en"} modules={SHARED_ORG_UI_MODULES} /> + ); } @@ -201,7 +216,7 @@ "description": ( "Standalone Organization web export skeleton. " "Pages come from dashboard/src/org-ui " - "(Phase 3: Announcements + Org chart + Employees + Skills + Governance + Knowledge + Tasks + Reflections). " + "(Phase 3: Announcements + Org chart + Employees + Skills + Governance + Knowledge + Tasks + Reflections + Settings). " "Full Node packaging is Phase 5." ), "type": "module", @@ -283,6 +298,15 @@ def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: "api": "/api/org-module/reflections", "store": "{FREEOS_HOME}/org/reflections.sqlite", }, + "settings": { + "component": "SettingsPage", + "embedded_route": "/organization/settings", + "standalone_route": "/settings", + "api": "/api/org-module/settings", + "modules_api": "/api/org-module/modules", + "prefs_api": "/api/org-module/prefs", + "store": "{FREEOS_HOME}/org-os/prefs.json", + }, }, "todo": "Phase 5: full Vite + server packaging; do not fork org-ui pages", } diff --git a/src/octop/modules/org_os/prefs.py b/src/octop/modules/org_os/prefs.py new file mode 100644 index 00000000..20fac881 --- /dev/null +++ b/src/octop/modules/org_os/prefs.py @@ -0,0 +1,58 @@ +"""Persist org-local preferences (not FreeOS system settings). + +Lives next to catalog module toggles under ``{FREEOS_HOME}/org-os/``. +LLM keys, users, timezone, and models stay on FreeOS system settings. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +_PREFS_NAME = "prefs.json" +_LIMITS = {"name": 120, "description": 500} + + +def _path(home: Path) -> Path: + root = Path(home) / "org-os" + root.mkdir(parents=True, exist_ok=True) + return root / _PREFS_NAME + + +def default_org_prefs() -> dict[str, str]: + return {"name": "", "description": ""} + + +def load_org_prefs(home: Path) -> dict[str, str]: + prefs = default_org_prefs() + path = _path(home) + if not path.is_file(): + return prefs + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return prefs + if not isinstance(raw, dict): + return prefs + for key, limit in _LIMITS.items(): + value = raw.get(key) + if isinstance(value, str): + prefs[key] = value.strip()[:limit] + return prefs + + +def save_org_prefs(home: Path, updates: dict[str, Any]) -> dict[str, str]: + current = load_org_prefs(home) + for key, limit in _LIMITS.items(): + if key not in updates or updates[key] is None: + continue + if not isinstance(updates[key], str): + raise ValueError(key) + value = updates[key].strip() + if len(value) > limit: + raise ValueError(key) + current[key] = value + path = _path(home) + path.write_text(json.dumps(current, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return current diff --git a/tests/unit/cli/test_org_export_standalone.py b/tests/unit/cli/test_org_export_standalone.py index 6dd2eb6a..098b67a7 100644 --- a/tests/unit/cli/test_org_export_standalone.py +++ b/tests/unit/cli/test_org_export_standalone.py @@ -25,6 +25,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "knowledge", "tasks", "reflections", + "settings", ] modules = json.loads((out / "src" / "modules.json").read_text(encoding="utf-8")) assert modules["shared_org_ui_modules"] == [ @@ -36,6 +37,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "knowledge", "tasks", "reflections", + "settings", ] assert modules["pages"]["announcements"]["component"] == "AnnouncementPage" assert modules["pages"]["announcements"]["embedded_route"] == "/organization/announcements" @@ -61,6 +63,12 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert modules["pages"]["reflections"]["embedded_route"] == "/organization/reflections" assert modules["pages"]["reflections"]["api"] == "/api/org-module/reflections" assert modules["pages"]["reflections"]["store"] == "{FREEOS_HOME}/org/reflections.sqlite" + assert modules["pages"]["settings"]["component"] == "SettingsPage" + assert modules["pages"]["settings"]["embedded_route"] == "/organization/settings" + assert modules["pages"]["settings"]["api"] == "/api/org-module/settings" + assert modules["pages"]["settings"]["modules_api"] == "/api/org-module/modules" + assert modules["pages"]["settings"]["prefs_api"] == "/api/org-module/prefs" + assert modules["pages"]["settings"]["store"] == "{FREEOS_HOME}/org-os/prefs.json" app = (out / "src" / "App.tsx").read_text(encoding="utf-8") assert "AnnouncementPage" in app assert "OrgChartPage" in app @@ -72,6 +80,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert "TasksPage" in app assert "TaskDetailPage" in app assert "ReflectionsPage" in app + assert "SettingsPage" in app assert 'from "org-ui"' in app readme = (out / "README.md").read_text(encoding="utf-8") assert "Phase 5" in readme @@ -83,3 +92,4 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert "KnowledgePage" in readme assert "TasksPage" in readme assert "ReflectionsPage" in readme + assert "SettingsPage" in readme diff --git a/tests/unit/test_org_settings.py b/tests/unit/test_org_settings.py new file mode 100644 index 00000000..2335e173 --- /dev/null +++ b/tests/unit/test_org_settings.py @@ -0,0 +1,109 @@ +"""In-host organization-module settings: prefs + catalog toggles.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from octop.api.deps import current_user, get_server +from octop.api.routers.org_module import router +from octop.infra.users.identity import Role, User +from octop.modules.org_os.contract import ( + SHARED_ORG_UI_MODULES, + assert_shared_modules_in_catalog, +) +from octop.modules.org_os.module_toggles import load_module_toggles, save_module_toggles +from octop.modules.org_os.prefs import load_org_prefs, save_org_prefs + + +def _admin() -> User: + return User(id=1, username="admin", role=Role.ADMIN, display_name="Ada") + + +def _member() -> User: + return User(id=2, username="member", role=Role.USER, display_name="Mo") + + +def _client(tmp_path: Path, user: User) -> TestClient: + app = FastAPI() + app.include_router(router, prefix="/api/org-module") + server = SimpleNamespace( + paths=SimpleNamespace(config=tmp_path / "config.json", root=tmp_path), + services=None, + ) + app.dependency_overrides[current_user] = lambda: user + app.dependency_overrides[get_server] = lambda: server + return TestClient(app) + + +def test_shared_settings_module_is_in_catalog() -> None: + assert "settings" in SHARED_ORG_UI_MODULES + assert_shared_modules_in_catalog() + + +def test_prefs_roundtrip_and_ignore_unknown(tmp_path: Path) -> None: + saved = save_org_prefs( + tmp_path, {"name": " Acme ", "description": "HQ", "llm_api_key": "nope"} + ) + assert saved == {"name": "Acme", "description": "HQ"} + assert load_org_prefs(tmp_path)["name"] == "Acme" + assert "llm_api_key" not in load_org_prefs(tmp_path) + + +def test_prefs_reject_overlong_name(tmp_path: Path) -> None: + try: + save_org_prefs(tmp_path, {"name": "x" * 121}) + except ValueError as exc: + assert str(exc) == "name" + else: + raise AssertionError("expected ValueError") + + +def test_api_snapshot_and_prefs_reuse_modules(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + snap = admin.get("/api/org-module/settings") + assert snap.status_code == 200 + body = snap.json()["data"] + assert body["scope"] == "org_module" + assert "llm_keys" in body["not_on_this_page"] + assert body["system_settings"]["overview"] == "/system-settings" + assert any(row["key"] == "settings" and row["locked"] for row in body["catalog"]) + assert body["modules"]["settings"] is True + assert body["prefs"] == {"name": "", "description": ""} + + saved = admin.put("/api/org-module/prefs", json={"name": "Northwind", "description": "Ops"}) + assert saved.status_code == 200 + assert saved.json()["data"]["name"] == "Northwind" + + toggled = admin.put("/api/org-module/modules", json={"updates": {"chat": False}}) + assert toggled.status_code == 200 + assert toggled.json()["updates"]["chat"] is False + assert toggled.json()["updates"]["settings"] is True + again = admin.get("/api/org-module/settings").json()["data"] + assert again["prefs"]["name"] == "Northwind" + assert again["modules"]["chat"] is False + assert load_module_toggles(tmp_path)["chat"] is False + + +def test_api_member_can_read_but_not_write_prefs(tmp_path: Path) -> None: + save_org_prefs(tmp_path, {"name": "Visible"}) + save_module_toggles(tmp_path, {"announcements": False}) + member = _client(tmp_path, _member()) + snap = member.get("/api/org-module/settings") + assert snap.status_code == 200 + assert snap.json()["data"]["prefs"]["name"] == "Visible" + assert snap.json()["data"]["modules"]["announcements"] is False + denied = member.put("/api/org-module/prefs", json={"name": "Nope"}) + assert denied.status_code == 403 + assert denied.json()["success"] is False + assert load_org_prefs(tmp_path)["name"] == "Visible" + + +def test_api_invalid_prefs(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + response = admin.put("/api/org-module/prefs", json={"name": "x" * 121}) + assert response.status_code == 400 + assert response.json()["success"] is False