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

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

### 修复

- 桌面 `POST /api/auth/local-session` 对已有 `~/.freeos`(多用户 / 组织映射行)或 WebView 非 `127.0.0.1` Host 返回 403,前端重试后掉进注册登录。本机会话在 loopback / `*.localhost` / Origin 为本机时签发 JWT 并选用已有工作室账号;SPA 在 `/` 跳到 `/projects` 丢掉 `?desktop=1` 之前记住桌面壳。403 修复后的路径是:可选模型配置(云 Key / 本机,可跳过)→ 第一个智能体 `/chat/main`,不经过登录墙,也不停在工作台列表。`/setup` 在 guest 已创建后不再打回登录页。

### 文档

- 增加阿拉伯语(`README.ar.md`)与葡萄牙语(`README.pt.md`)项目简介,并在各语言 README 的语言切换链接中列出。
Expand Down
19 changes: 16 additions & 3 deletions dashboard/src/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ function handleSetupRequired(): void {
/** Remembered Wails shell flag — keep ``?desktop=1`` if the query was dropped. */
function setupRedirectPath(): string {
try {
if (window.sessionStorage.getItem("freeos:desktop-shell") === "1") {
if (
window.sessionStorage.getItem("freeos:desktop-shell") === "1" ||
window.localStorage.getItem("freeos:desktop-shell") === "1"
) {
return "/setup?desktop=1";
}
} catch {
Expand Down Expand Up @@ -204,7 +207,7 @@ function buildHeaders(path: string, extra?: HeadersInit): HeadersInit {

// Apply the global JWT first; the caller's `extra` (including a
// wizard token) can still override it below.
const token = getAuthToken();
const token = getAuthToken().trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
Expand Down Expand Up @@ -242,7 +245,7 @@ function buildAuthHeaders(path: string): Record<string, string> {
const headers: Record<string, string> = {
"Accept-Language": i18n.language?.startsWith("zh") ? "zh" : "en",
};
const token = getAuthToken();
const token = getAuthToken().trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
Expand Down Expand Up @@ -271,6 +274,16 @@ function handleUnauthorized(): void {
path.startsWith("/invite")
)
return;
try {
if (
window.sessionStorage.getItem("freeos:desktop-shell") === "1" ||
window.localStorage.getItem("freeos:desktop-shell") === "1"
) {
return;
}
} catch {
/* private mode */
}
_redirectingToLogin = true;

const takenOver = !window.dispatchEvent(
Expand Down
87 changes: 81 additions & 6 deletions dashboard/src/components/AuthGuard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ function renderGuard() {
/>
<Route path="/login" element={<div>login wall</div>} />
<Route path="/setup" element={<div>setup wizard</div>} />
<Route path="/chat/:agentId" element={<div>first agent</div>} />
<Route path="/projects" element={<div>conversation list</div>} />
</Routes>
</MemoryRouter>,
);
Expand Down Expand Up @@ -80,7 +82,7 @@ describe("AuthGuard local session", () => {

renderGuard();

expect(await screen.findByText("usable app")).toBeInTheDocument();
expect(await screen.findByText("setup wizard")).toBeInTheDocument();
expect(screen.queryByText("login wall")).toBeNull();
expect(getAuthToken()).toBe("guest-token");
await waitFor(() => expect(localSession).toHaveBeenCalledOnce());
Expand Down Expand Up @@ -205,25 +207,71 @@ describe("AuthGuard local session", () => {
});

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

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

it("does not keep the /projects dump as home on first desktop launch", async () => {
getAuthStatus.mockResolvedValue({
setup_required: true,
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={["/projects?desktop=1"]}>
<Routes>
<Route
path="/projects"
element={
<AuthGuard>
<div>conversation list</div>
</AuthGuard>
}
/>
<Route path="/login" element={<div>login wall</div>} />
<Route path="/setup" element={<div>model setup</div>} />
<Route path="/chat/:agentId" element={<div>first agent</div>} />
</Routes>
</MemoryRouter>,
);

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

it("opens the studio door even when the organization room is available", async () => {
Expand Down Expand Up @@ -251,7 +299,7 @@ describe("AuthGuard local session", () => {

renderGuard();

expect(await screen.findByText("usable app")).toBeInTheDocument();
expect(await screen.findByText("setup wizard")).toBeInTheDocument();
expect(screen.queryByText("login wall")).toBeNull();
expect(getAuthToken()).toBe("guest-token");
});
Expand Down Expand Up @@ -282,4 +330,31 @@ describe("AuthGuard local session", () => {
expect(screen.queryByText("usable app")).toBeNull();
view.unmount();
});

it("keeps retrying after the router drops ?desktop=1", async () => {
sessionStorage.setItem("freeos:desktop-shell", "1");
getAuthStatus.mockResolvedValue({ setup_required: false, desktop: false });
localSession.mockRejectedValue(new Error("interactive login required"));

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

await waitFor(() => expect(localSession).toHaveBeenCalled());
expect(screen.queryByText("login wall")).toBeNull();
view.unmount();
});
});
50 changes: 20 additions & 30 deletions dashboard/src/components/AuthGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ 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 { needsDesktopModelOnboarding } from "../utils/desktopOnboarding";
import {
DESKTOP_MODEL_SETUP_PATH,
desktopPostSessionPath,
needsDesktopModelOnboarding,
} from "../utils/desktopOnboarding";
import { isDesktopShell } from "../utils/desktopShell";
import { CurrentUserProvider } from "../hooks/useCurrentUser";
import { AuthPromptProvider } from "../context/AuthPromptContext";
Expand Down Expand Up @@ -39,26 +43,19 @@ export default function AuthGuard({ children }: AuthGuardProps) {
}
};

const enterAfterSession = async (
me: OctopUser,
hasProviders: boolean,
desktop: boolean,
) => {
if (desktop && needsDesktopModelOnboarding(hasProviders)) {
if (!cancelled) navigate("/setup", { replace: true });
return;
}
const enterAfterSession = async (me: OctopUser, hasProviders: boolean) => {
// Open → optional model (skippable) → first agent. Never the login wall.
const next = desktopPostSessionPath(hasProviders);
if (!cancelled) navigate(next, { replace: true });
if (next === DESKTOP_MODEL_SETUP_PATH) return;
await adopt(me);
};

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

const holdForDesktop = async (
hasProviders = false,
desktop = shellDesktop,
) => {
const holdForDesktop = async (hasProviders = false) => {
while (!cancelled) {
if (await adoptLocal(hasProviders, desktop)) return;
if (await adoptLocal(hasProviders)) return;
await new Promise((resolve) => {
window.setTimeout(resolve, 400);
});
Expand All @@ -106,12 +99,11 @@ export default function AuthGuard({ children }: AuthGuardProps) {
desktop ? 20 : 4,
desktop ? 250 : 150,
hasProviders,
desktop,
)
)
return;
if (desktop) {
await holdForDesktop(hasProviders, desktop);
await holdForDesktop(hasProviders);
return;
}
clearAuthToken();
Expand All @@ -120,24 +112,23 @@ export default function AuthGuard({ children }: AuthGuardProps) {
}

if (desktop && needsDesktopModelOnboarding(hasProviders)) {
if (await tryLocalSession(20, 250, hasProviders, desktop)) return;
if (await tryLocalSession(20, 250, hasProviders)) return;
if (!cancelled) navigate("/setup", { replace: true });
return;
}

const token = getAuthToken();
const token = getAuthToken().trim();
if (!token) {
if (
await tryLocalSession(
desktop ? 20 : 4,
desktop ? 250 : 150,
hasProviders,
desktop,
)
)
return;
if (desktop) {
await holdForDesktop(hasProviders, desktop);
await holdForDesktop(hasProviders);
return;
}
if (!cancelled) {
Expand All @@ -156,12 +147,11 @@ export default function AuthGuard({ children }: AuthGuardProps) {
desktop ? 20 : 4,
desktop ? 250 : 150,
hasProviders,
desktop,
)
)
return;
if (desktop) {
await holdForDesktop(hasProviders, desktop);
await holdForDesktop(hasProviders);
return;
}
if (!cancelled) {
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/hooks/useUnauthorizedRedirect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { UNAUTHORIZED_EVENT } from "../api/request";
import { isDesktopShell } from "../utils/desktopShell";

/**
* Route an expired session to /login through the router.
Expand All @@ -15,6 +16,7 @@ export function useUnauthorizedRedirect(): void {
useEffect(() => {
const handler = (event: Event) => {
event.preventDefault();
if (isDesktopShell()) return;
navigate("/login", { replace: true });
};
window.addEventListener(UNAUTHORIZED_EVENT, handler);
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// registered synchronously before Chrome fires the event (which can happen
// before React mounts and useEffect runs).
import "./pwa-prompt";
import { isDesktopShell } from "./utils/desktopShell";

import { createRoot } from "react-dom/client";
import App from "./App.tsx";
Expand All @@ -14,6 +15,8 @@ import {
} from "./utils/reloadOnStaleChunk";

if (typeof window !== "undefined") {
// Capture `?desktop=1` before React Router replaces `/`.
isDesktopShell();
// Recover from post-deploy stale hashed chunks (white screen → soft reload).
installChunkLoadRecovery();

Expand Down
Loading
Loading