Skip to content
Closed
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
75 changes: 73 additions & 2 deletions console/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { createGlobalStyle } from "antd-style";
import { ConfigProvider, bailianTheme } from "@agentscope-ai/design";
import { BrowserRouter } from "react-router-dom";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useEffect, useState } from "react";
import MainLayout from "./layouts/MainLayout";
import LoginPage from "./pages/Login";
import { authApi } from "./api/modules/auth";
import { getApiUrl, getApiToken, clearAuthToken } from "./api/config";
import "./styles/layout.css";
import "./styles/form-override.css";

Expand All @@ -12,12 +16,79 @@ const GlobalStyle = createGlobalStyle`
}
`;

function AuthGuard({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState<"loading" | "auth-required" | "ok">(
"loading",
);

useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await authApi.getStatus();
if (cancelled) return;
if (!res.enabled) {
setStatus("ok");
return;
}
const token = getApiToken();
if (!token) {
setStatus("auth-required");
return;
}
try {
const r = await fetch(getApiUrl("/auth/verify"), {
headers: { Authorization: `Bearer ${token}` },
});
if (cancelled) return;
if (r.ok) {
setStatus("ok");
} else {
clearAuthToken();
setStatus("auth-required");
}
} catch {
if (!cancelled) {
clearAuthToken();
setStatus("auth-required");
}
}
} catch {
if (!cancelled) setStatus("ok");
}
})();
return () => {
cancelled = true;
};
}, []);

if (status === "loading") return null;
if (status === "auth-required")
return (
<Navigate
to={`/login?redirect=${encodeURIComponent(window.location.pathname)}`}
replace
/>
);
return <>{children}</>;
}

