mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface LauncherConfig {
|
||||
port: number
|
||||
public: boolean
|
||||
allowed_cidrs: string[]
|
||||
launcher_token: string
|
||||
}
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "允许访问网段",
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user