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

## [Unreleased]

### 变更

- First launch on desktop / loopback opens a local guest session — no login wall. Register or sign in only when saving, exporting, or publishing to an account.
- Login, splash, favicons, and desktop icons use the circular XYAI mark. Product version remains 0.0.1.

## [0.0.1] - 2026-09-15

当前 FreeOS 产品版本。`1.0.0` 对本阶段过早;后续按 [semver](https://semver.org/spec/v2.0.0.html) 随产品成熟度递增。规范来源是 `pyproject.toml`(同步 `octop.__version__`、桌面 / NSIS / FnOS 回退值,以及 CI 产物名)。发布工作流需要标签时使用 `v0.0.1`;不重写已推送的历史 tag。
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ Operator detail: [docs/asset-loop.md](docs/asset-loop.md).
`FreeOS-desktop-windows-amd64-<version>.exe`(普通 64 位电脑)或
`FreeOS-desktop-windows-arm64-<version>.exe`(ARM 电脑)。
2. 双击安装包。安装程序会放到「程序文件」并创建开始菜单和桌面快捷方式。
3. 打开 **FreeOS**。第一次启动会解压内置运行环境(可能要一两分钟),然后出现设置向导。
4. 设好管理员密码后即可聊天。组织控制台(openXYOS)已随安装包内置,无需再装 Node;侧栏 **Organization** 默认打开。
3. 打开 **FreeOS**。第一次启动会解压内置运行环境(可能要一两分钟),然后直接进入可用会话,无需先登录。
4. 保存、导出或发布到账号时再注册或登录。组织控制台(openXYOS)已随安装包内置,无需再装 Node;侧栏 **Organization** 默认打开。

数据目录默认是 `%USERPROFILE%\.freeos`(可用环境变量 `FREEOS_HOME` 改)。旧版 Octop 的 `~/.octop` 仍会被识别。

Expand Down
2 changes: 1 addition & 1 deletion dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@
<div id="octop-boot-ring"></div>
<img
id="octop-boot-logo"
src="/logo.svg"
src="/xyai-mark.png"
alt=""
width="60"
height="60"
Expand Down
Binary file modified dashboard/public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified dashboard/public/favicon-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified dashboard/public/favicon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified dashboard/public/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion dashboard/public/offline.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
</head>
<body>
<div class="container">
<img src="logo.svg" alt="FreeOS" class="logo" />
<img src="xyai-mark.png" alt="FreeOS" class="logo" />
<h1>当前处于离线状态</h1>
<p>无法连接到 FreeOS 服务端。<br />请检查网络连接后重试。</p>
<button onclick="location.reload()">重新连接</button>
Expand Down
Binary file modified dashboard/public/pwa-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified dashboard/public/pwa-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added dashboard/public/xyai-mark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions dashboard/src/api/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface OctopUser {
locale: string;
/** Module permission keys; admin responses include the full catalog. */
permissions?: string[];
/** True while the desktop/loopback guest has not registered. */
is_local?: boolean;
}

export interface LoginResponse {
Expand Down Expand Up @@ -153,6 +155,31 @@ export const authApi = {
return { ...raw, token: raw.access_token };
},

/** Desktop / loopback guest or single-user JWT (no login form). */
localSession: async (): Promise<LoginResponse> => {
const raw = await request<RawLoginResponse>("/auth/local-session", {
method: "POST",
});
return { ...raw, token: raw.access_token };
},

/** Claim the local guest session with a username and password. */
register: async (
username: string,
password: string,
displayName?: string | null,
): Promise<LoginResponse> => {
const raw = await request<RawLoginResponse>("/auth/register", {
method: "POST",
body: JSON.stringify({
username,
password,
display_name: displayName ?? null,
}),
});
return { ...raw, token: raw.access_token };
},

/** Return whether the configured OIDC provider can accept logins. */
getOidcStatus: () => request<OidcStatus>("/auth/oidc/status"),

Expand Down
200 changes: 200 additions & 0 deletions dashboard/src/components/AuthForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { useState } from "react";
import { Input, Button } from "antd";
import { message } from "@/utils/antdMessage";
import { Lock, User } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getAuthToken, setAuthToken } from "../api";
import {
authApi,
type LoginResponse,
type OidcStatus,
} from "../api/modules/auth";
import { apiErrorMessage } from "../utils/apiError";
import { refreshServerLabels } from "../i18n";
import { applyUserLocale } from "../utils/locale";
import {
MIN_PASSWORD_LENGTH,
passwordPolicyIssue,
} from "../utils/passwordPolicy";
import SlideCaptcha from "../pages/Login/SlideCaptcha";

export type AuthFormMode = "login" | "register";

interface AuthFormProps {
mode: AuthFormMode;
onModeChange?: (mode: AuthFormMode) => void;
onSuccess: (res: LoginResponse) => void;
oidc?: OidcStatus | null;
allowRegister?: boolean;
}

export default function AuthForm({
mode,
onModeChange,
onSuccess,
oidc,
allowRegister = true,
}: AuthFormProps) {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const [slideVerified, setSlideVerified] = useState(false);
const [slideResetKey, setSlideResetKey] = useState(0);

const resetSlide = () => {
setSlideVerified(false);
setSlideResetKey((k) => k + 1);
};

const onOidc = async () => {
setOidcLoading(true);
try {
const { authorization_url } = await authApi.startOidc("/chat");
window.location.href = authorization_url;
} catch (err) {
message.error(apiErrorMessage(err, t("login.oidcStartFailed"), t));
setOidcLoading(false);
}
};

const applySession = async (res: LoginResponse) => {
setAuthToken(res.access_token);
await applyUserLocale(res.user.locale);
void refreshServerLabels(res.user.locale);
onSuccess(res);
};

const handleSubmit = async () => {
if (!username || !password || !slideVerified) return;
if (mode === "register") {
const issue = passwordPolicyIssue(password);
if (issue) {
message.error(
issue === "too_short"
? t("account.passwordTooShort", { min: MIN_PASSWORD_LENGTH })
: t("account.passwordTooWeak"),
);
return;
}
}
setLoading(true);
try {
if (mode === "register" && !getAuthToken()) {
const guest = await authApi.localSession();
setAuthToken(guest.access_token);
}
const res =
mode === "register"
? await authApi.register(username, password)
: await authApi.login(username, password);
await applySession(res);
} catch (err) {
message.error(
apiErrorMessage(
err,
mode === "register" ? t("login.registerFailed") : t("login.failed"),
t,
),
);
resetSlide();
} finally {
setLoading(false);
}
};

return (
<>
<Input
prefix={
<User size={16} style={{ color: "var(--fn-text-quaternary)" }} />
}
placeholder={t("login.username")}
size="large"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
style={{ borderRadius: 10 }}
/>
<Input.Password
prefix={
<Lock size={16} style={{ color: "var(--fn-text-quaternary)" }} />
}
placeholder={t("login.password")}
size="large"
value={password}
onChange={(e) => setPassword(e.target.value)}
onPressEnter={() => void handleSubmit()}
style={{ borderRadius: 10 }}
/>
<SlideCaptcha
hint={t("login.slideHint")}
verifiedLabel={t("login.slideVerified")}
onVerified={() => setSlideVerified(true)}
resetKey={slideResetKey}
/>
<Button
type="primary"
size="large"
block
loading={loading}
onClick={() => void handleSubmit()}
disabled={!username || !password || !slideVerified}
style={{ borderRadius: 10, height: 44, fontWeight: 500 }}
>
{mode === "register" ? t("login.registerSubmit") : t("login.submit")}
</Button>
{allowRegister && onModeChange ? (
<Button
type="link"
block
onClick={() => onModeChange(mode === "login" ? "register" : "login")}
>
{mode === "login"
? t("login.switchToRegister")
: t("login.switchToLogin")}
</Button>
) : null}
{oidc?.enabled && mode === "login" ? (
<>
<div
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 12,
color: "var(--fn-text-tertiary)",
fontSize: 13,
}}
>
<span
style={{
flex: 1,
height: 1,
background: "var(--fn-border-primary)",
}}
/>
{t("login.or")}
<span
style={{
flex: 1,
height: 1,
background: "var(--fn-border-primary)",
}}
/>
</div>
<Button
size="large"
block
loading={oidcLoading}
onClick={() => void onOidc()}
style={{ borderRadius: 10, height: 44, fontWeight: 500 }}
>
{t("login.oidcWith", { name: oidc.display_name })}
</Button>
</>
) : null}
</>
);
}
89 changes: 89 additions & 0 deletions dashboard/src/components/AuthGuard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";

const localSession = vi.fn();
const getAuthStatus = vi.fn();
const me = vi.fn();

vi.mock("../api/modules/auth", () => ({
authApi: {
localSession: (...args: unknown[]) => localSession(...args),
getAuthStatus: (...args: unknown[]) => getAuthStatus(...args),
me: (...args: unknown[]) => me(...args),
},
}));

vi.mock("../utils/locale", () => ({
applyUserLocale: vi.fn(async () => undefined),
}));

vi.mock("../context/AuthPromptContext", () => ({
AuthPromptProvider: ({ children }: { children: React.ReactNode }) => children,
}));

import AuthGuard from "./AuthGuard";
import { getAuthToken } from "../api/request";

function renderGuard() {
return 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>,
);
}

describe("AuthGuard local session", () => {
beforeEach(() => {
localStorage.clear();
localSession.mockReset();
getAuthStatus.mockReset();
me.mockReset();
});

it("opens the app without the login wall on first launch", async () => {
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");
await waitFor(() => expect(localSession).toHaveBeenCalledOnce());
});

it("falls back to login when local session is unavailable", async () => {
getAuthStatus.mockResolvedValue({ setup_required: false });
localSession.mockRejectedValue(new Error("interactive login required"));

renderGuard();

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