function App() {
return (
<BrowserRouter>
<GlobalStyle />
<ConfigProvider {...bailianTheme} prefix="copaw" prefixCls="copaw">
<MainLayout />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={
<AuthGuard>
<MainLayout />
</AuthGuard>
}
/>
</Routes>
</ConfigProvider>
</BrowserRouter>
);
Expand Down
21 changes: 20 additions & 1 deletion console/src/api/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
declare const BASE_URL: string;
declare const TOKEN: string;

const AUTH_TOKEN_KEY = "copaw_auth_token";

/**
* Get the full API URL with /api prefix
* @param path - API path (e.g., "/models", "/skills")
Expand All @@ -14,9 +16,26 @@ export function getApiUrl(path: string): string {
}

/**
* Get the API token
* Get the API token - checks localStorage first (auth login),
* then falls back to the build-time TOKEN constant.
* @returns API token string or empty string
*/
export function getApiToken(): string {
const stored = localStorage.getItem(AUTH_TOKEN_KEY);
if (stored) return stored;
return typeof TOKEN !== "undefined" ? TOKEN : "";
}

/**
* Store the auth token in localStorage after login.
*/
export function setAuthToken(token: string): void {
localStorage.setItem(AUTH_TOKEN_KEY, token);
}

/**
* Remove the auth token from localStorage (logout / 401).
*/
export function clearAuthToken(): void {
localStorage.removeItem(AUTH_TOKEN_KEY);
}
52 changes: 52 additions & 0 deletions console/src/api/modules/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { getApiUrl } from "../config";

export interface LoginResponse {
token: string;
username: string;
message?: string;
}

export interface AuthStatusResponse {
enabled: boolean;
has_users: boolean;
}

export const authApi = {
login: async (
username: string,
password: string,
): Promise<LoginResponse> => {
const res = await fetch(getApiUrl("/auth/login"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Login failed");
}
return res.json();
},

register: async (
username: string,
password: string,
): Promise<LoginResponse> => {
const res = await fetch(getApiUrl("/auth/register"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Registration failed");
}
return res.json();
},

getStatus: async (): Promise<AuthStatusResponse> => {
const res = await fetch(getApiUrl("/auth/status"));
if (!res.ok) throw new Error("Failed to check auth status");
return res.json();
},
};
11 changes: 10 additions & 1 deletion console/src/api/request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getApiUrl, getApiToken } from "./config";
import { getApiUrl, getApiToken, clearAuthToken } from "./config";

function buildHeaders(method?: string, extra?: HeadersInit): Headers {
// Normalize extra to a Headers instance for consistent handling
Expand Down Expand Up @@ -35,6 +35,15 @@ export async function request<T = unknown>(
});

if (!response.ok) {
// Handle 401: clear token and redirect to login
if (response.status === 401) {
clearAuthToken();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
throw new Error("Not authenticated");
}

const text = await response.text().catch(() => "");
throw new Error(
`Request failed: ${response.status} ${response.statusText}${
Expand Down
33 changes: 33 additions & 0 deletions console/src/layouts/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ import {
Copy,
Check,
BarChart3,
LogOut,
} from "lucide-react";
import api from "../api";
import { clearAuthToken } from "../api/config";
import { authApi } from "../api/modules/auth";
import styles from "./index.module.less";

const { Sider } = Layout;
Expand Down Expand Up @@ -199,6 +202,14 @@ export default function Sidebar({ selectedKey }: SidebarProps) {
const [allVersions, setAllVersions] = useState<string[]>([]);
const [updateModalOpen, setUpdateModalOpen] = useState(false);
const [updateMarkdown, setUpdateMarkdown] = useState<string>("");
const [authEnabled, setAuthEnabled] = useState(false);

useEffect(() => {
authApi
.getStatus()
.then((res) => setAuthEnabled(res.enabled))
.catch(() => {});
}, []);

useEffect(() => {
if (!collapsed) {
Expand Down Expand Up @@ -410,6 +421,28 @@ export default function Sidebar({ selectedKey }: SidebarProps) {
items={menuItems}
/>

{authEnabled && (
<div style={{ padding: "12px 16px", borderTop: "1px solid #f0f0f0" }}>
<Button
type="text"
icon={<LogOut size={16} />}
onClick={() => {
clearAuthToken();
window.location.href = "/login";
}}
block
style={{
display: "flex",
alignItems: "center",
gap: 8,
justifyContent: collapsed ? "center" : "flex-start",
}}
>
{!collapsed && t("login.logout")}
</Button>
</div>
)}

<Modal
open={updateModalOpen}
onCancel={() => setUpdateModalOpen(false)}
Expand Down
18 changes: 18 additions & 0 deletions console/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,5 +637,23 @@
"TOOL_CMD_DANGEROUS_MV": "Detects 'mv' command that may move or overwrite files unexpectedly"
}
}
},
"login": {
"title": "Login to CoPaw",
"registerTitle": "Create Account",
"firstUserHint": "Create the first account to get started",
"usernamePlaceholder": "Username",
"passwordPlaceholder": "Password",
"usernameRequired": "Please enter your username",
"passwordRequired": "Please enter your password",
"submit": "Login",
"register": "Register",
"failed": "Invalid username or password",
"registerSuccess": "Account created successfully",
"registerFailed": "Registration failed",
"authNotEnabled": "Authentication is not enabled",
"logout": "Logout",
"switchToLogin": "Already have an account? Login",
"switchToRegister": "Create a new account"
}
}
18 changes: 18 additions & 0 deletions console/src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,5 +637,23 @@
"TOOL_CMD_DANGEROUS_MV": "ファイルを意図せず移動・上書きする可能性のある mv コマンドを検出"
}
}
},
"login": {
"title": "CoPaw にログイン",
"registerTitle": "アカウント作成",
"firstUserHint": "最初のアカウントを作成して始めましょう",
"usernamePlaceholder": "ユーザー名",
"passwordPlaceholder": "パスワード",
"usernameRequired": "ユーザー名を入力してください",
"passwordRequired": "パスワードを入力してください",
"submit": "ログイン",
"register": "登録",
"failed": "ユーザー名またはパスワードが正しくありません",
"registerSuccess": "アカウントが作成されました",
"registerFailed": "登録に失敗しました",
"authNotEnabled": "認証は有効になっていません",
"logout": "ログアウト",
"switchToLogin": "アカウントをお持ちですか?ログイン",
"switchToRegister": "新しいアカウントを作成"
}
}
18 changes: 18 additions & 0 deletions console/src/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,5 +637,23 @@
"TOOL_CMD_DANGEROUS_MV": "Обнаруживает команду mv, которая может неожиданно переместить или перезаписать файлы"
}
}
},
"login": {
"title": "Вход в CoPaw",
"registerTitle": "Создать аккаунт",
"firstUserHint": "Создайте первый аккаунт для начала работы",
"usernamePlaceholder": "Имя пользователя",
"passwordPlaceholder": "Пароль",
"usernameRequired": "Введите имя пользователя",
"passwordRequired": "Введите пароль",
"submit": "Войти",
"register": "Регистрация",
"failed": "Неверное имя пользователя или пароль",
"registerSuccess": "Аккаунт успешно создан",
"registerFailed": "Ошибка регистрации",
"authNotEnabled": "Аутентификация не включена",
"logout": "Выйти",
"switchToLogin": "Уже есть аккаунт? Войти",
"switchToRegister": "Создать новый аккаунт"
}
}
18 changes: 18 additions & 0 deletions console/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,5 +637,23 @@
"TOOL_CMD_DANGEROUS_MV": "检测可能意外移动或覆盖文件的 mv 命令"
}
}
},
"login": {
"title": "登录 CoPaw",
"registerTitle": "创建账号",
"firstUserHint": "创建第一个账号以开始使用",
"usernamePlaceholder": "用户名",
"passwordPlaceholder": "密码",
"usernameRequired": "请输入用户名",
"passwordRequired": "请输入密码",
"submit": "登录",
"register": "注册",
"failed": "用户名或密码错误",
"registerSuccess": "账号创建成功",
"registerFailed": "注册失败",
"authNotEnabled": "认证未启用",
"logout": "退出登录",
"switchToLogin": "已有账号?去登录",
"switchToRegister": "创建新账号"
}
}
Loading