Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<slug>` 聊天智能体。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。不改运行时。
Expand Down
21 changes: 21 additions & 0 deletions dashboard/src/api/modules/auth.orgIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
42 changes: 31 additions & 11 deletions dashboard/src/api/modules/auth.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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<AuthStatus> | null = null;

Expand All @@ -154,9 +176,7 @@ function applySetupFlags(status: AuthStatus): AuthStatus {

export const authApi = {
organizationIdentityStatus: () =>
request<{ integrated: boolean; authority: string }>(
"/org-module/identity/status",
),
request<OrganizationIdentityStatus>("/org-module/identity/status"),

organizationLogin: async (
email: string,
Expand Down
4 changes: 3 additions & 1 deletion dashboard/src/api/modules/orgModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 5 additions & 7 deletions dashboard/src/components/AuthForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 32 additions & 1 deletion dashboard/src/components/AuthGuard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
}));

Expand Down Expand Up @@ -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"));
Expand Down
21 changes: 0 additions & 21 deletions dashboard/src/components/AuthGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"subtitle": "FreeOS 数据面与 openXYOS 控制面的双循环:干活与治理互相生长。",
"heroTitle": "组织",
"heroStory": "FreeOS 负责干活,openXYOS 负责组织与治理,两者互相生长。",
"roomDoorHint": "组织像工作室里另一间可独立布置的房间:试用、演练、长出自己的流程;与日常对话同在一个 FreeOS 里,又各自留白。",
"roomFrameTitle": "组织房间",
"toggleLabel": "启用组织模块",
"enableModule": "启用组织模块",
"manageOs": "组织OS管理",
Expand Down Expand Up @@ -5095,6 +5097,9 @@
"login": {
"title": "登录",
"subtitle": "懂你、帮你、陪你成长的智能伙伴",
"studioHint": "先在本机安顿好。注册、登录和数据都留在你够得着的地方。",
"roomDoorHint": "组织能力像工作室里的另一间房间,钥匙不是同一把。",
"roomDoorLink": "走进那间房间",
"username": "用户名或邮箱",
"password": "密码",
"submit": "登录",
Expand Down
39 changes: 30 additions & 9 deletions dashboard/src/pages/Login/index.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -22,7 +22,7 @@ export default function LoginPage() {
const [searchParams] = useSearchParams();
const [oidc, setOidc] = useState<OidcStatus | null>(null);
const [mode, setMode] = useState<AuthFormMode>("login");
const [organizationIdentity, setOrganizationIdentity] = useState(false);
const [roomOpen, setRoomOpen] = useState(false);
const desktop = isDesktopShell(`?${searchParams.toString()}`);

useEffect(() => {
Expand All @@ -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;
Expand Down Expand Up @@ -115,7 +112,7 @@ export default function LoginPage() {
navigate("/chat", { replace: true });
};

if (desktop && !organizationIdentity) {
if (desktop) {
return (
<div
style={{
Expand Down Expand Up @@ -171,14 +168,38 @@ export default function LoginPage() {
>
{mode === "register" ? t("login.registerTitle") : t("login.title")}
</h2>
<p
style={{
margin: 0,
fontSize: 13,
lineHeight: 1.6,
color: "var(--fn-text-tertiary)",
textAlign: "center",
}}
>
{t("login.studioHint")}
</p>

<AuthForm
mode={mode}
onModeChange={setMode}
onSuccess={onSuccess}
oidc={oidc}
organizationIdentity={organizationIdentity}
/>
{roomOpen ? (
<p
style={{
margin: 0,
fontSize: 13,
lineHeight: 1.6,
color: "var(--fn-text-tertiary)",
textAlign: "center",
}}
>
{t("login.roomDoorHint")}{" "}
<Link to="/organization">{t("login.roomDoorLink")}</Link>
</p>
) : null}
</div>
</div>
);
Expand Down
49 changes: 49 additions & 0 deletions dashboard/src/pages/Organization/OrganizationEntry.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="org-workbench">workbench</div>,
}));

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(<OrganizationEntry />);
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(<OrganizationEntry />);
expect(await screen.findByTestId("org-room-door-hint")).toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector("iframe")).toHaveAttribute(
"src",
"/organization-app/dashboard?freeos_embed=1",
);
});
});
});
Loading
Loading