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 @@ -8,6 +8,7 @@

### 新增

- Organization Phase 4(独立站导出起步):`freeos org export-standalone --out <dir>` 从 `dashboard/src/org-ui` 生成可运行的 Vite 包(OpenApp 风格路由、本地 JWT IdentityBridge `openxyos.standalone.jwt`、登录页、Docker / docker-compose、`server/proxy.mjs` 把 `/api` 反代到 FreeOS `FREEOS_UPSTREAM`)。Chat 不导出。默认安装器与 sidecar 不变(Phase 5)。说明见 `docs/org-export.md`。
- Organization Phase 3(工作台总览切片,Open-12 收口):宿主内薄 Workspace / OpenDashboard。Dashboard `/organization/workspace` 使用共享 `dashboard/src/org-ui` 的 `WorkspacePage`。数据复用已有 `GET /api/org-module/overview`,链到已迁的公告 / 架构 / 员工 / 技能 / 智能体 / 任务 / 知识 / 反思 / 治理 / 设置。**不是**第二套控制面(assemble / pack / loop 仍在 `/organization`)。**Chat 永久不迁。** `freeos org export-standalone` 同时列出 Workspace。Phase 3 Open-12 宿主 UI 完成,下一步 Phase 4 导出。
- Organization Phase 3(智能体切片):宿主内 Agent Studio / 智能体定制。Dashboard `/organization/agents` 使用共享 `dashboard/src/org-ui` 的 `AgentsPage`。列表走 `GET /api/org-module/agents`;编译 / 生命周期 / 注册复用已有 `POST /api/org-module/blueprints/compile`、`/employees/transition`、`/employees/spawn`(`{FREEOS_HOME}/tenants/<id>/employees/`)。**不是** FreeOS 个性化智能体编辑器,也不是 Chat,也不依赖未挂载的 sidecar `/api/agent-studio/*`。注册后的同事出现在 Experts。`freeos org export-standalone` 同时列出 Agents。未迁:Chat、资料上传、人才市场。
- 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。未迁:Chat、边车系统设置。
Expand Down
135 changes: 135 additions & 0 deletions dashboard/src/org-ui/bridges/localJwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from "vitest";
import {
STANDALONE_SESSION_KEY,
STANDALONE_TOKEN_KEY,
StandaloneUnauthorizedError,
createBridgeFetcher,
createLocalJwtBridge,
sessionFromLoginUser,
} from "./localJwt";

function memoryStorage(seed?: Record<string, string>) {
const data = new Map<string, string>(Object.entries(seed ?? {}));
return {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
data.set(key, value);
},
removeItem: (key: string) => {
data.delete(key);
},
data,
};
}

describe("createLocalJwtBridge", () => {
it("uses a token key distinct from the Dashboard session", () => {
expect(STANDALONE_TOKEN_KEY).toBe("openxyos.standalone.jwt");
expect(STANDALONE_TOKEN_KEY).not.toBe("auth_token");
});

it("stores local JWT headers and session without host actions", () => {
const storage = memoryStorage();
const navigate = vi.fn();
const bridge = createLocalJwtBridge({
apiBase: "/api",
locale: "zh",
storage,
navigate,
});
expect(bridge.showHostActions).toBe(false);
expect(bridge.apiBase()).toBe("/api");
expect(bridge.hasToken()).toBe(false);
expect(bridge.authHeaders()).toEqual({});

bridge.setSession("tok-1", {
userId: 7,
displayName: "Ada",
role: "admin",
isAdmin: true,
});
expect(bridge.hasToken()).toBe(true);
expect(bridge.authHeaders()).toEqual({ Authorization: "Bearer tok-1" });
expect(bridge.getSession()).toEqual({
userId: 7,
displayName: "Ada",
role: "admin",
isAdmin: true,
});
expect(storage.getItem(STANDALONE_TOKEN_KEY)).toBe("tok-1");
expect(storage.getItem(STANDALONE_SESSION_KEY)).toContain("Ada");

bridge.navigate("/org");
expect(navigate).toHaveBeenCalledWith("/org");
bridge.clearSession();
expect(bridge.hasToken()).toBe(false);
expect(bridge.getSession().displayName).toBe("");
});

it("maps a FreeOS login user onto OrgSession", () => {
expect(
sessionFromLoginUser({
id: 3,
username: "ada",
display_name: "Ada Lovelace",
role: "admin",
}),
).toEqual({
userId: 3,
displayName: "Ada Lovelace",
role: "admin",
isAdmin: true,
});
});
});

describe("createBridgeFetcher", () => {
it("prefixes apiBase and attaches the standalone Bearer token", async () => {
const storage = memoryStorage();
const bridge = createLocalJwtBridge({ apiBase: "/api", storage });
bridge.setSession("abc", {
userId: 1,
displayName: "Ada",
role: "admin",
isAdmin: true,
});
const fetchMock = vi.fn(async () => ({
status: 200,
ok: true,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ success: true, data: { count: 1 } }),
}));
vi.stubGlobal("fetch", fetchMock);
const fetchJson = createBridgeFetcher(bridge);
const payload = await fetchJson<{ success: boolean }>(
"/org-module/overview",
);
expect(payload).toEqual({ success: true, data: { count: 1 } });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/api/org-module/overview");
const headers = new Headers(init.headers);
expect(headers.get("Authorization")).toBe("Bearer abc");
vi.unstubAllGlobals();
});

