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 (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -18,6 +19,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,7 +76,15 @@ func (h *Handler) TryAutoStartGateway() {
|
||||||
|
|
||||||
ready, reason, err := h.gatewayStartReady()
|
ready, reason, err := h.gatewayStartReady()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
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)
|
log.Printf("Skip auto-starting gateway: %v", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ready {
|
if !ready {
|
||||||
|
|
@ -91,6 +101,8 @@ func (h *Handler) TryAutoStartGateway() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// gatewayStartReady validates whether current config can start the gateway.
|
// 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) {
|
func (h *Handler) gatewayStartReady() (bool, string, error) {
|
||||||
cfg, err := config.LoadConfig(h.configPath)
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -256,7 +268,18 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||||
execPath := utils.FindPicoclawBinary()
|
execPath := utils.FindPicoclawBinary()
|
||||||
|
|
||||||
cmd := exec.Command(execPath, "gateway")
|
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
|
// Forward the launcher's config path via the environment variable that
|
||||||
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||||
// config file without requiring a --config flag on the gateway subcommand.
|
// 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
|
// Wait for exit in background and clean up
|
||||||
go func() {
|
go func() {
|
||||||
if err := cmd.Wait(); err != nil {
|
exitErr := cmd.Wait()
|
||||||
log.Printf("Gateway process exited: %v", err)
|
if exitErr != nil {
|
||||||
|
log.Printf("Gateway process exited: %v", exitErr)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Gateway process exited normally")
|
log.Printf("Gateway process exited normally")
|
||||||
}
|
}
|
||||||
|
|
@ -329,11 +353,27 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||||
}
|
}
|
||||||
gateway.mu.Unlock()
|
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 {
|
if shouldBroadcastStopped {
|
||||||
gateway.events.Broadcast(GatewayEvent{
|
gateway.events.Broadcast(GatewayEvent{Status: "stopped"})
|
||||||
Status: "stopped",
|
|
||||||
RestartRequired: false,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -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
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -772,3 +819,17 @@ func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||||
buf.Append(scanner.Text())
|
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"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
"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.
|
// Handler serves HTTP API requests.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
configPath string
|
configPath string
|
||||||
|
|
@ -17,6 +30,9 @@ type Handler struct {
|
||||||
oauthMu sync.Mutex
|
oauthMu sync.Mutex
|
||||||
oauthFlows map[string]*oauthFlow
|
oauthFlows map[string]*oauthFlow
|
||||||
oauthState map[string]string
|
oauthState map[string]string
|
||||||
|
passphraseStore *credential.SecureStore
|
||||||
|
passphraseMu sync.Mutex
|
||||||
|
passphraseLastState passphraseState
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates an instance of the API handler.
|
// NewHandler creates an instance of the API handler.
|
||||||
|
|
@ -26,6 +42,7 @@ func NewHandler(configPath string) *Handler {
|
||||||
serverPort: launcherconfig.DefaultPort,
|
serverPort: launcherconfig.DefaultPort,
|
||||||
oauthFlows: make(map[string]*oauthFlow),
|
oauthFlows: make(map[string]*oauthFlow),
|
||||||
oauthState: make(map[string]string),
|
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...)
|
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.
|
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
|
||||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// Config CRUD
|
// Config CRUD
|
||||||
|
|
@ -54,6 +86,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// OAuth login and credential management
|
// OAuth login and credential management
|
||||||
h.registerOAuthRoutes(mux)
|
h.registerOAuthRoutes(mux)
|
||||||
|
|
||||||
|
// Passphrase management (in-memory store for encrypted credentials)
|
||||||
|
h.registerPassphraseRoutes(mux)
|
||||||
|
|
||||||
// Model list management
|
// Model list management
|
||||||
h.registerModelRoutes(mux)
|
h.registerModelRoutes(mux)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
"github.com/sipeed/picoclaw/web/backend/api"
|
"github.com/sipeed/picoclaw/web/backend/api"
|
||||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||||
|
|
@ -115,6 +116,21 @@ func main() {
|
||||||
// API Routes (e.g. /api/status)
|
// API Routes (e.g. /api/status)
|
||||||
apiHandler := api.NewHandler(absPath)
|
apiHandler := api.NewHandler(absPath)
|
||||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
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)
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
// Frontend Embedded Assets
|
// Frontend Embedded Assets
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ interface GatewayStatusResponse {
|
||||||
gateway_status: "running" | "starting" | "restarting" | "stopped" | "error"
|
gateway_status: "running" | "starting" | "restarting" | "stopped" | "error"
|
||||||
gateway_start_allowed?: boolean
|
gateway_start_allowed?: boolean
|
||||||
gateway_start_reason?: string
|
gateway_start_reason?: string
|
||||||
gateway_restart_required?: boolean
|
passphrase_state?: "" | "pending" | "failed"
|
||||||
pid?: number
|
pid?: number
|
||||||
boot_default_model?: string
|
boot_default_model?: string
|
||||||
config_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 {
|
import {
|
||||||
|
IconKey,
|
||||||
|
IconLoader2,
|
||||||
|
IconLock,
|
||||||
IconPlugConnectedX,
|
IconPlugConnectedX,
|
||||||
IconRobot,
|
IconRobot,
|
||||||
IconRobotOff,
|
IconRobotOff,
|
||||||
IconStar,
|
IconStar,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { setPassphrase } from "@/api/passphrase"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface ChatEmptyStateProps {
|
interface ChatEmptyStateProps {
|
||||||
hasConfiguredModels: boolean
|
hasConfiguredModels: boolean
|
||||||
defaultModelName: string
|
defaultModelName: string
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
|
gatewayStartReason?: string
|
||||||
|
passphraseState?: "" | "pending" | "failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatEmptyState({
|
export function ChatEmptyState({
|
||||||
hasConfiguredModels,
|
hasConfiguredModels,
|
||||||
defaultModelName,
|
defaultModelName,
|
||||||
isConnected,
|
isConnected,
|
||||||
|
gatewayStartReason = "",
|
||||||
|
passphraseState = "",
|
||||||
}: ChatEmptyStateProps) {
|
}: ChatEmptyStateProps) {
|
||||||
const { t } = useTranslation()
|
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) {
|
if (!hasConfiguredModels) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -85,3 +177,4 @@ export function ChatEmptyState({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,9 @@ export function ChatPage() {
|
||||||
newChat,
|
newChat,
|
||||||
} = usePicoChat()
|
} = usePicoChat()
|
||||||
|
|
||||||
const { state: gwState } = useGateway()
|
const { state: gwState, startReason, passphraseState } = useGateway()
|
||||||
const isGatewayRunning = gwState === "running"
|
const isConnected = gwState === "running"
|
||||||
|
const isGatewayRunning = isConnected
|
||||||
const isChatConnected = connectionState === "connected"
|
const isChatConnected = connectionState === "connected"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -142,7 +143,9 @@ export function ChatPage() {
|
||||||
<ChatEmptyState
|
<ChatEmptyState
|
||||||
hasConfiguredModels={hasConfiguredModels}
|
hasConfiguredModels={hasConfiguredModels}
|
||||||
defaultModelName={defaultModelName}
|
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 { DeviceCodeSheet } from "./device-code-sheet"
|
||||||
import { LogoutConfirmDialog } from "./logout-confirm-dialog"
|
import { LogoutConfirmDialog } from "./logout-confirm-dialog"
|
||||||
import { OpenAICredentialCard } from "./openai-credential-card"
|
import { OpenAICredentialCard } from "./openai-credential-card"
|
||||||
|
import { PassphraseCard } from "./passphrase-card"
|
||||||
|
|
||||||
export function CredentialsPage() {
|
export function CredentialsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
@ -51,6 +52,11 @@ export function CredentialsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Passphrase card is always visible — independent of OAuth loading state */}
|
||||||
|
<div className="pt-5">
|
||||||
|
<PassphraseCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm">
|
<div className="text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm">
|
||||||
{error}
|
{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 { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type GatewayStatusResponse,
|
type GatewayStatusResponse,
|
||||||
getGatewayStatus,
|
getGatewayStatus,
|
||||||
restartGateway,
|
|
||||||
startGateway,
|
startGateway,
|
||||||
stopGateway,
|
stopGateway,
|
||||||
} from "@/api/gateway"
|
} from "@/api/gateway"
|
||||||
import {
|
import { gatewayAtom, updateGatewayStore } from "@/store"
|
||||||
applyGatewayStatusToStore,
|
|
||||||
gatewayAtom,
|
|
||||||
updateGatewayStore,
|
|
||||||
} from "@/store"
|
|
||||||
|
|
||||||
// Global variable to ensure we only have one SSE connection
|
// Global variable to ensure we only have one SSE connection
|
||||||
let sseInitialized = false
|
let sseInitialized = false
|
||||||
|
|
||||||
export function useGateway() {
|
export function useGateway() {
|
||||||
const gateway = useAtomValue(gatewayAtom)
|
const [{ status: state, canStart, startReason, passphraseState }, setGateway] = useAtom(gatewayAtom)
|
||||||
const { status: state, canStart, restartRequired } = gateway
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => {
|
const applyGatewayStatus = useCallback(
|
||||||
applyGatewayStatusToStore(data)
|
(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
|
// Initialize global SSE connection once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -37,7 +40,8 @@ export function useGateway() {
|
||||||
updateGatewayStore({
|
updateGatewayStore({
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
canStart: true,
|
canStart: true,
|
||||||
restartRequired: false,
|
startReason: "",
|
||||||
|
passphraseState: "",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -116,37 +120,5 @@ export function useGateway() {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const restart = useCallback(async () => {
|
return { state, loading, canStart, startReason, passphraseState, start, stop }
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,19 @@
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"description": "Manage OAuth and token-based credentials for supported providers.",
|
"description": "Manage OAuth and token-based credentials for supported providers.",
|
||||||
"loading": "Loading credentials...",
|
"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": {
|
"providers": {
|
||||||
"openai": {
|
"openai": {
|
||||||
"description": "Supports browser OAuth, device code, and token login."
|
"description": "Supports browser OAuth, device code, and token login."
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,19 @@
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
|
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
|
||||||
"loading": "正在加载凭据...",
|
"loading": "正在加载凭据...",
|
||||||
|
"passphrase": {
|
||||||
|
"title": "加密密码",
|
||||||
|
"description": "用于解密配置中的 enc:// API Key,仅存储在内存中,不会写入磁盘。",
|
||||||
|
"statusSet": "密码已加载",
|
||||||
|
"statusNotSet": "未设置密码",
|
||||||
|
"placeholder": "输入密码",
|
||||||
|
"save": "解锁",
|
||||||
|
"saving": "解锁中...",
|
||||||
|
"successMessage": "密码已存储,网关正在启动...",
|
||||||
|
"errorEmpty": "密码不能为空。",
|
||||||
|
"errorSave": "存储密码失败。",
|
||||||
|
"errorWrongPassphrase": "密码错误,网关启动失败,请重试。"
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openai": {
|
"openai": {
|
||||||
"description": "支持浏览器 OAuth、设备码和 Token 登录。"
|
"description": "支持浏览器 OAuth、设备码和 Token 登录。"
|
||||||
|
|
|
||||||
|
|
@ -13,36 +13,19 @@ export type GatewayState =
|
||||||
export interface GatewayStoreState {
|
export interface GatewayStoreState {
|
||||||
status: GatewayState
|
status: GatewayState
|
||||||
canStart: boolean
|
canStart: boolean
|
||||||
restartRequired: boolean
|
startReason: string
|
||||||
|
passphraseState: "" | "pending" | "failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
type GatewayStorePatch = Partial<GatewayStoreState>
|
type GatewayStorePatch = Partial<GatewayStoreState>
|
||||||
|
|
||||||
const DEFAULT_GATEWAY_STATE: GatewayStoreState = {
|
// Global atom for gateway state
|
||||||
|
export const gatewayAtom = atom<GatewayStoreState>({
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
canStart: true,
|
canStart: true,
|
||||||
restartRequired: false,
|
startReason: "",
|
||||||
}
|
passphraseState: "",
|
||||||
|
})
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateGatewayStore(
|
export function updateGatewayStore(
|
||||||
patch:
|
patch:
|
||||||
|
|
@ -51,26 +34,17 @@ export function updateGatewayStore(
|
||||||
) {
|
) {
|
||||||
getDefaultStore().set(gatewayAtom, (prev) => {
|
getDefaultStore().set(gatewayAtom, (prev) => {
|
||||||
const nextPatch = typeof patch === "function" ? patch(prev) : patch
|
const nextPatch = typeof patch === "function" ? patch(prev) : patch
|
||||||
return normalizeGatewayStoreState(prev, nextPatch)
|
return { ...prev, ...nextPatch }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyGatewayStatusToStore(
|
function applyGatewayStatusToStore(data: GatewayStatusResponse) {
|
||||||
data: Partial<
|
getDefaultStore().set(gatewayAtom, (prev) => ({
|
||||||
Pick<
|
...prev,
|
||||||
GatewayStatusResponse,
|
status: data.gateway_status ?? "unknown",
|
||||||
"gateway_status" | "gateway_start_allowed" | "gateway_restart_required"
|
canStart: data.gateway_start_allowed ?? true,
|
||||||
>
|
startReason: data.gateway_start_reason ?? "",
|
||||||
>,
|
passphraseState: data.passphrase_state ?? "",
|
||||||
) {
|
|
||||||
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),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,6 +53,6 @@ export async function refreshGatewayState() {
|
||||||
const status = await getGatewayStatus()
|
const status = await getGatewayStatus()
|
||||||
applyGatewayStatusToStore(status)
|
applyGatewayStatusToStore(status)
|
||||||
} catch {
|
} catch {
|
||||||
updateGatewayStore(DEFAULT_GATEWAY_STATE)
|
updateGatewayStore({ status: "unknown", canStart: true, startReason: "", passphraseState: "" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue