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 .env.examples
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DAIS_DESKTOP_AUTH_TOKEN=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ openapi.json

/target/
/node-bin/
.env


# Nx
Expand Down
23 changes: 22 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ ORM:SQLAlchemy + Alembic
仅前端使用的数据直接使用 zustand 的 persist 存储在 localStorage 中。
对于前后端共享数据,如工作区设置、Agent 配置、用户会话数据等,通过后端存储在 SQLite 数据库中。

### 桌面应用与远程访问

- Dais 是一个 Tauri 桌面应用,Python FastAPI server 作为 Sidecar 随桌面应用运行;前端既可由 Tauri WebView 加载,也可通过 server 暴露的 Web 端口由局域网浏览器访问。
- 远程访问复用与桌面端相同的前端应用和后端 API,不是独立的 Web 部署。开发功能时需要同时考虑 Tauri WebView 和普通浏览器两种运行环境,不能假设前端始终运行在 Tauri 中。
- 桌面应用负责控制远程监听是否启用及监听端口。关闭远程访问表示停止从 Web 端口接受远程连接,不等同于删除持久化的浏览器会话。
- 未认证浏览器不得初始化主应用、请求受保护业务数据或建立 SSE;Tauri 桌面端不经过浏览器登录门禁。

### 访问鉴权

- 后端 `/api` 接口默认需要鉴权,支持两种独立认证通道:Tauri 桌面端通过 `X-Dais-Desktop-Token` 请求头认证,远程浏览器通过 `dais_browser_session` HttpOnly Cookie 认证。
- 匿名 API 必须是明确且必要的例外;当前公开接口和桌面专属接口由 `AuthenticationMiddleware` 按 HTTP method 与精确 path 维护。新增 API 不应默认公开。
- 桌面专属 API 只能接受桌面 Token,不能接受浏览器会话 Cookie;浏览器登录码生成属于桌面专属能力。
- 前端 API 请求必须复用统一的 Orval mutator,不要在业务组件中手工添加桌面 Token;浏览器 Cookie 请求必须保留 `credentials: "same-origin"`。
- SSE 与普通 API 使用相同的认证语义,必须携带对应凭证;浏览器会话失效时应停止连接并返回 Auth Gate。
- 浏览器认证状态以 HttpOnly Cookie 和后端 SQLite 会话记录为准,不得将认证 Token 或额外认证状态持久化到 localStorage 或 Zustand。
- 六位浏览器登录码必须始终按字符串处理以保留前导零,不得转换为 number。登录码、Cookie Token 及完整 Token 摘要不得进入日志、toast、持久化状态或分析事件。

## 开发指南

### 上下文信息要求
Expand All @@ -57,7 +74,11 @@ ORM:SQLAlchemy + Alembic
- **组件编写**: 统一使用 `function Component(props: Props) {}` 格式编写,不要使用箭头函数
- **图标组件使用**: 统一使用 lucide-react 库提供的图标组件,且使用时需要带 `Icon` 后缀。(例如,使用 `Plus` 图标应该导入 `PlusIcon`)
- **dialog 组件使用**: 在创建通用 dialog 组件时,需要基于 shadcn 提供的 dialog 组件创建,同时必须使用 DialogTrigger 来控制 dialog 状态,不要在 dialog 组件外额外使用 useState 管理 dialog 状态
- **错误处理**: 在出现错误时统一使用 toast 组件展示
- **错误处理**:
- 一般 API 错误使用 toast 展示。
- `401 UNAUTHENTICATED` 在浏览器环境中由 Auth Gate 统一处理,不显示通用错误 toast。
- 可操作的表单校验错误应显示在对应字段或表单中,例如 `LOGIN_CODE_INVALID`,不要同时显示全局 toast。


#### 后端代码风格

Expand Down
2 changes: 1 addition & 1 deletion src-frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="../public/logo.png" />
<link rel="icon" type="image/svg+xml" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dais</title>
</head>
Expand Down
1 change: 1 addition & 0 deletions src-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"howler": "^2.2.4",
"i18next": "^25.8.14",
"immer": "^11.0.1",
"input-otp": "^1.5.0",
"lucide-react": "^0.548.0",
"motion": "^12.42.0",
"nanoid": "^5.1.6",
Expand Down
14 changes: 14 additions & 0 deletions src-frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 1 addition & 13 deletions src-frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,10 @@
import { Suspense, useEffect } from "react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Layout, LayoutSkeleton } from "@/features/Layouts";
import { GlobalShortcutsProvider } from "@/hooks/use-global-shortcuts";
import { applyTheme } from "@/lib/apply-theme";
import sseDispatcher, { SSE_ENDPOINT } from "@/lib/sse-dispatcher";
import { useSettingsStore } from "@/stores/settings-store";
import { i18n, resolveLanguage } from "@/i18n";
import { Layout, LayoutSkeleton } from "@/features/Layouts";

function App() {
const { current: { theme, language } } = useSettingsStore();
useEffect(() => applyTheme(theme), [theme]);

useEffect(() => {
const nextLanguage = resolveLanguage(language);
if (i18n.resolvedLanguage === nextLanguage) return;
i18n.changeLanguage(nextLanguage);
}, [language]);

useEffect(() => {
sseDispatcher.connect(SSE_ENDPOINT);
return () => sseDispatcher.disconnect();
Expand Down
13 changes: 13 additions & 0 deletions src-frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import queryClient from "@/query-client";
import { getGetAuthSessionQueryKey } from "./generated/endpoints/auth/auth";

export {
useBrowserLogin,
useCreateLoginCode,
useDeleteAuthSession,
useGetAuthSessionSuspense,
} from "./generated/endpoints/auth/auth";

export async function resetAuthSessionQuery() {
await queryClient.resetQueries({ queryKey: getGetAuthSessionQueryKey() });
}
6 changes: 5 additions & 1 deletion src-frontend/src/api/orval-mutator/custom-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CLIENT_ID_HEADER, clientId } from "@/lib/client-id";
import { getDesktopAuthHeaders } from "@/lib/desktop-auth";

import { API_BASE } from "..";
import type {
Expand Down Expand Up @@ -40,7 +41,10 @@ export async function fetchApi<T>(
const requestInit = (() => {
const headers = new Headers(init?.headers);
headers.set(CLIENT_ID_HEADER, clientId);
return { ...init, headers };
for (const [name, value] of Object.entries(getDesktopAuthHeaders())) {
headers.set(name, value);
}
return { ...init, headers, credentials: "same-origin" as const };
})();

let res: Response;
Expand Down
74 changes: 74 additions & 0 deletions src-frontend/src/components/ui/input-otp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as React from "react"
import { cn } from "cn"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"

function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}

function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}

function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}

return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
}

function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}

export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
69 changes: 69 additions & 0 deletions src-frontend/src/features/BrowserAuth/BrowserAuthGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { use } from "react";
import { useTranslation } from "react-i18next";
import { BackendReadyPromise } from "@/api";
import { useGetAuthSessionSuspense } from "@/api/auth";
import { FetchError } from "@/api/orval-mutator/custom-fetch";
import { AsyncBoundary } from "@/components/custom/AsyncBoundary";
import { FailedToLoad } from "@/components/custom/FailedToLoad";
import { BROWSER_AUTH_NAMESPACE } from "@/i18n/resources";
import { isTauri } from "@/lib/tauri";
import { useServerSettingsStore } from "@/stores/server-settings-store";
import { LayoutSkeleton } from "../Layouts";
import { BrowserLoginView } from "./BrowserLoginView";

function BrowserAuthGateContent({ children }: BrowserAuthGateProps) {
use(BackendReadyPromise);

useGetAuthSessionSuspense({
query: {
refetchOnWindowFocus: false,
retry: false,
},
});

return children;
}

type BrowserAuthGateProps = {
children: React.ReactNode;
};

export function BrowserAuthGate({ children }: BrowserAuthGateProps) {
const { t } = useTranslation(BROWSER_AUTH_NAMESPACE);
const reloadServerSettings = useServerSettingsStore((s) => s.reload);

if (isTauri) return children;

return (
<AsyncBoundary
skeleton={<LayoutSkeleton />}
errorRender={({ error, resetErrorBoundary }) => {
const isUnauthenticated =
error instanceof FetchError && error.statusCode === 401 && error.errorCode === "UNAUTHENTICATED";

if (isUnauthenticated) {
return (
<div className="h-screen flex items-center justify-center bg-muted/30">
<BrowserLoginView onAuthenticated={() => {
reloadServerSettings();
resetErrorBoundary();
}} />
</div>
);
}

return (
<main className="h-screen flex items-center justify-center bg-muted/30">
<FailedToLoad
title={t("session_check.error.title")}
description={t("session_check.error.description")}
retry={resetErrorBoundary}
/>
</main>
);
}}
>
<BrowserAuthGateContent>{children}</BrowserAuthGateContent>
</AsyncBoundary>
);
}
64 changes: 64 additions & 0 deletions src-frontend/src/features/BrowserAuth/BrowserLoginView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import logo from "@shared/icon-square.png";
import { Loader2Icon } from "lucide-react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { resetAuthSessionQuery, useBrowserLogin } from "@/api/auth";
import { FetchError } from "@/api/orval-mutator/custom-fetch";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { BROWSER_AUTH_NAMESPACE } from "@/i18n/resources";
import { LoginCodeField } from "./fields/LoginCodeField";

export type BrowserLoginFormValues = {
code: string;
};

type BrowserLoginViewProps = {
onAuthenticated: () => void;
};

export function BrowserLoginView({ onAuthenticated }: BrowserLoginViewProps) {
const { t } = useTranslation(BROWSER_AUTH_NAMESPACE);
const form = useForm<BrowserLoginFormValues>({
defaultValues: { code: "" },
});
const loginMutation = useBrowserLogin({
mutation: {
onError: (error) => {
if (error instanceof FetchError && error.errorCode === "LOGIN_CODE_INVALID") {
form.setError("code", { message: t("login.code.error.invalid") });
}
},
onSuccess: async () => {
form.reset();
await resetAuthSessionQuery()
await onAuthenticated();
},
},
});

const handleSubmit = (values: BrowserLoginFormValues) => {
form.clearErrors("code");
loginMutation.mutate({ data: values });
};

return (
<Card className="w-fit py-8">
<CardHeader className="flex flex-col items-center">
<img alt="Dais" src={logo} className="size-16 rounded-lg" />
<h1 className="text-xl font-semibold">{t("login.title")}</h1>
</CardHeader>
<CardContent>
<FormProvider {...form}>
<form className="flex flex-col gap-7" onSubmit={form.handleSubmit(handleSubmit)}>
<LoginCodeField />
<Button className="w-full rounded-md" disabled={loginMutation.isPending} type="submit">
{loginMutation.isPending && <Loader2Icon className="animate-spin" />}
{t("login.submit_button")}
</Button>
</form>
</FormProvider>
</CardContent>
</Card>
);
}
Loading
Loading