it("throws StandaloneUnauthorizedError on 401", async () => {
const bridge = createLocalJwtBridge({
apiBase: "/api",
storage: memoryStorage(),
});
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 401,
ok: false,
headers: new Headers(),
text: async () => "nope",
})),
);
await expect(
createBridgeFetcher(bridge)("/org-module/settings"),
).rejects.toBeInstanceOf(StandaloneUnauthorizedError);
vi.unstubAllGlobals();
});
});
175 changes: 175 additions & 0 deletions dashboard/src/org-ui/bridges/localJwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/** Standalone IdentityBridge: local JWT, distinct from the Dashboard session. */

import type {
IdentityBridge,
OrgFetcher,
OrgLocale,
OrgSession,
ShellAdapter,
} from "../shell";

/** Must not reuse Dashboard `auth_token` — standalone keeps its own session. */
export const STANDALONE_TOKEN_KEY = "openxyos.standalone.jwt";
export const STANDALONE_SESSION_KEY = "openxyos.standalone.session";

export class StandaloneUnauthorizedError extends Error {
constructor(message = "standalone session expired") {
super(message);
this.name = "StandaloneUnauthorizedError";
}
}

export interface LocalJwtStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}

export interface LocalJwtBridge extends ShellAdapter {
setSession(token: string, session: OrgSession): void;
clearSession(): void;
hasToken(): boolean;
}

const EMPTY_SESSION: OrgSession = {
userId: 0,
displayName: "",
role: "user",
isAdmin: false,
};

function memoryStorage(): LocalJwtStorage {
const data = new Map<string, string>();
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => {
data.set(key, value);
},
removeItem: (key) => {
data.delete(key);
},
};
}

function defaultStorage(): LocalJwtStorage {
try {
if (typeof localStorage !== "undefined") {
return localStorage;
}
} catch {
/* private mode / SSR */
}
return memoryStorage();
}

function readSession(storage: LocalJwtStorage): OrgSession {
const raw = storage.getItem(STANDALONE_SESSION_KEY);
if (!raw) return EMPTY_SESSION;
try {
const parsed = JSON.parse(raw) as Partial<OrgSession>;
return {
userId: Number(parsed.userId || 0),
displayName: String(parsed.displayName || ""),
role: String(parsed.role || "user"),
isAdmin: Boolean(parsed.isAdmin),
};
} catch {
return EMPTY_SESSION;
}
}

/**
* Local-JWT IdentityBridge for `freeos org export-standalone`.
* Token key is `openxyos.standalone.jwt` — never Dashboard `auth_token`.
*/
export function createLocalJwtBridge(opts?: {
apiBase?: string;
locale?: OrgLocale;
timeZone?: string;
storage?: LocalJwtStorage;
navigate?: (path: string) => void;
}): LocalJwtBridge {
const storage = opts?.storage ?? defaultStorage();
const apiBase = (opts?.apiBase || "/api").replace(/\/$/, "");
const locale = opts?.locale ?? "en";
const timeZone = opts?.timeZone ?? "Asia/Shanghai";
const navigate = opts?.navigate ?? (() => undefined);

const bridge: LocalJwtBridge = {
locale,
timeZone,
showHostActions: false,
navigate,
apiBase() {
return apiBase;
},
getSession() {
return readSession(storage);
},
authHeaders() {
const headers: Record<string, string> = {};
const token = storage.getItem(STANDALONE_TOKEN_KEY) || "";
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
},
setSession(token, session) {
storage.setItem(STANDALONE_TOKEN_KEY, token);
storage.setItem(STANDALONE_SESSION_KEY, JSON.stringify(session));
},
clearSession() {
storage.removeItem(STANDALONE_TOKEN_KEY);
storage.removeItem(STANDALONE_SESSION_KEY);
},
hasToken() {
return Boolean(storage.getItem(STANDALONE_TOKEN_KEY));
},
};
return bridge;
}

/** `createOrgApiClient` adapter: same-origin `/api` + standalone Bearer token. */
export function createBridgeFetcher(bridge: IdentityBridge): OrgFetcher {
return async <T>(path: string, init?: RequestInit): Promise<T> => {
const base = bridge.apiBase().replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
const headers = new Headers(init?.headers);
for (const [key, value] of Object.entries(bridge.authHeaders())) {
if (!headers.has(key)) headers.set(key, value);
}
const response = await fetch(`${base}${suffix}`, { ...init, headers });
if (response.status === 401) {
throw new StandaloneUnauthorizedError();
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`Request failed: ${response.status} ${response.statusText}${
text ? ` - ${text}` : ""
}`,
);
}
if (response.status === 204) {
return undefined as T;
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
return (await response.text()) as unknown as T;
}
return (await response.json()) as T;
};
}

export function sessionFromLoginUser(user: {
id?: number;
username?: string;
display_name?: string;
role?: string;
}): OrgSession {
const role = user.role || "user";
return {
userId: Number(user.id || 0),
displayName: user.display_name || user.username || "",
role,
isAdmin: role === "admin",
};
}
9 changes: 9 additions & 0 deletions dashboard/src/org-ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,12 @@ export type {
OrgSession,
ShellAdapter,
} from "./shell";
export {
STANDALONE_SESSION_KEY,
STANDALONE_TOKEN_KEY,
StandaloneUnauthorizedError,
createBridgeFetcher,
createLocalJwtBridge,
sessionFromLoginUser,
} from "./bridges/localJwt";
export type { LocalJwtBridge, LocalJwtStorage } from "./bridges/localJwt";
2 changes: 1 addition & 1 deletion docs/architecture-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<slug>` · `{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` + `/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 |
| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + Open-12 host routes (shared `dashboard/src/org-ui`). Standalone site is **exported** from the same source (`freeos org export-standalone` → Vite SPA + local JWT + proxy to host `/api/org-module`; see [org-export.md](org-export.md)). Sidecar `:3780` iframe is opt-in compat only |

### Auth and tenant mapping

Expand Down
Loading
Loading