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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

- 桌面安装包与配置模板不再允许嵌入云厂商 API Key(含 DeepSeek)。首次运行不会预填真实密钥:优先本机 Ollama,云调用在密钥为空时直接拒绝并提示用户自行填写(即使环境里有 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `LLM_API_KEY` 也不写入 `providers`)。打包排除 `.env`、`octop.db`、`.freeos`,打 zip 前扫描 staging。生产/air-gap sidecar 不再回退 `LLM_API_KEY`。已发布的 **0.0.1–0.0.4** 安装包须下架(已从 Release 删除),并轮换可能泄露的 DeepSeek 密钥。

### 变更

- 桌面首次启动不再要求注册/登录。首屏是可选模型配置(云密钥或本机 Ollama,可跳过);跳过或保存后进入默认智能体对话,而不是停在工作台列表。已有提供商或会话的用户不会被再次拦住。账号仍可稍后在头像菜单里领取,供保存/导出/组织房间使用。首次运行与安全条目一致:不预填云密钥,本机 Ollama 优先。

### 文档

- 增加阿拉伯语(`README.ar.md`)与葡萄牙语(`README.pt.md`)项目简介,并在各语言 README 的语言切换链接中列出。
Expand Down
86 changes: 86 additions & 0 deletions dashboard/src/components/AuthGuard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,92 @@ describe("AuthGuard local session", () => {
expect(getAuthToken()).toBe("guest-token");
});

it("sends a provisioned desktop guest to model setup", async () => {
getAuthStatus.mockResolvedValue({
setup_required: false,
has_providers: false,
desktop: 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",
});

render(
<MemoryRouter initialEntries={["/chat?desktop=1"]}>
<Routes>
<Route
path="/chat"
element={
<AuthGuard>
<div>usable app</div>
</AuthGuard>
}
/>
<Route path="/login" element={<div>login wall</div>} />
<Route path="/setup" element={<div>model setup</div>} />
</Routes>
</MemoryRouter>,
);

expect(await screen.findByText("model setup")).toBeInTheDocument();
expect(screen.queryByText("login wall")).toBeNull();
expect(screen.queryByText("usable app")).toBeNull();
});

it("does not loop returning desktop users who already have a provider", async () => {
getAuthStatus.mockResolvedValue({
setup_required: false,
has_providers: true,
desktop: 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",
});

render(
<MemoryRouter initialEntries={["/chat?desktop=1"]}>
<Routes>
<Route
path="/chat"
element={
<AuthGuard>
<div>usable app</div>
</AuthGuard>
}
/>
<Route path="/login" element={<div>login wall</div>} />
<Route path="/setup" element={<div>model setup</div>} />
</Routes>
</MemoryRouter>,
);

expect(await screen.findByText("usable app")).toBeInTheDocument();
expect(screen.queryByText("model setup")).toBeNull();
expect(screen.queryByText("login wall")).toBeNull();
});

