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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user