refactor(web): switch dashboard auth from tokens to passwords (#2608)

- replace token-based launcher auth with password-based login and sessions
- migrate legacy launcher_token values into bcrypt-backed password storage
- add one-shot local auto-login bootstrap
- update config UI, i18n strings, docs, and auth-related tests
This commit is contained in:
wenjie
2026-04-21 18:04:15 +08:00
committed by GitHub
parent a5379d5fff
commit 71c877a67f
34 changed files with 1188 additions and 585 deletions
+18 -4
View File
@@ -2,16 +2,26 @@
* Dashboard launcher auth API.
* Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages.
*/
export type LoginResult =
| { ok: true }
| { ok: false; status: number; error: string }
export async function postLauncherDashboardLogin(
password: string,
): Promise<boolean> {
): Promise<LoginResult> {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ password: password.trim() }),
})
return res.ok
if (res.ok) return { ok: true }
return {
ok: false,
status: res.status,
error: await readLauncherAuthError(res),
}
}
export type LauncherAuthStatus = {
@@ -57,12 +67,16 @@ export async function postLauncherDashboardSetup(
}),
})
if (res.ok) return { ok: true }
let msg = "Unknown error"
return { ok: false, error: await readLauncherAuthError(res) }
}
async function readLauncherAuthError(res: Response): Promise<string> {
let msg = `Request failed with status ${res.status}`
try {
const j = (await res.json()) as { error?: string }
if (j.error) msg = j.error
} catch {
/* ignore */
}
return { ok: false, error: msg }
return msg
}
-1
View File
@@ -11,7 +11,6 @@ export interface LauncherConfig {
port: number
public: boolean
allowed_cidrs: string[]
launcher_token: string
}
export interface SystemVersionInfo {
+16 -13
View File
@@ -295,6 +295,22 @@ export function AppHeader() {
</DropdownMenu>
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={toggleTheme}
>
{theme === "dark" ? (
<IconSun className="size-4.5" />
) : (
<IconMoon className="size-4.5" />
)}
</Button>
<Separator className="mx-2 my-2" orientation="vertical" />
{/* Logout */}
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<Button
@@ -309,19 +325,6 @@ export function AppHeader() {
</TooltipTrigger>
<TooltipContent>{t("header.logout.tooltip")}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={toggleTheme}
>
{theme === "dark" ? (
<IconSun className="size-4.5" />
) : (
<IconMoon className="size-4.5" />
)}
</Button>
</div>
</header>
)
@@ -7,6 +7,7 @@ import { toast } from "sonner"
import { patchAppConfig } from "@/api/channels"
import { launcherFetch } from "@/api/http"
import { postLauncherDashboardSetup } from "@/api/launcher-auth"
import {
getAutoStartStatus,
getLauncherConfig,
@@ -94,7 +95,8 @@ export function ConfigPage() {
port: String(launcherConfig.port),
publicAccess: launcherConfig.public,
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
launcherToken: launcherConfig.launcher_token ?? "",
dashboardPassword: "",
dashboardPasswordConfirm: "",
}
setLauncherForm(parsed)
setLauncherBaseline(parsed)
@@ -107,8 +109,14 @@ export function ConfigPage() {
}, [autoStartStatus])
const configDirty = JSON.stringify(form) !== JSON.stringify(baseline)
const launcherDirty =
JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline)
const launcherSettingsDirty =
launcherForm.port !== launcherBaseline.port ||
launcherForm.publicAccess !== launcherBaseline.publicAccess ||
launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText
const launcherPasswordDirty =
launcherForm.dashboardPassword.trim() !== "" ||
launcherForm.dashboardPasswordConfirm.trim() !== ""
const launcherDirty = launcherSettingsDirty || launcherPasswordDirty
const autoStartDirty = autoStartEnabled !== autoStartBaseline
const isDirty = configDirty || launcherDirty || autoStartDirty
@@ -143,6 +151,19 @@ export function ConfigPage() {
const handleSave = async () => {
try {
setSaving(true)
const password = launcherForm.dashboardPassword.trim()
const confirm = launcherForm.dashboardPasswordConfirm.trim()
if (launcherPasswordDirty) {
if (!password) {
throw new Error(t("pages.config.dashboard_password_required"))
}
if (password !== confirm) {
throw new Error(t("pages.config.dashboard_password_mismatch"))
}
if (Array.from(password).length < 8) {
throw new Error(t("pages.config.dashboard_password_min_length"))
}
}
if (configDirty) {
const workspace = form.workspace.trim()
@@ -255,7 +276,8 @@ export function ConfigPage() {
queryClient.invalidateQueries({ queryKey: ["config"] })
}
if (launcherDirty) {
let savedLauncherForm: LauncherForm | null = null
if (launcherSettingsDirty) {
const port = parseIntField(launcherForm.port, "Service port", {
min: 1,
max: 65535,
@@ -265,7 +287,6 @@ export function ConfigPage() {
port,
public: launcherForm.publicAccess,
allowed_cidrs: allowedCIDRs,
launcher_token: launcherForm.launcherToken.trim(),
})
const parsedLauncher: LauncherForm = {
port: String(savedLauncherConfig.port),
@@ -273,8 +294,10 @@ export function ConfigPage() {
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
"\n",
),
launcherToken: savedLauncherConfig.launcher_token ?? "",
dashboardPassword: "",
dashboardPasswordConfirm: "",
}
savedLauncherForm = parsedLauncher
setLauncherForm(parsedLauncher)
setLauncherBaseline(parsedLauncher)
queryClient.setQueryData(
@@ -283,6 +306,23 @@ export function ConfigPage() {
)
}
if (launcherPasswordDirty) {
const result = await postLauncherDashboardSetup(password, confirm)
if (!result.ok) {
throw new Error(result.error)
}
const clearedLauncherForm = savedLauncherForm ?? {
...launcherForm,
dashboardPassword: "",
dashboardPasswordConfirm: "",
}
setLauncherForm(clearedLauncherForm)
if (savedLauncherForm) {
setLauncherBaseline(savedLauncherForm)
}
}
if (autoStartDirty) {
if (!autoStartSupported) {
throw new Error(t("pages.config.autostart_unsupported"))
@@ -304,6 +344,22 @@ export function ConfigPage() {
}
}
const actionButtons = (
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={handleReset}
disabled={!isDirty || saving}
>
{t("common.reset")}
</Button>
<Button onClick={handleSave} disabled={!isDirty || saving}>
<IconDeviceFloppy className="size-4" />
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
)
return (
<div className="flex h-full flex-col">
<PageHeader
@@ -340,12 +396,6 @@ export function ConfigPage() {
</div>
) : (
<div className="space-y-6">
{isDirty && (
<div className="bg-yellow-50 px-3 py-2 text-sm text-yellow-700">
{t("pages.config.unsaved_changes")}
</div>
)}
<LauncherSection
launcherForm={launcherForm}
onFieldChange={updateLauncherField}
@@ -374,23 +424,21 @@ export function ConfigPage() {
onAutoStartChange={setAutoStartEnabled}
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={handleReset}
disabled={!isDirty || saving}
>
{t("common.reset")}
</Button>
<Button onClick={handleSave} disabled={!isDirty || saving}>
<IconDeviceFloppy className="size-4" />
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
{!isDirty && actionButtons}
</div>
)}
</div>
</div>
{isDirty && (
<div className="border-border/70 bg-background/95 supports-backdrop-filter:bg-background/80 shrink-0 border-t px-3 py-3 shadow-[0_-12px_30px_rgba(15,23,42,0.10)] backdrop-blur lg:px-6">
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-muted-foreground/70 text-xs">
{t("pages.config.unsaved_changes")}
</div>
{actionButtons}
</div>
</div>
)}
</div>
)
}
@@ -519,23 +519,48 @@ export function LauncherSection({
return (
<ConfigSectionCard
title={t("pages.config.sections.launcher")}
description={t("pages.config.launcher_token_section_hint")}
description={t("pages.config.launcher_section_hint")}
>
<Field
label={t("pages.config.launcher_token")}
hint={t("pages.config.launcher_token_hint")}
label={t("pages.config.dashboard_password")}
hint={t("pages.config.dashboard_password_hint")}
layout="setting-row"
controlClassName="md:max-w-md"
>
<Input
type="password"
value={launcherForm.launcherToken}
value={launcherForm.dashboardPassword}
disabled={disabled}
autoComplete="off"
placeholder={t("pages.config.launcher_token_placeholder")}
onChange={(e) => onFieldChange("launcherToken", e.target.value)}
autoComplete="new-password"
placeholder={t("pages.config.dashboard_password_placeholder")}
onChange={(e) =>
onFieldChange("dashboardPassword", e.target.value)
}
/>
</Field>
{launcherForm.dashboardPassword.trim() !== "" && (
<Field
label={t("pages.config.dashboard_password_confirm")}
hint={t("pages.config.dashboard_password_confirm_hint")}
layout="setting-row"
controlClassName="md:max-w-md"
>
<Input
type="password"
value={launcherForm.dashboardPasswordConfirm}
disabled={disabled}
autoComplete="new-password"
placeholder={t(
"pages.config.dashboard_password_confirm_placeholder",
)}
onChange={(e) =>
onFieldChange("dashboardPasswordConfirm", e.target.value)
}
/>
</Field>
)}
<SwitchCardField
label={t("pages.config.lan_access")}
hint={t("pages.config.lan_access_hint")}
@@ -30,7 +30,8 @@ export interface LauncherForm {
port: string
publicAccess: boolean
allowedCIDRsText: string
launcherToken: string
dashboardPassword: string
dashboardPasswordConfirm: string
}
export const DM_SCOPE_OPTIONS = [
@@ -94,7 +95,8 @@ export const EMPTY_LAUNCHER_FORM: LauncherForm = {
port: "18800",
publicAccess: false,
allowedCIDRsText: "",
launcherToken: "",
dashboardPassword: "",
dashboardPasswordConfirm: "",
}
function asRecord(value: unknown): JsonRecord {
+10 -4
View File
@@ -663,10 +663,16 @@
"autostart_load_error": "Failed to load launch-at-login status.",
"server_port": "Service Port",
"server_port_hint": "HTTP port used by PicoClaw Web.",
"launcher_token": "Login Token",
"launcher_token_section_hint": "Changes in this section take effect after the launcher restarts.",
"launcher_token_hint": "Used to sign in on the launcher login page.",
"launcher_token_placeholder": "Enter login token",
"launcher_section_hint": "Changes in this section take effect after the launcher restarts.",
"dashboard_password": "Login Password",
"dashboard_password_hint": "Set a new login password.",
"dashboard_password_placeholder": "At least 8 characters",
"dashboard_password_confirm": "Confirm New Password",
"dashboard_password_confirm_hint": "Enter the new login password again.",
"dashboard_password_confirm_placeholder": "Repeat password",
"dashboard_password_required": "Enter and confirm the new login password.",
"dashboard_password_mismatch": "The login passwords do not match.",
"dashboard_password_min_length": "Login password must be at least 8 characters.",
"lan_access": "Enable LAN Access",
"lan_access_hint": "Allow access from other devices on your local network.",
"allowed_cidrs": "Allowed Network CIDRs",
+10 -4
View File
@@ -663,10 +663,16 @@
"autostart_load_error": "加载开机自启状态失败",
"server_port": "服务端口",
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口",
"launcher_token": "登录令牌",
"launcher_token_section_hint": "此分组中的改动需要在重启 launcher 后生效",
"launcher_token_hint": "用于在 launcher 登录页进行登录",
"launcher_token_placeholder": "输入登录令牌",
"launcher_section_hint": "此分组中的改动需要在重启 launcher 后生效",
"dashboard_password": "登录密码",
"dashboard_password_hint": "设置新的登录密码",
"dashboard_password_placeholder": "至少 8 个字符",
"dashboard_password_confirm": "确认新密码",
"dashboard_password_confirm_hint": "再次输入新的登录密码",
"dashboard_password_confirm_placeholder": "再次输入密码",
"dashboard_password_required": "请输入并确认新的登录密码",
"dashboard_password_mismatch": "两次输入的登录密码不一致",
"dashboard_password_min_length": "登录密码至少需要 8 个字符",
"lan_access": "启用局域网访问",
"lan_access_hint": "允许局域网中的其他设备访问当前服务",
"allowed_cidrs": "允许访问网段",
+1 -2
View File
@@ -33,7 +33,6 @@ const RootLayout = () => {
const [authError, setAuthError] = useState<string | null>(null)
// Session guard: proactively check auth status on every page load.
// This catches the case where ?token= auto-login bypassed the login/setup UI.
useEffect(() => {
if (isAuthPage) return
void getLauncherAuthStatus()
@@ -55,7 +54,7 @@ const RootLayout = () => {
setAuthError(
err instanceof Error
? err.message
: "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.",
: "Auth service unavailable. Reset dashboard password storage and restart the application.",
)
}
})
+19 -11
View File
@@ -28,7 +28,7 @@ import { useTheme } from "@/hooks/use-theme"
function LauncherLoginPage() {
const { t, i18n } = useTranslation()
const { theme, toggleTheme } = useTheme()
const [token, setToken] = React.useState("")
const [password, setPassword] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("")
@@ -45,17 +45,25 @@ function LauncherLoginPage() {
})
}, [])
const loginWithToken = React.useCallback(
async (tokenValue: string) => {
const loginWithPassword = React.useCallback(
async (passwordValue: string) => {
setError("")
setSubmitting(true)
try {
const ok = await postLauncherDashboardLogin(tokenValue)
if (ok) {
const result = await postLauncherDashboardLogin(passwordValue)
if (result.ok) {
globalThis.location.assign("/")
return
}
setError(t("launcherLogin.errorInvalid"))
if (result.status === 409) {
globalThis.location.assign("/launcher-setup")
return
}
if (result.status === 401) {
setError(t("launcherLogin.errorInvalid"))
return
}
setError(result.error)
} catch {
setError(t("launcherLogin.errorNetwork"))
} finally {
@@ -67,7 +75,7 @@ function LauncherLoginPage() {
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
await loginWithToken(token)
await loginWithPassword(password)
}
return (
@@ -112,17 +120,17 @@ function LauncherLoginPage() {
<CardContent>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="launcher-token">
<Label htmlFor="launcher-password">
{t("launcherLogin.passwordLabel")}
</Label>
<Input
id="launcher-token"
id="launcher-password"
name="password"
type="password"
autoComplete="current-password"
required
value={token}
onChange={(e) => setToken(e.target.value)}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("launcherLogin.passwordPlaceholder")}
/>
</div>