it("opens the studio door even when the organization room is available", async () => {
organizationIdentityStatus.mockResolvedValue({
integrated: true,
Expand Down
62 changes: 40 additions & 22 deletions dashboard/src/components/AuthGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Spin } from "antd";
import { clearAuthToken, getAuthToken, setAuthToken } from "../api/request";
import { authApi, type OctopUser } from "../api/modules/auth";
import { applyUserLocale } from "../utils/locale";
import { desktopPostSessionPath } from "../utils/desktopOnboarding";
import { needsDesktopModelOnboarding } from "../utils/desktopOnboarding";
import { isDesktopShell } from "../utils/desktopShell";
import { CurrentUserProvider } from "../hooks/useCurrentUser";
import { AuthPromptProvider } from "../context/AuthPromptContext";
Expand All @@ -28,7 +28,7 @@ export default function AuthGuard({ children }: AuthGuardProps) {

useEffect(() => {
let cancelled = false;
const desktop = isDesktopShell(desktopQuery ? `?${desktopQuery}` : "");
const shellDesktop = isDesktopShell(desktopQuery ? `?${desktopQuery}` : "");

const adopt = async (me: OctopUser) => {
await applyUserLocale(me.locale);
Expand All @@ -39,19 +39,26 @@ export default function AuthGuard({ children }: AuthGuardProps) {
}
};

const enterAfterSession = async (me: OctopUser, hasProviders: boolean) => {
if (desktop && desktopPostSessionPath(hasProviders) === "/setup") {
const enterAfterSession = async (
me: OctopUser,
hasProviders: boolean,
desktop: boolean,
) => {
if (desktop && needsDesktopModelOnboarding(hasProviders)) {
if (!cancelled) navigate("/setup", { replace: true });
return;
}
await adopt(me);
};

const adoptLocal = async (hasProviders = false): Promise<boolean> => {
const adoptLocal = async (
hasProviders = false,
desktop = shellDesktop,
): Promise<boolean> => {
try {
const res = await authApi.localSession();
setAuthToken(res.access_token);
await enterAfterSession(res.user, hasProviders);
await enterAfterSession(res.user, hasProviders, desktop);
return true;
} catch {
return false;
Expand All @@ -62,9 +69,10 @@ export default function AuthGuard({ children }: AuthGuardProps) {
attempts: number,
delayMs: number,
hasProviders = false,
desktop = shellDesktop,
) => {
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (await adoptLocal(hasProviders)) return true;
if (await adoptLocal(hasProviders, desktop)) return true;
if (attempt < attempts - 1) {
await new Promise((resolve) => {
window.setTimeout(resolve, delayMs);
Expand All @@ -74,9 +82,12 @@ export default function AuthGuard({ children }: AuthGuardProps) {
return false;
};

const holdForDesktop = async (hasProviders = false) => {
const holdForDesktop = async (
hasProviders = false,
desktop = shellDesktop,
) => {
while (!cancelled) {
if (await adoptLocal(hasProviders)) return;
if (await adoptLocal(hasProviders, desktop)) return;
await new Promise((resolve) => {
window.setTimeout(resolve, 400);
});
Expand All @@ -86,31 +97,30 @@ export default function AuthGuard({ children }: AuthGuardProps) {
const check = async () => {
try {
const status = await authApi.getAuthStatus();
const desktop = shellDesktop || status.desktop === true;
const hasProviders = status.has_providers === true;

if (status.setup_required) {
if (
await tryLocalSession(
desktop ? 20 : 4,
desktop ? 250 : 150,
status.has_providers === true,
hasProviders,
desktop,
)
)
return;
if (desktop) {
await holdForDesktop(status.has_providers === true);
await holdForDesktop(hasProviders, desktop);
return;
}
clearAuthToken();
if (!cancelled) navigate("/setup", { replace: true });
return;
}

if (
desktop &&
desktopPostSessionPath(status.has_providers === true) === "/setup"
) {
if (await tryLocalSession(20, 250, status.has_providers === true))
return;
if (desktop && needsDesktopModelOnboarding(hasProviders)) {
if (await tryLocalSession(20, 250, hasProviders, desktop)) return;
if (!cancelled) navigate("/setup", { replace: true });
return;
}
Expand All @@ -121,12 +131,13 @@ export default function AuthGuard({ children }: AuthGuardProps) {
await tryLocalSession(
desktop ? 20 : 4,
desktop ? 250 : 150,
status.has_providers === true,
hasProviders,
desktop,
)
)
return;
if (desktop) {
await holdForDesktop();
await holdForDesktop(hasProviders, desktop);
return;
}
if (!cancelled) {
Expand All @@ -140,10 +151,17 @@ export default function AuthGuard({ children }: AuthGuardProps) {
const me = await authApi.me();
await adopt(me);
} catch {
if (await tryLocalSession(desktop ? 20 : 4, desktop ? 250 : 150))
if (
await tryLocalSession(
desktop ? 20 : 4,
desktop ? 250 : 150,
hasProviders,
desktop,
)
)
return;
if (desktop) {
await holdForDesktop();
await holdForDesktop(hasProviders, desktop);
return;
}
if (!cancelled) {
Expand All @@ -152,7 +170,7 @@ export default function AuthGuard({ children }: AuthGuardProps) {
}
}
} catch {
if (desktop) {
if (shellDesktop) {
await holdForDesktop();
return;
}
Expand Down
10 changes: 9 additions & 1 deletion dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4669,7 +4669,8 @@
},
"wizard": {
"title": "Initial setup",
"desktopTitle": "Configure a model",
"desktopTitle": "Set up a model",
"desktopSubtitle": "Optional — skip anytime and start chatting",
"checking": "Checking setup status…",
"back": "Back",
"sessionExpired": "Wizard session expired. Please verify the bootstrap password again.",
Expand Down Expand Up @@ -4741,8 +4742,15 @@
},
"model": {
"intro": "Start with a local runtime such as Ollama, or a local OpenAI-compatible URL. Cloud providers are optional. You can skip and finish later on Models.",
"desktopIntro": "Choose a local runtime such as Ollama, or paste a cloud API key. You can skip this and chat with the first assistant right away.",
"skip": "Skip",
"skipToWorkspace": "Skip and enter workspace",
"skipToChat": "Skip and start chatting",
"continueToChat": "Save and start chatting",
"nextHint": "Next: open a conversation with your first assistant.",
"ollamaDetected": "Ollama is running on this computer. You can continue with local models.",
"ollamaInstalled": "Ollama is installed but not running. Start it, continue anyway, or skip and chat first.",
"ollamaMissing": "No local runtime detected yet. Add a cloud API key, or skip and set this up later in Models.",
"presetTab": "Preset provider",
"customTab": "Custom provider",
"showMorePresets": "Show more providers ({{count}})",
Expand Down
8 changes: 8 additions & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -4808,6 +4808,7 @@
"wizard": {
"title": "初始化设置",
"desktopTitle": "配置模型",
"desktopSubtitle": "可选步骤,随时可跳过并开始对话",
"checking": "正在检查初始化状态…",
"back": "上一步",
"sessionExpired": "向导会话已过期,请重新验证启动密码。",
Expand Down Expand Up @@ -4880,8 +4881,15 @@
},
"model": {
"intro": "先选本机运行时(如 Ollama)或本机 OpenAI 兼容地址。云厂商是可选项。也可以跳过,稍后在「模型」页完成。",
"desktopIntro": "选择本机运行时(如 Ollama),或填写云厂商 API Key。也可以跳过,直接与第一位助手开始对话。",
"skip": "跳过",
"skipToWorkspace": "跳过,进入工作台",
"skipToChat": "跳过,开始对话",
"continueToChat": "保存并开始对话",
"nextHint": "下一步:进入与第一位助手的对话。",
"ollamaDetected": "已检测到本机 Ollama 正在运行,可直接用本地模型继续。",
"ollamaInstalled": "已安装 Ollama 但尚未运行。可以先启动、直接继续,或跳过先去对话。",
"ollamaMissing": "尚未检测到本机运行时。可以填写云厂商 API Key,或跳过,稍后在「模型」页再设置。",
"presetTab": "预置提供商",
"customTab": "自定义提供商",
"showMorePresets": "显示更多提供商({{count}} 个)",
Expand Down
41 changes: 40 additions & 1 deletion dashboard/src/pages/Login/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function renderLogin() {
<Route path="/login" element={<LoginPage />} />
<Route path="/chat" element={<div>usable app</div>} />
<Route path="/setup" element={<div>setup wizard</div>} />
<Route path="/projects" element={<div>conversation list</div>} />
</Routes>
</MemoryRouter>,
);
Expand Down Expand Up @@ -69,7 +70,6 @@ describe("LoginPage local session", () => {
expect(screen.queryByText("login form")).toBeNull();
expect(getAuthToken()).toBe("guest-token");
await waitFor(() => expect(localSession).toHaveBeenCalledOnce());
expect(getAuthStatus).not.toHaveBeenCalled();
});

it("shows the form when a local session is not available", async () => {
Expand Down Expand Up @@ -105,6 +105,7 @@ describe("LoginPage local session", () => {
<Route path="/login" element={<LoginPage />} />
<Route path="/chat" element={<div>usable app</div>} />
<Route path="/setup" element={<div>model setup</div>} />
<Route path="/projects" element={<div>conversation list</div>} />
</Routes>
</MemoryRouter>,
);
Expand All @@ -114,6 +115,43 @@ describe("LoginPage local session", () => {
expect(getAuthToken()).toBe("guest-token");
});

it("opens returning desktop users on the conversation list", async () => {
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",
});
getAuthStatus.mockResolvedValue({
setup_required: false,
has_providers: true,
desktop: true,
});

render(
<MemoryRouter initialEntries={["/login?desktop=1"]}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/chat" element={<div>usable app</div>} />
<Route path="/setup" element={<div>model setup</div>} />
<Route path="/projects" element={<div>conversation list</div>} />
</Routes>
</MemoryRouter>,
);

expect(await screen.findByText("conversation list")).toBeInTheDocument();
expect(screen.queryByText("login form")).toBeNull();
expect(screen.queryByText("model setup")).toBeNull();
});

it("does not render the login form inside the desktop shell", async () => {
localSession.mockRejectedValue(new Error("interactive login required"));
getAuthStatus.mockResolvedValue({ setup_required: false });
Expand All @@ -125,6 +163,7 @@ describe("LoginPage local session", () => {
<Route path="/login" element={<LoginPage />} />
<Route path="/chat" element={<div>usable app</div>} />
<Route path="/setup" element={<div>setup wizard</div>} />
<Route path="/projects" element={<div>conversation list</div>} />
</Routes>
</MemoryRouter>,
);
Expand Down
Loading
Loading