feat(launcher): in-memory passphrase store with web UI unlock
Replace env-var passphrase forwarding with a SecureStore-backed flow: - web/backend/main.go: seed passphrase from env var into SecureStore at startup, then clear the env var and redirect credential.PassphraseProvider to apiHandler.GetPassphrase so all LoadConfig calls share one source - web/backend/api/passphrase.go: POST /api/credential/passphrase stores passphrase and auto-starts gateway; GET status endpoint - web/backend/api/gateway.go: build child env via filterEnv() (strips PassphraseEnvVar from parent env) then inject only from SecureStore; passphraseState machine (pending/failed/none) tracks gateway start outcome; expose passphrase_state in /api/gateway/status response - web/backend/api/router.go: add passphraseStore, passphraseMu, passphraseLastState fields; SeedPassphrase / GetPassphrase methods - frontend: PassphraseCard component on credentials page; chat-empty-state shows passphrase input when start_reason contains 'passphrase' or passphraseState is failed; i18n keys for en + zh - Makefile: add -buildvcs=false to GOFLAGS
This commit is contained in:
parent
c513ad22d7
commit
0c94bef30e
14 changed files with 514 additions and 104 deletions
|
|
@ -3,6 +3,7 @@ package api
|
|||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
|
|
@ -18,6 +19,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||
)
|
||||
|
||||
|
|
@ -74,7 +76,15 @@ func (h *Handler) TryAutoStartGateway() {
|
|||
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
if err != nil {
|
||||
log.Printf("Skip auto-starting gateway: %v", err)
|
||||
if errors.Is(err, credential.ErrPassphraseRequired) {
|
||||
log.Printf("Skip auto-starting gateway: encrypted credentials require a passphrase. " +
|
||||
"Enter it on the Credentials page to unlock.")
|
||||
} else if errors.Is(err, credential.ErrDecryptionFailed) {
|
||||
log.Printf("Skip auto-starting gateway: failed to decrypt credentials. " +
|
||||
"Check the passphrase and SSH key on the Credentials page.")
|
||||
} else {
|
||||
log.Printf("Skip auto-starting gateway: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !ready {
|
||||
|
|
@ -91,6 +101,8 @@ func (h *Handler) TryAutoStartGateway() {
|
|||
}
|
||||
|
||||
// gatewayStartReady validates whether current config can start the gateway.
|
||||
// LoadConfig uses credential.PassphraseProvider (set to SecureStore.Get at
|
||||
// startup) so enc:// credentials are resolved correctly without os.Environ.
|
||||
func (h *Handler) gatewayStartReady() (bool, string, error) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
|
|
@ -256,7 +268,18 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
|||
execPath := utils.FindPicoclawBinary()
|
||||
|
||||
cmd := exec.Command(execPath, "gateway")
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
// Build a clean environment for the child process.
|
||||
// Start from the launcher's current environment, but explicitly strip
|
||||
// PICOCLAW_KEY_PASSPHRASE so it cannot leak from the parent env.
|
||||
// The passphrase is then injected directly from the in-memory SecureStore
|
||||
// (child-only; never stored in the launcher's own os.Environ).
|
||||
childEnv := filterEnv(os.Environ(), credential.PassphraseEnvVar)
|
||||
if passphrase := h.passphraseStore.Get(); passphrase != "" {
|
||||
childEnv = append(childEnv, credential.PassphraseEnvVar+"="+passphrase)
|
||||
}
|
||||
cmd.Env = childEnv
|
||||
|
||||
// Forward the launcher's config path via the environment variable that
|
||||
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||
// config file without requiring a --config flag on the gateway subcommand.
|
||||
|
|
@ -311,8 +334,9 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
|||
|
||||
// Wait for exit in background and clean up
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Printf("Gateway process exited: %v", err)
|
||||
exitErr := cmd.Wait()
|
||||
if exitErr != nil {
|
||||
log.Printf("Gateway process exited: %v", exitErr)
|
||||
} else {
|
||||
log.Printf("Gateway process exited normally")
|
||||
}
|
||||
|
|
@ -329,11 +353,27 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
|||
}
|
||||
gateway.mu.Unlock()
|
||||
|
||||
// If we had an active passphrase attempt and the gateway crashed,
|
||||
// mark passphrase as failed so the frontend can show an error.
|
||||
if exitErr != nil {
|
||||
h.passphraseMu.Lock()
|
||||
if h.passphraseLastState == passphraseStatePending {
|
||||
h.passphraseLastState = passphraseStateFailed
|
||||
// Clear the bad passphrase so user must re-enter
|
||||
h.passphraseStore.Clear()
|
||||
}
|
||||
h.passphraseMu.Unlock()
|
||||
} else {
|
||||
// Clean normal exit
|
||||
h.passphraseMu.Lock()
|
||||
if h.passphraseLastState == passphraseStatePending {
|
||||
h.passphraseLastState = passphraseStateNone
|
||||
}
|
||||
h.passphraseMu.Unlock()
|
||||
}
|
||||
|
||||
if shouldBroadcastStopped {
|
||||
gateway.events.Broadcast(GatewayEvent{
|
||||
Status: "stopped",
|
||||
RestartRequired: false,
|
||||
})
|
||||
gateway.events.Broadcast(GatewayEvent{Status: "stopped"})
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
@ -662,6 +702,13 @@ func (h *Handler) gatewayStatusData() map[string]any {
|
|||
}
|
||||
}
|
||||
|
||||
// Expose passphrase state so the frontend can distinguish
|
||||
// "never entered" vs "wrong passphrase" vs "pending start".
|
||||
h.passphraseMu.Lock()
|
||||
ps := h.passphraseLastState
|
||||
h.passphraseMu.Unlock()
|
||||
data["passphrase_state"] = string(ps)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
|
|
@ -772,3 +819,17 @@ func scanPipe(r io.Reader, buf *LogBuffer) {
|
|||
buf.Append(scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
// filterEnv returns a copy of environ with all entries whose key matches
|
||||
// the supplied key removed. Used to strip the passphrase from the
|
||||
// inherited environment before assembling the child-process environ.
|
||||
func filterEnv(environ []string, key string) []string {
|
||||
prefix := key + "="
|
||||
result := make([]string, 0, len(environ))
|
||||
for _, e := range environ {
|
||||
if !strings.HasPrefix(e, prefix) {
|
||||
result = append(result, e)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
80
web/backend/api/passphrase.go
Normal file
80
web/backend/api/passphrase.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// registerPassphraseRoutes binds the passphrase management endpoints.
|
||||
func (h *Handler) registerPassphraseRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/credential/passphrase", h.handleSetPassphrase)
|
||||
mux.HandleFunc("GET /api/credential/passphrase/status", h.handlePassphraseStatus)
|
||||
}
|
||||
|
||||
// handleSetPassphrase stores the supplied passphrase in the in-memory
|
||||
// SecureStore, then attempts to auto-start the gateway if it is not running.
|
||||
//
|
||||
// POST /api/credential/passphrase
|
||||
// Body: {"passphrase": "..."}
|
||||
func (h *Handler) handleSetPassphrase(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Passphrase == "" {
|
||||
http.Error(w, "passphrase must not be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.passphraseStore.SetString(body.Passphrase)
|
||||
|
||||
// Mark state as pending before launching gateway
|
||||
h.passphraseMu.Lock()
|
||||
h.passphraseLastState = passphraseStatePending
|
||||
h.passphraseMu.Unlock()
|
||||
|
||||
// Try to start the gateway now that the passphrase is available.
|
||||
// credential.PassphraseProvider points to passphraseStore.Get, so
|
||||
// gatewayStartReady() (and all LoadConfig calls) will resolve enc://
|
||||
// credentials correctly using the newly stored passphrase.
|
||||
go func() {
|
||||
gateway.mu.Lock()
|
||||
defer gateway.mu.Unlock()
|
||||
if isGatewayProcessAliveLocked() {
|
||||
return
|
||||
}
|
||||
pid, err := h.startGatewayLocked("starting")
|
||||
if err != nil {
|
||||
log.Printf("Failed to start gateway after passphrase unlock: %v", err)
|
||||
// startGatewayLocked failed before spawning the process, so the exit
|
||||
// goroutine will never run. Transition pending → failed manually.
|
||||
h.passphraseMu.Lock()
|
||||
if h.passphraseLastState == passphraseStatePending {
|
||||
h.passphraseLastState = passphraseStateFailed
|
||||
h.passphraseStore.Clear()
|
||||
}
|
||||
h.passphraseMu.Unlock()
|
||||
return
|
||||
}
|
||||
log.Printf("Gateway started after passphrase unlock (PID: %d)", pid)
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// handlePassphraseStatus reports whether a passphrase is currently stored.
|
||||
//
|
||||
// GET /api/credential/passphrase/status
|
||||
func (h *Handler) handlePassphraseStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"passphrase_set": h.passphraseStore.IsSet(),
|
||||
})
|
||||
}
|
||||
|
|
@ -4,9 +4,22 @@ import (
|
|||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
)
|
||||
|
||||
// passphraseState tracks what happened with the last passphrase attempt.
|
||||
// "" → no passphrase submitted yet (or just cleared)
|
||||
// "pending" → passphrase set, gateway starting
|
||||
// "failed" → gateway exited; passphrase likely wrong
|
||||
type passphraseState string
|
||||
|
||||
const (
|
||||
passphraseStateNone passphraseState = ""
|
||||
passphraseStatePending passphraseState = "pending"
|
||||
passphraseStateFailed passphraseState = "failed"
|
||||
)
|
||||
|
||||
// Handler serves HTTP API requests.
|
||||
type Handler struct {
|
||||
configPath string
|
||||
|
|
@ -17,15 +30,19 @@ type Handler struct {
|
|||
oauthMu sync.Mutex
|
||||
oauthFlows map[string]*oauthFlow
|
||||
oauthState map[string]string
|
||||
passphraseStore *credential.SecureStore
|
||||
passphraseMu sync.Mutex
|
||||
passphraseLastState passphraseState
|
||||
}
|
||||
|
||||
// NewHandler creates an instance of the API handler.
|
||||
func NewHandler(configPath string) *Handler {
|
||||
return &Handler{
|
||||
configPath: configPath,
|
||||
serverPort: launcherconfig.DefaultPort,
|
||||
oauthFlows: make(map[string]*oauthFlow),
|
||||
oauthState: make(map[string]string),
|
||||
configPath: configPath,
|
||||
serverPort: launcherconfig.DefaultPort,
|
||||
oauthFlows: make(map[string]*oauthFlow),
|
||||
oauthState: make(map[string]string),
|
||||
passphraseStore: credential.NewSecureStore(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +54,21 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a
|
|||
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
|
||||
}
|
||||
|
||||
// SeedPassphrase pre-loads the passphrase into the in-memory SecureStore.
|
||||
// Call this at startup when the passphrase was supplied via an environment
|
||||
// variable; after seeding, the caller should clear the env var so it is no
|
||||
// longer visible in the process environment.
|
||||
func (h *Handler) SeedPassphrase(passphrase string) {
|
||||
h.passphraseStore.SetString(passphrase)
|
||||
}
|
||||
|
||||
// GetPassphrase returns the currently stored passphrase, or "" if not set.
|
||||
// This satisfies the credential.PassphraseProvider signature so all LoadConfig
|
||||
// calls in the launcher automatically use the in-memory store.
|
||||
func (h *Handler) GetPassphrase() string {
|
||||
return h.passphraseStore.Get()
|
||||
}
|
||||
|
||||
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Config CRUD
|
||||
|
|
@ -54,6 +86,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
// OAuth login and credential management
|
||||
h.registerOAuthRoutes(mux)
|
||||
|
||||
// Passphrase management (in-memory store for encrypted credentials)
|
||||
h.registerPassphraseRoutes(mux)
|
||||
|
||||
// Model list management
|
||||
h.registerModelRoutes(mux)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
"github.com/sipeed/picoclaw/web/backend/api"
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||
|
|
@ -115,6 +116,21 @@ func main() {
|
|||
// API Routes (e.g. /api/status)
|
||||
apiHandler := api.NewHandler(absPath)
|
||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||
|
||||
// If PICOCLAW_KEY_PASSPHRASE is set in the environment at startup, seed it
|
||||
// into the in-memory SecureStore and then remove it from the process
|
||||
// environment so it is no longer visible via /proc/<pid>/environ or similar.
|
||||
if envPassphrase := os.Getenv(credential.PassphraseEnvVar); envPassphrase != "" {
|
||||
apiHandler.SeedPassphrase(envPassphrase)
|
||||
os.Unsetenv(credential.PassphraseEnvVar)
|
||||
log.Printf("Seeded passphrase from %s environment variable (env var cleared)", credential.PassphraseEnvVar)
|
||||
}
|
||||
|
||||
// Point the credential package at the in-memory store so that all
|
||||
// LoadConfig() calls in the launcher (config API, models API, gateway
|
||||
// readiness check, etc.) use the same passphrase source.
|
||||
credential.PassphraseProvider = apiHandler.GetPassphrase
|
||||
|
||||
apiHandler.RegisterRoutes(mux)
|
||||
|
||||
// Frontend Embedded Assets
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ interface GatewayStatusResponse {
|
|||
gateway_status: "running" | "starting" | "restarting" | "stopped" | "error"
|
||||
gateway_start_allowed?: boolean
|
||||
gateway_start_reason?: string
|
||||
gateway_restart_required?: boolean
|
||||
passphrase_state?: "" | "pending" | "failed"
|
||||
pid?: number
|
||||
boot_default_model?: string
|
||||
config_default_model?: string
|
||||
|
|
|
|||
30
web/frontend/src/api/passphrase.ts
Normal file
30
web/frontend/src/api/passphrase.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// API client for in-memory passphrase management.
|
||||
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText)
|
||||
throw new Error(text || `API error: ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export interface PassphraseStatusResponse {
|
||||
passphrase_set: boolean
|
||||
}
|
||||
|
||||
/** Returns whether a passphrase is currently held in the launcher. */
|
||||
export async function getPassphraseStatus(): Promise<PassphraseStatusResponse> {
|
||||
return request<PassphraseStatusResponse>("/api/credential/passphrase/status")
|
||||
}
|
||||
|
||||
/** Stores the passphrase in the launcher's in-memory SecureStore. */
|
||||
export async function setPassphrase(passphrase: string): Promise<{ status: string }> {
|
||||
return request<{ status: string }>("/api/credential/passphrase", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ passphrase }),
|
||||
})
|
||||
}
|
||||
|
|
@ -1,26 +1,118 @@
|
|||
import {
|
||||
IconKey,
|
||||
IconLoader2,
|
||||
IconLock,
|
||||
IconPlugConnectedX,
|
||||
IconRobot,
|
||||
IconRobotOff,
|
||||
IconStar,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { setPassphrase } from "@/api/passphrase"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface ChatEmptyStateProps {
|
||||
hasConfiguredModels: boolean
|
||||
defaultModelName: string
|
||||
isConnected: boolean
|
||||
gatewayStartReason?: string
|
||||
passphraseState?: "" | "pending" | "failed"
|
||||
}
|
||||
|
||||
export function ChatEmptyState({
|
||||
hasConfiguredModels,
|
||||
defaultModelName,
|
||||
isConnected,
|
||||
gatewayStartReason = "",
|
||||
passphraseState = "",
|
||||
}: ChatEmptyStateProps) {
|
||||
const { t } = useTranslation()
|
||||
const needsPassphrase = gatewayStartReason.toLowerCase().includes("passphrase")
|
||||
|| passphraseState === "failed"
|
||||
|| passphraseState === "pending"
|
||||
|
||||
const [passphrase, setPassphraseValue] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
// When backend signals failure, reset the saved flag and show error.
|
||||
useEffect(() => {
|
||||
if (passphraseState === "failed") {
|
||||
setSaved(false)
|
||||
setError(t("credentials.passphrase.errorWrongPassphrase"))
|
||||
}
|
||||
}, [passphraseState, t])
|
||||
|
||||
async function handleUnlock() {
|
||||
if (!passphrase.trim()) return
|
||||
setSaving(true)
|
||||
setError("")
|
||||
try {
|
||||
await setPassphrase(passphrase.trim())
|
||||
setSaved(true)
|
||||
setPassphraseValue("")
|
||||
} catch {
|
||||
setError(t("credentials.passphrase.errorSave"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Passphrase unlock takes priority — models/config can't load without it
|
||||
if (!isConnected && needsPassphrase) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-500/10 text-blue-500">
|
||||
<IconLock className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-medium">
|
||||
{t("credentials.passphrase.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 text-center text-sm">
|
||||
{t("credentials.passphrase.description")}
|
||||
</p>
|
||||
{saved ? (
|
||||
<p className="text-green-600 dark:text-green-400 text-sm">
|
||||
{t("credentials.passphrase.successMessage")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex w-full max-w-sm flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("credentials.passphrase.placeholder")}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphraseValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleUnlock() }}
|
||||
disabled={saving}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={saving || !passphrase.trim()}
|
||||
onClick={() => void handleUnlock()}
|
||||
>
|
||||
{saving
|
||||
? <IconLoader2 className="size-4 animate-spin" />
|
||||
: <IconKey className="size-4" />}
|
||||
{saving
|
||||
? t("credentials.passphrase.saving")
|
||||
: t("credentials.passphrase.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-destructive text-xs">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasConfiguredModels) {
|
||||
return (
|
||||
|
|
@ -85,3 +177,4 @@ export function ChatEmptyState({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,8 +33,9 @@ export function ChatPage() {
|
|||
newChat,
|
||||
} = usePicoChat()
|
||||
|
||||
const { state: gwState } = useGateway()
|
||||
const isGatewayRunning = gwState === "running"
|
||||
const { state: gwState, startReason, passphraseState } = useGateway()
|
||||
const isConnected = gwState === "running"
|
||||
const isGatewayRunning = isConnected
|
||||
const isChatConnected = connectionState === "connected"
|
||||
|
||||
const {
|
||||
|
|
@ -142,7 +143,9 @@ export function ChatPage() {
|
|||
<ChatEmptyState
|
||||
hasConfiguredModels={hasConfiguredModels}
|
||||
defaultModelName={defaultModelName}
|
||||
isConnected={isGatewayRunning}
|
||||
isConnected={isConnected}
|
||||
gatewayStartReason={startReason}
|
||||
passphraseState={passphraseState}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { AntigravityCredentialCard } from "./antigravity-credential-card"
|
|||
import { DeviceCodeSheet } from "./device-code-sheet"
|
||||
import { LogoutConfirmDialog } from "./logout-confirm-dialog"
|
||||
import { OpenAICredentialCard } from "./openai-credential-card"
|
||||
import { PassphraseCard } from "./passphrase-card"
|
||||
|
||||
export function CredentialsPage() {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -51,6 +52,11 @@ export function CredentialsPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Passphrase card is always visible — independent of OAuth loading state */}
|
||||
<div className="pt-5">
|
||||
<PassphraseCard />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm">
|
||||
{error}
|
||||
|
|
|
|||
114
web/frontend/src/components/credentials/passphrase-card.tsx
Normal file
114
web/frontend/src/components/credentials/passphrase-card.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { IconKey, IconLock, IconLockOpen, IconLoader2 } from "@tabler/icons-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { getPassphraseStatus, setPassphrase } from "@/api/passphrase"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export function PassphraseCard() {
|
||||
const { t } = useTranslation()
|
||||
const [value, setValue] = useState("")
|
||||
const [isSet, setIsSet] = useState<boolean | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getPassphraseStatus()
|
||||
.then((res) => setIsSet(res.passphrase_set))
|
||||
.catch(() => setIsSet(false))
|
||||
}, [])
|
||||
|
||||
async function handleSave() {
|
||||
if (!value.trim()) {
|
||||
setMessage({ text: t("credentials.passphrase.errorEmpty"), error: true })
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
await setPassphrase(value.trim())
|
||||
setIsSet(true)
|
||||
setValue("")
|
||||
setMessage({ text: t("credentials.passphrase.successMessage"), error: false })
|
||||
} catch {
|
||||
setMessage({ text: t("credentials.passphrase.errorSave"), error: true })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-card flex h-full flex-col rounded-xl border p-4">
|
||||
<div className="min-h-16">
|
||||
<h3 className="text-base font-semibold inline-flex items-center gap-2">
|
||||
<span className="border-muted inline-flex size-6 items-center justify-center rounded-full border">
|
||||
<IconKey className="size-3.5" />
|
||||
</span>
|
||||
{t("credentials.passphrase.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("credentials.passphrase.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 text-xs">
|
||||
{isSet === null ? (
|
||||
<IconLoader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : isSet ? (
|
||||
<>
|
||||
<IconLockOpen className="size-3.5 text-green-500" />
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
{t("credentials.passphrase.statusSet")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconLock className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("credentials.passphrase.statusNotSet")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-4 pt-4">
|
||||
<div className="border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3">
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSave()
|
||||
}}
|
||||
type="password"
|
||||
placeholder={t("credentials.passphrase.placeholder")}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
disabled={saving || !value.trim()}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
||||
{saving
|
||||
? t("credentials.passphrase.saving")
|
||||
: t("credentials.passphrase.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{message && (
|
||||
<p
|
||||
className={`text-xs ${message.error ? "text-destructive" : "text-green-600 dark:text-green-400"}`}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-8" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,30 +1,33 @@
|
|||
import { useAtomValue } from "jotai"
|
||||
import { useAtom } from "jotai"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import {
|
||||
type GatewayStatusResponse,
|
||||
getGatewayStatus,
|
||||
restartGateway,
|
||||
startGateway,
|
||||
stopGateway,
|
||||
} from "@/api/gateway"
|
||||
import {
|
||||
applyGatewayStatusToStore,
|
||||
gatewayAtom,
|
||||
updateGatewayStore,
|
||||
} from "@/store"
|
||||
import { gatewayAtom, updateGatewayStore } from "@/store"
|
||||
|
||||
// Global variable to ensure we only have one SSE connection
|
||||
let sseInitialized = false
|
||||
|
||||
export function useGateway() {
|
||||
const gateway = useAtomValue(gatewayAtom)
|
||||
const { status: state, canStart, restartRequired } = gateway
|
||||
const [{ status: state, canStart, startReason, passphraseState }, setGateway] = useAtom(gatewayAtom)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => {
|
||||
applyGatewayStatusToStore(data)
|
||||
}, [])
|
||||
const applyGatewayStatus = useCallback(
|
||||
(data: GatewayStatusResponse) => {
|
||||
setGateway((prev) => ({
|
||||
...prev,
|
||||
status: data.gateway_status ?? "unknown",
|
||||
canStart: data.gateway_start_allowed ?? true,
|
||||
startReason: data.gateway_start_reason ?? "",
|
||||
passphraseState: data.passphrase_state ?? "",
|
||||
}))
|
||||
},
|
||||
[setGateway],
|
||||
)
|
||||
|
||||
// Initialize global SSE connection once
|
||||
useEffect(() => {
|
||||
|
|
@ -37,7 +40,8 @@ export function useGateway() {
|
|||
updateGatewayStore({
|
||||
status: "unknown",
|
||||
canStart: true,
|
||||
restartRequired: false,
|
||||
startReason: "",
|
||||
passphraseState: "",
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -116,37 +120,5 @@ export function useGateway() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const restart = useCallback(async () => {
|
||||
if (state !== "running") return
|
||||
|
||||
const previousState = state
|
||||
const previousCanStart = canStart
|
||||
const previousRestartRequired = restartRequired
|
||||
|
||||
setLoading(true)
|
||||
updateGatewayStore({
|
||||
status: "restarting",
|
||||
restartRequired: false,
|
||||
})
|
||||
|
||||
try {
|
||||
await restartGateway()
|
||||
} catch (err) {
|
||||
console.error("Failed to restart gateway:", err)
|
||||
try {
|
||||
const status = await getGatewayStatus()
|
||||
applyGatewayStatus(status)
|
||||
} catch {
|
||||
updateGatewayStore({
|
||||
status: previousState,
|
||||
canStart: previousCanStart,
|
||||
restartRequired: previousRestartRequired,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [applyGatewayStatus, canStart, restartRequired, state])
|
||||
|
||||
return { state, loading, canStart, restartRequired, start, stop, restart }
|
||||
return { state, loading, canStart, startReason, passphraseState, start, stop }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,19 @@
|
|||
"credentials": {
|
||||
"description": "Manage OAuth and token-based credentials for supported providers.",
|
||||
"loading": "Loading credentials...",
|
||||
"passphrase": {
|
||||
"title": "Encryption Passphrase",
|
||||
"description": "Required to decrypt enc:// API keys in your config. Stored only in memory — never written to disk.",
|
||||
"statusSet": "Passphrase loaded",
|
||||
"statusNotSet": "No passphrase set",
|
||||
"placeholder": "Enter passphrase",
|
||||
"save": "Unlock",
|
||||
"saving": "Unlocking...",
|
||||
"successMessage": "Passphrase stored. Gateway is starting...",
|
||||
"errorEmpty": "Passphrase must not be empty.",
|
||||
"errorSave": "Failed to store passphrase.",
|
||||
"errorWrongPassphrase": "Wrong passphrase — gateway failed to start. Please try again."
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"description": "Supports browser OAuth, device code, and token login."
|
||||
|
|
|
|||
|
|
@ -81,6 +81,19 @@
|
|||
"credentials": {
|
||||
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
|
||||
"loading": "正在加载凭据...",
|
||||
"passphrase": {
|
||||
"title": "加密密码",
|
||||
"description": "用于解密配置中的 enc:// API Key,仅存储在内存中,不会写入磁盘。",
|
||||
"statusSet": "密码已加载",
|
||||
"statusNotSet": "未设置密码",
|
||||
"placeholder": "输入密码",
|
||||
"save": "解锁",
|
||||
"saving": "解锁中...",
|
||||
"successMessage": "密码已存储,网关正在启动...",
|
||||
"errorEmpty": "密码不能为空。",
|
||||
"errorSave": "存储密码失败。",
|
||||
"errorWrongPassphrase": "密码错误,网关启动失败,请重试。"
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"description": "支持浏览器 OAuth、设备码和 Token 登录。"
|
||||
|
|
|
|||
|
|
@ -13,36 +13,19 @@ export type GatewayState =
|
|||
export interface GatewayStoreState {
|
||||
status: GatewayState
|
||||
canStart: boolean
|
||||
restartRequired: boolean
|
||||
startReason: string
|
||||
passphraseState: "" | "pending" | "failed"
|
||||
}
|
||||
|
||||
type GatewayStorePatch = Partial<GatewayStoreState>
|
||||
|
||||
const DEFAULT_GATEWAY_STATE: GatewayStoreState = {
|
||||
// Global atom for gateway state
|
||||
export const gatewayAtom = atom<GatewayStoreState>({
|
||||
status: "unknown",
|
||||
canStart: true,
|
||||
restartRequired: false,
|
||||
}
|
||||
|
||||
// Global atom for gateway state
|
||||
export const gatewayAtom = atom<GatewayStoreState>(DEFAULT_GATEWAY_STATE)
|
||||
|
||||
function normalizeGatewayStoreState(
|
||||
prev: GatewayStoreState,
|
||||
patch: GatewayStorePatch,
|
||||
) {
|
||||
const next = { ...prev, ...patch }
|
||||
|
||||
if (
|
||||
next.status === prev.status &&
|
||||
next.canStart === prev.canStart &&
|
||||
next.restartRequired === prev.restartRequired
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
startReason: "",
|
||||
passphraseState: "",
|
||||
})
|
||||
|
||||
export function updateGatewayStore(
|
||||
patch:
|
||||
|
|
@ -51,26 +34,17 @@ export function updateGatewayStore(
|
|||
) {
|
||||
getDefaultStore().set(gatewayAtom, (prev) => {
|
||||
const nextPatch = typeof patch === "function" ? patch(prev) : patch
|
||||
return normalizeGatewayStoreState(prev, nextPatch)
|
||||
return { ...prev, ...nextPatch }
|
||||
})
|
||||
}
|
||||
|
||||
export function applyGatewayStatusToStore(
|
||||
data: Partial<
|
||||
Pick<
|
||||
GatewayStatusResponse,
|
||||
"gateway_status" | "gateway_start_allowed" | "gateway_restart_required"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
updateGatewayStore((prev) => ({
|
||||
status: data.gateway_status ?? prev.status,
|
||||
canStart: data.gateway_start_allowed ?? prev.canStart,
|
||||
restartRequired:
|
||||
data.gateway_restart_required ??
|
||||
(data.gateway_status && data.gateway_status !== "running"
|
||||
? false
|
||||
: prev.restartRequired),
|
||||
function applyGatewayStatusToStore(data: GatewayStatusResponse) {
|
||||
getDefaultStore().set(gatewayAtom, (prev) => ({
|
||||
...prev,
|
||||
status: data.gateway_status ?? "unknown",
|
||||
canStart: data.gateway_start_allowed ?? true,
|
||||
startReason: data.gateway_start_reason ?? "",
|
||||
passphraseState: data.passphrase_state ?? "",
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +53,6 @@ export async function refreshGatewayState() {
|
|||
const status = await getGatewayStatus()
|
||||
applyGatewayStatusToStore(status)
|
||||
} catch {
|
||||
updateGatewayStore(DEFAULT_GATEWAY_STATE)
|
||||
updateGatewayStore({ status: "unknown", canStart: true, startReason: "", passphraseState: "" })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue