From 6390eeee6f58573892a64d62b0ac50956d1882a1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 10:44:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(org):=20dual-identity=20UX=20bounds=20?= =?UTF-8?q?=E2=80=94=20studio=20vs=20org=20room=20(P1.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep FreeOS studio auth and organization-room auth as separate doors. Organization roles stay in organization_role and never become host Role.ADMIN. Studio guests cannot write org business with a host JWT. Soft copy uses 工作室 / 另一间房间 / 各自留白. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + .../src/api/modules/auth.orgIdentity.test.ts | 21 ++++++ dashboard/src/api/modules/auth.ts | 42 +++++++++--- dashboard/src/api/modules/orgModule.ts | 4 +- dashboard/src/components/AuthForm.tsx | 12 ++-- dashboard/src/components/AuthGuard.test.tsx | 33 ++++++++- dashboard/src/components/AuthGuard.tsx | 21 ------ dashboard/src/locales/en.json | 5 ++ dashboard/src/locales/zh.json | 5 ++ dashboard/src/pages/Login/index.tsx | 39 ++++++++--- .../Organization/OrganizationEntry.test.tsx | 49 ++++++++++++++ .../pages/Organization/OrganizationEntry.tsx | 47 ++++++++++--- dashboard/src/pages/Organization/index.tsx | 1 + docs/api.md | 7 +- docs/architecture-integration.md | 2 + docs/org-merge-plan.md | 13 ++-- docs/user-guide.md | 4 +- .../openxyos/frontend/src/api/authFetch.ts | 7 +- .../openxyos/frontend/src/pages/AuthPage.tsx | 11 +-- src/octop/api/app.py | 27 ++------ src/octop/api/deps.py | 19 +++--- src/octop/api/middleware/jwt_auth.py | 11 ++- src/octop/api/middleware/setup_lockdown.py | 9 ++- src/octop/api/openapi_meta.py | 7 +- src/octop/api/routers/auth.py | 15 ----- src/octop/api/routers/org_identity.py | 12 +++- src/octop/api/routers/org_module.py | 5 ++ src/octop/infra/server.py | 2 +- src/octop/infra/users/identity.py | 5 ++ src/octop/infra/users/manager.py | 7 +- src/octop/modules/org_os/proxy.py | 14 ++-- tests/integration/test_dashboard_serve.py | 8 +-- tests/unit/api/test_jwt_auth_middleware.py | 20 ++++++ tests/unit/api/test_org_identity.py | 67 +++++++++++++++++++ tests/unit/test_identity.py | 17 +++++ tests/unit/test_org_module.py | 16 ++++- tests/unit/test_user_manager.py | 54 +++++++++++++++ 37 files changed, 496 insertions(+), 143 deletions(-) create mode 100644 dashboard/src/api/modules/auth.orgIdentity.test.ts create mode 100644 dashboard/src/pages/Organization/OrganizationEntry.test.tsx create mode 100644 tests/unit/api/test_org_identity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d0cab7..f2db57f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### 新增 +- 双身份边界(P1.3):工作室本机登录与组织房间登录分成两扇门。组织管理员不再被抬成宿主 `Role.ADMIN`;工作室访客不能拿宿主通行证写组织房间业务。界面与文档用「工作室 / 另一间房间 / 各自留白」说明两条路径。 - P1.2:`freeos org export-standalone` 默认 `--mode full` 写出可商业化 openXYOS **源码包**(`openxyos/` Apache-2.0 完整 App.tsx 树 + `slice/` MIT 宿主桥 + `modules.json` 对照清单)。`--mode slice` 保留原先 org-ui SPA + `FREEOS_UPSTREAM` 反代。工作室智能体对话不导出;组织沟通协作在完整树。说明见 `docs/org-export.md`。 - Organization P1.1(双向资产总线):`freeos org loop run` / `POST /api/org-module/loop/run` / `assets apply` 在托管组织运行时、`OPENXYOS_BASE_URL` 或 `{FREEOS_HOME}/org-os/runtime.json` 可用时,把资产包 ingest 进当前**组织工作区租户**(`remote_applied=true`),不再只写 `openxyos-mirror/`。无运行时地址时仍为镜像-only,且不拉起 Node。回流后同事仍登记为 `org-` 聊天智能体。FreeOS 工作室账号与组织房间身份分开。说明见 `docs/asset-loop.md`。 - P0.2:openXYOS → FreeOS 能力迁移图(`docs/org-capability-migration-map.md` / `.zh-CN.md` + `docs/org-capability-migration-map.json`)。按域记录原生宿主 / org-ui 切片 / 托管 Node iframe / 可选边车 / 仅导出;波次为资产总线 → 导出 → 双身份 UX → 本地模型/知识库 → 拆除 Node。不改运行时。 diff --git a/dashboard/src/api/modules/auth.orgIdentity.test.ts b/dashboard/src/api/modules/auth.orgIdentity.test.ts new file mode 100644 index 00000000..197e4a88 --- /dev/null +++ b/dashboard/src/api/modules/auth.orgIdentity.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + hostUserFromOrganization, + type OrganizationIdentityUser, +} from "./auth"; + +describe("hostUserFromOrganization", () => { + it("never maps an organization-room admin onto the studio admin role", () => { + const user: OrganizationIdentityUser = { + id: 11, + email: "root@example.local", + nickname: "Root", + role: "super_admin", + tenant_id: 4, + }; + const host = hostUserFromOrganization(user); + expect(host.role).toBe("user"); + expect(host.organization_role).toBe("super_admin"); + expect(host.username).toBe("root@example.local"); + }); +}); diff --git a/dashboard/src/api/modules/auth.ts b/dashboard/src/api/modules/auth.ts index 0a2e6dd1..f0ccff92 100644 --- a/dashboard/src/api/modules/auth.ts +++ b/dashboard/src/api/modules/auth.ts @@ -1,4 +1,5 @@ import { clearSetupRequired, markSetupRequired, request } from "../request"; +import type { OrganizationIdentityStatus } from "./orgModule"; /** * Auth + setup module — adapted to octop's multi-user backend. @@ -53,6 +54,8 @@ export interface OctopUser { permissions?: string[]; /** True while the desktop/loopback guest has not registered. */ is_local?: boolean; + /** Organization-room role when this profile is a mapping; never host admin. */ + organization_role?: string | null; } export interface LoginResponse { @@ -114,6 +117,9 @@ interface RawOrganizationSession { }; } +export const ORG_ROOM_TOKEN_KEY = "org_room_token"; +export const ORG_ROOM_USER_KEY = "org_room_user"; + function organizationSession(raw: RawOrganizationSession): LoginResponse { const token = raw.data.tokens.accessToken; const user = raw.data.user; @@ -124,17 +130,33 @@ function organizationSession(raw: RawOrganizationSession): LoginResponse { expires_in: raw.data.tokens.expiresIn ?? 3600, organization: true, organization_user: user, - user: { - id: user.id, - username: user.email, - role: user.role === "super_admin" ? "admin" : "user", - display_name: user.nickname || user.email, - locale: "zh", - permissions: [], - }, + user: hostUserFromOrganization(user), + }; +} + +/** Organization-room principal as a studio profile — always a host `user`. */ +export function hostUserFromOrganization( + user: OrganizationIdentityUser, +): OctopUser { + return { + id: user.id, + username: user.email, + role: "user", + display_name: user.nickname || user.email, + locale: "zh", + permissions: [], + organization_role: user.role, }; } +export function setOrgRoomSession( + token: string, + user: OrganizationIdentityUser, +): void { + localStorage.setItem(ORG_ROOM_TOKEN_KEY, token); + localStorage.setItem(ORG_ROOM_USER_KEY, JSON.stringify(user)); +} + /** Coalesce AuthGuard / Login / Setup probing the same endpoint in parallel. */ let statusInFlight: Promise | null = null; @@ -154,9 +176,7 @@ function applySetupFlags(status: AuthStatus): AuthStatus { export const authApi = { organizationIdentityStatus: () => - request<{ integrated: boolean; authority: string }>( - "/org-module/identity/status", - ), + request("/org-module/identity/status"), organizationLogin: async ( email: string, diff --git a/dashboard/src/api/modules/orgModule.ts b/dashboard/src/api/modules/orgModule.ts index 3280b5c4..56339820 100644 --- a/dashboard/src/api/modules/orgModule.ts +++ b/dashboard/src/api/modules/orgModule.ts @@ -30,7 +30,9 @@ export interface OrgModuleStatus { export interface OrganizationIdentityStatus { integrated: boolean; - authority: "organization" | string; + authority: "organization" | "studio" | "dual" | string; + studio?: "freeos" | string; + room?: "organization" | string | null; } export interface OrgPlaneCounts { diff --git a/dashboard/src/components/AuthForm.tsx b/dashboard/src/components/AuthForm.tsx index b6c52d0f..54530faa 100644 --- a/dashboard/src/components/AuthForm.tsx +++ b/dashboard/src/components/AuthForm.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { getAuthToken, setAuthToken } from "../api"; import { authApi, + setOrgRoomSession, type LoginResponse, type OidcStatus, } from "../api/modules/auth"; @@ -62,13 +63,10 @@ export default function AuthForm({ }; const applySession = async (res: LoginResponse) => { - setAuthToken(res.access_token); - if (res.organization) { - localStorage.setItem("token", res.access_token); - localStorage.setItem( - "user", - JSON.stringify(res.organization_user ?? res.user), - ); + if (res.organization && res.organization_user) { + setOrgRoomSession(res.access_token, res.organization_user); + } else { + setAuthToken(res.access_token); } await applyUserLocale(res.user.locale); void refreshServerLabels(res.user.locale); diff --git a/dashboard/src/components/AuthGuard.test.tsx b/dashboard/src/components/AuthGuard.test.tsx index 24a3359d..2ae5a88c 100644 --- a/dashboard/src/components/AuthGuard.test.tsx +++ b/dashboard/src/components/AuthGuard.test.tsx @@ -12,7 +12,8 @@ vi.mock("../api/modules/auth", () => ({ localSession: (...args: unknown[]) => localSession(...args), getAuthStatus: (...args: unknown[]) => getAuthStatus(...args), me: (...args: unknown[]) => me(...args), - organizationIdentityStatus: (...args: unknown[]) => organizationIdentityStatus(...args), + organizationIdentityStatus: (...args: unknown[]) => + organizationIdentityStatus(...args), }, })); @@ -139,6 +140,36 @@ describe("AuthGuard local session", () => { expect(getAuthToken()).toBe("guest-token"); }); + it("opens the studio door even when the organization room is available", async () => { + organizationIdentityStatus.mockResolvedValue({ + integrated: true, + authority: "dual", + studio: "freeos", + room: "organization", + }); + getAuthStatus.mockResolvedValue({ setup_required: true }); + localSession.mockResolvedValue({ + access_token: "guest-token", + token_type: "Bearer", + expires_in: 3600, + user: { + id: 1, + username: "local", + role: "admin", + display_name: "FreeOS", + locale: "zh", + is_local: true, + }, + token: "guest-token", + }); + + renderGuard(); + + expect(await screen.findByText("usable app")).toBeInTheDocument(); + expect(screen.queryByText("login wall")).toBeNull(); + expect(getAuthToken()).toBe("guest-token"); + }); + it("never opens the login wall inside the desktop shell", async () => { getAuthStatus.mockResolvedValue({ setup_required: false }); localSession.mockRejectedValue(new Error("interactive login required")); diff --git a/dashboard/src/components/AuthGuard.tsx b/dashboard/src/components/AuthGuard.tsx index a6cc0d4c..9e18a4e1 100644 --- a/dashboard/src/components/AuthGuard.tsx +++ b/dashboard/src/components/AuthGuard.tsx @@ -85,27 +85,6 @@ export default function AuthGuard({ children }: AuthGuardProps) { const check = async () => { try { - const organization = await authApi.organizationIdentityStatus(); - if (organization.integrated) { - const organizationToken = - getAuthToken() || localStorage.getItem("token") || ""; - if (!organizationToken) { - if (!cancelled) navigate("/login", { replace: true }); - return; - } - setAuthToken(organizationToken); - localStorage.setItem("token", organizationToken); - try { - const me = await authApi.me(); - await adopt(me); - } catch { - clearAuthToken(); - localStorage.removeItem("token"); - localStorage.removeItem("user"); - if (!cancelled) navigate("/login", { replace: true }); - } - return; - } const status = await authApi.getAuthStatus(); if (status.setup_required) { diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 70a41b82..9be889a9 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -11,6 +11,8 @@ "subtitle": "A dual loop: FreeOS does the work, openXYOS governs the org — they grow each other.", "heroTitle": "Organization", "heroStory": "FreeOS does the work. openXYOS owns organization and governance. They grow each other.", + "roomDoorHint": "Organization is another room you can furnish on its own: try, rehearse, and grow your workflows. It shares the studio with everyday chat, and each door keeps its own space.", + "roomFrameTitle": "Organization room", "toggleLabel": "Enable organization module", "enableModule": "Enable organization module", "manageOs": "Organization OS", @@ -4956,6 +4958,9 @@ "login": { "title": "Sign in", "subtitle": "An intelligent partner that understands you, helps you, and grows with you", + "studioHint": "Settle in locally first. Sign-up, sign-in, and data stay within reach.", + "roomDoorHint": "Organization is another room in the studio, with its own key.", + "roomDoorLink": "Enter that room", "username": "Username or email", "password": "Password", "submit": "Sign in", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 45758399..0192fc6d 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -11,6 +11,8 @@ "subtitle": "FreeOS 数据面与 openXYOS 控制面的双循环:干活与治理互相生长。", "heroTitle": "组织", "heroStory": "FreeOS 负责干活,openXYOS 负责组织与治理,两者互相生长。", + "roomDoorHint": "组织像工作室里另一间可独立布置的房间:试用、演练、长出自己的流程;与日常对话同在一个 FreeOS 里,又各自留白。", + "roomFrameTitle": "组织房间", "toggleLabel": "启用组织模块", "enableModule": "启用组织模块", "manageOs": "组织OS管理", @@ -5095,6 +5097,9 @@ "login": { "title": "登录", "subtitle": "懂你、帮你、陪你成长的智能伙伴", + "studioHint": "先在本机安顿好。注册、登录和数据都留在你够得着的地方。", + "roomDoorHint": "组织能力像工作室里的另一间房间,钥匙不是同一把。", + "roomDoorLink": "走进那间房间", "username": "用户名或邮箱", "password": "密码", "submit": "登录", diff --git a/dashboard/src/pages/Login/index.tsx b/dashboard/src/pages/Login/index.tsx index db7cebcb..f863050f 100644 --- a/dashboard/src/pages/Login/index.tsx +++ b/dashboard/src/pages/Login/index.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Spin } from "antd"; import { message } from "@/utils/antdMessage"; @@ -22,7 +22,7 @@ export default function LoginPage() { const [searchParams] = useSearchParams(); const [oidc, setOidc] = useState(null); const [mode, setMode] = useState("login"); - const [organizationIdentity, setOrganizationIdentity] = useState(false); + const [roomOpen, setRoomOpen] = useState(false); const desktop = isDesktopShell(`?${searchParams.toString()}`); useEffect(() => { @@ -34,12 +34,9 @@ export default function LoginPage() { const boot = async () => { try { const organization = await authApi.organizationIdentityStatus(); - if (organization.integrated) { - if (!cancelled) setOrganizationIdentity(true); - return; - } + if (!cancelled) setRoomOpen(Boolean(organization.integrated)); } catch { - // Continue with the standalone host login when integration is unavailable. + // Continue with the studio door when the room is not open yet. } const attempts = desktop ? 20 : 4; const delayMs = desktop ? 250 : 150; @@ -115,7 +112,7 @@ export default function LoginPage() { navigate("/chat", { replace: true }); }; - if (desktop && !organizationIdentity) { + if (desktop) { return (
{mode === "register" ? t("login.registerTitle") : t("login.title")} +

+ {t("login.studioHint")} +

+ {roomOpen ? ( +

+ {t("login.roomDoorHint")}{" "} + {t("login.roomDoorLink")} +

+ ) : null}
); diff --git a/dashboard/src/pages/Organization/OrganizationEntry.test.tsx b/dashboard/src/pages/Organization/OrganizationEntry.test.tsx new file mode 100644 index 00000000..eeac8f11 --- /dev/null +++ b/dashboard/src/pages/Organization/OrganizationEntry.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import OrganizationEntry from "./OrganizationEntry"; + +const identityStatus = vi.fn(); + +vi.mock("../../api/modules/orgModule", () => ({ + orgModuleApi: { + identityStatus: (...args: unknown[]) => identityStatus(...args), + }, +})); + +vi.mock("./index", () => ({ + default: () =>
workbench
, +})); + +describe("OrganizationEntry dual doors", () => { + beforeEach(() => { + identityStatus.mockReset(); + }); + + it("keeps the in-host workbench when the room door is closed", async () => { + identityStatus.mockResolvedValue({ + integrated: false, + authority: "studio", + studio: "freeos", + room: null, + }); + render(); + expect(await screen.findByTestId("org-workbench")).toBeInTheDocument(); + }); + + it("opens the organization room behind a soft door hint", async () => { + identityStatus.mockResolvedValue({ + integrated: true, + authority: "dual", + studio: "freeos", + room: "organization", + }); + render(); + expect(await screen.findByTestId("org-room-door-hint")).toBeInTheDocument(); + await waitFor(() => { + expect(document.querySelector("iframe")).toHaveAttribute( + "src", + "/organization-app/dashboard?freeos_embed=1", + ); + }); + }); +}); diff --git a/dashboard/src/pages/Organization/OrganizationEntry.tsx b/dashboard/src/pages/Organization/OrganizationEntry.tsx index 14cea448..e0b35b63 100644 --- a/dashboard/src/pages/Organization/OrganizationEntry.tsx +++ b/dashboard/src/pages/Organization/OrganizationEntry.tsx @@ -1,14 +1,15 @@ import { useEffect, useState } from "react"; import { Spin } from "antd"; +import { useTranslation } from "react-i18next"; import { orgModuleApi } from "../../api/modules/orgModule"; import OrganizationPage from "./index"; /** - * The integrated product has one complete organization application under the - * same FreeOS origin. Keep the old workbench only for legacy installations - * that have not enabled that runtime. + * Studio nav stays in the dashboard shell. The integrated room is another + * door in the same house — it keeps its own guestbook. */ export default function OrganizationEntry() { + const { t } = useTranslation(); const [integrated, setIntegrated] = useState(null); useEffect(() => { @@ -30,11 +31,41 @@ export default function OrganizationEntry() { if (integrated === false) return ; if (integrated === true) { return ( -