picoclaw/web/frontend/src/routes/launcher-setup.tsx

147 lines
6 KiB
TypeScript
Raw Normal View History

feat(launcher): standard HTTP login/setup/logout flow for dashboard, frontend and backend impl. and fix windows pid lock for ws (#2339) * feat(launcher): replace token-in-logs auth with standard HTTP login flow ## Problem Previously users had to find the one-time token from console logs or log files to access the dashboard - a non-standard, error-prone workflow with no clear path for changing credentials. ## Solution: standard HTTP API login with bcrypt-backed password store ### Auth flow (new) 1. First run: browser opens, session guard detects uninitialized state, redirects to /launcher-setup 2. User sets a password (min 8 chars) via POST /api/auth/setup {password, confirm}, bcrypt(cost=12) hash stored in ~/.picoclaw/launcher-auth.db (SQLite) 3. Subsequent logins: POST /api/auth/login {password}, HttpOnly cookie picoclaw_launcher_auth (HMAC-SHA256 signed, 7-day expiry) 4. 401 on any API call, frontend redirects to /launcher-login 5. Logout: POST /api/auth/logout, cookie cleared, redirect to login ### Backend changes - web/backend/api/auth.go: renamed Token to Password; added handleSetup; launcherAuthStatusResponse now includes Initialized bool; PasswordStore interface wires bcrypt store into handlers - web/backend/dashboardauth/: new package - Store with New(dir) / Open(path); SetPassword (bcrypt cost=12), VerifyPassword, IsInitialized - sql.go: all DB-layer constants (DBFilename, sqliteDriver, bcryptCost, four SQL query strings) - compile-time constants, zero runtime overhead - web/backend/middleware/launcher_dashboard_auth.go: /launcher-setup and /api/auth/setup added to public paths - web/backend/main.go: - dashboardauth.New(picoHome) replaces manual path construction - maskSecret(): suffix only revealed when >=5 chars hidden (length >= 12), preventing 8-char minimum passwords from leaking their tail - web/backend/main_test.go: TestMaskSecret updated with boundary cases ### Forward-compatibility: pkg/credential integration If the dashboard password is later reused as the enc:// passphrase, the bcrypt hash in launcher-auth.db becomes an offline oracle. Recommended mitigation (not yet implemented): derive two independent subkeys via HKDF before use: bcrypt(HKDF(password, info="picoclaw-dashboard-login-v1")) stored in DB HKDF(password, info="picoclaw-credential-enc-v1") passed to PassphraseProvider This isolates the two domains: cracking the bcrypt hash yields only the login subkey, which is computationally independent of the enc:// subkey. * fix(auth): replace wastedassign ok := false with var ok bool * refactor(tray): remove copy-token clipboard feature Dashboard login now uses standard web auth (bcrypt + session cookie). The system tray 'Copy dashboard token' menu item is no longer needed. - Delete tray_offers_copy.go and tray_offers_copy_stub.go - Remove mCopyTok menu item and clipboard handler from systray.go - Remove launcherDashboardTokenForClipboard var from main.go - Remove MenuCopyToken/MenuCopyTokenHint keys from i18n.go * feat(launcher-ui): standard HTTP login/setup/logout flow for dashboard Replaces the previous "find token in logs" workflow with a proper browser-based authentication UI backed by the new /api/auth/* endpoints. ### New pages - /launcher-setup: first-run password initialization form (password + confirm, min 8 chars); calls POST /api/auth/setup; redirects to login on success - /launcher-login: standard password login form; calls POST /api/auth/login; sets HttpOnly session cookie on success ### Session guard (src/routes/__root.tsx) A useEffect on every non-auth page load calls GET /api/auth/status: - initialized=false -> redirect to /launcher-setup - authenticated=false -> redirect to /launcher-login This ensures the setup/login UI is shown even when the ?token= URL mechanism auto-logs in (first-run case). ### Logout button (src/components/app-header.tsx) IconLogout button added to the header with a confirm AlertDialog; calls POST /api/auth/logout then redirects to /launcher-login. ### API layer - src/api/launcher-auth.ts: LauncherAuthStatus gains initialized bool; postLauncherDashboardSetup() added; LauncherAuthTokenHelp removed - src/api/http.ts: 401 guard uses isLauncherAuthPathname() (covers both /launcher-login and /launcher-setup) to prevent redirect loops - src/lib/launcher-login-path.ts: isLauncherSetupPathname() and isLauncherAuthPathname() added ### Routing - src/routeTree.gen.ts: /launcher-setup route registered throughout - src/routes/launcher-login.tsx: tokenHelp UI removed; useEffect added to redirect to setup when initialized=false ### i18n - en.json / zh.json: launcherSetup block added; launcherLogin keys updated to use passwordLabel/passwordPlaceholder * fix(lint): ts lint fixed 1 * fix(auth): detail auth error handle * fix(login): frontend web auth error handle * fix(frontend): auth error handler 5xx
2026-04-08 13:43:51 +00:00
import { IconLanguage, IconMoon, IconSun } from "@tabler/icons-react"
import { createFileRoute } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { postLauncherDashboardSetup } from "@/api/launcher-auth"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useTheme } from "@/hooks/use-theme"
function LauncherSetupPage() {
const { t, i18n } = useTranslation()
const { theme, toggleTheme } = useTheme()
const [password, setPassword] = React.useState("")
const [confirm, setConfirm] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("")
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError("")
if (password !== confirm) {
setError(t("launcherSetup.errorMismatch"))
return
}
setSubmitting(true)
try {
const result = await postLauncherDashboardSetup(password, confirm)
if (result.ok) {
globalThis.location.assign("/launcher-login")
return
}
setError(result.error)
} catch {
setError(t("launcherSetup.errorNetwork"))
} finally {
setSubmitting(false)
}
}
return (
<div className="bg-background text-foreground flex min-h-dvh flex-col">
<header className="border-border/50 flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" aria-label="Language">
<IconLanguage className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
English
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon"
type="button"
onClick={() => toggleTheme()}
aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
>
{theme === "dark" ? (
<IconSun className="size-4" />
) : (
<IconMoon className="size-4" />
)}
</Button>
</header>
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-md" size="sm">
<CardHeader>
<CardTitle>{t("launcherSetup.title")}</CardTitle>
<CardDescription>{t("launcherSetup.description")}</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="setup-password">
{t("launcherSetup.passwordLabel")}
</Label>
<Input
id="setup-password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("launcherSetup.passwordPlaceholder")}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="setup-confirm">
{t("launcherSetup.confirmLabel")}
</Label>
<Input
id="setup-confirm"
name="confirm"
type="password"
autoComplete="new-password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("launcherSetup.confirmPlaceholder")}
/>
</div>
<Button type="submit" disabled={submitting}>
{submitting ? t("labels.loading") : t("launcherSetup.submit")}
</Button>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
</form>
</CardContent>
</Card>
</div>
</div>
)
}
export const Route = createFileRoute("/launcher-setup")({
component: LauncherSetupPage,
})