From 42d4d4cc743de35385169721efe8f36a2d23a181 Mon Sep 17 00:00:00 2001
From: wenjie
Date: Fri, 6 Mar 2026 19:04:06 +0800
Subject: [PATCH] feat(credentials): add multi-provider OAuth credential
management
- add backend `/api/oauth/*` endpoints for provider status, browser/device-code/token login, flow query/polling, and logout
- extend API handler with OAuth flow/state tracking and route registration, plus OAuth unit tests
- implement frontend credentials page/components for OpenAI, Anthropic, and Google Antigravity login/logout
- add OAuth API client and `useCredentialsPage` hook, with new EN/ZH i18n strings
---
web/backend/api/oauth.go | 844 ++++++++++++++++++
web/backend/api/oauth_test.go | 293 ++++++
web/backend/api/router.go | 13 +-
web/frontend/src/api/oauth.ts | 102 +++
.../credentials/anthropic-credential-card.tsx | 102 +++
.../antigravity-credential-card.tsx | 101 +++
.../credentials/credential-card.tsx | 44 +
.../credentials/credentials-page.tsx | 132 +++
.../credentials/device-code-sheet.tsx | 97 ++
.../credentials/logout-confirm-dialog.tsx | 57 ++
.../credentials/openai-credential-card.tsx | 161 ++++
.../credentials/provider-status-line.tsx | 43 +
.../src/hooks/use-credentials-page.ts | 458 ++++++++++
web/frontend/src/i18n/locales/en.json | 64 ++
web/frontend/src/i18n/locales/zh.json | 64 ++
web/frontend/src/routes/credentials.tsx | 25 +-
16 files changed, 2575 insertions(+), 25 deletions(-)
create mode 100644 web/backend/api/oauth.go
create mode 100644 web/backend/api/oauth_test.go
create mode 100644 web/frontend/src/api/oauth.ts
create mode 100644 web/frontend/src/components/credentials/anthropic-credential-card.tsx
create mode 100644 web/frontend/src/components/credentials/antigravity-credential-card.tsx
create mode 100644 web/frontend/src/components/credentials/credential-card.tsx
create mode 100644 web/frontend/src/components/credentials/credentials-page.tsx
create mode 100644 web/frontend/src/components/credentials/device-code-sheet.tsx
create mode 100644 web/frontend/src/components/credentials/logout-confirm-dialog.tsx
create mode 100644 web/frontend/src/components/credentials/openai-credential-card.tsx
create mode 100644 web/frontend/src/components/credentials/provider-status-line.tsx
create mode 100644 web/frontend/src/hooks/use-credentials-page.ts
diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go
new file mode 100644
index 000000000..04cd595f2
--- /dev/null
+++ b/web/backend/api/oauth.go
@@ -0,0 +1,844 @@
+package api
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "html"
+ "io"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/auth"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+const (
+ oauthProviderOpenAI = "openai"
+ oauthProviderAnthropic = "anthropic"
+ oauthProviderGoogleAntigravity = "google-antigravity"
+
+ oauthMethodBrowser = "browser"
+ oauthMethodDeviceCode = "device_code"
+ oauthMethodToken = "token"
+
+ oauthFlowPending = "pending"
+ oauthFlowSuccess = "success"
+ oauthFlowError = "error"
+ oauthFlowExpired = "expired"
+)
+
+const (
+ oauthBrowserFlowTTL = 10 * time.Minute
+ oauthDeviceCodeFlowTTL = 15 * time.Minute
+ oauthTerminalFlowGC = 30 * time.Minute
+)
+
+var oauthProviderOrder = []string{
+ oauthProviderOpenAI,
+ oauthProviderAnthropic,
+ oauthProviderGoogleAntigravity,
+}
+
+var oauthProviderMethods = map[string][]string{
+ oauthProviderOpenAI: {oauthMethodBrowser, oauthMethodDeviceCode, oauthMethodToken},
+ oauthProviderAnthropic: {oauthMethodToken},
+ oauthProviderGoogleAntigravity: {oauthMethodBrowser},
+}
+
+var oauthProviderLabels = map[string]string{
+ oauthProviderOpenAI: "OpenAI",
+ oauthProviderAnthropic: "Anthropic",
+ oauthProviderGoogleAntigravity: "Google Antigravity",
+}
+
+var (
+ oauthNow = time.Now
+ oauthGeneratePKCE = auth.GeneratePKCE
+ oauthGenerateState = auth.GenerateState
+ oauthBuildAuthorizeURL = auth.BuildAuthorizeURL
+ oauthRequestDeviceCode = auth.RequestDeviceCode
+ oauthPollDeviceCodeOnce = auth.PollDeviceCodeOnce
+ oauthExchangeCodeForTokens = auth.ExchangeCodeForTokens
+ oauthGetCredential = auth.GetCredential
+ oauthSetCredential = auth.SetCredential
+ oauthDeleteCredential = auth.DeleteCredential
+ oauthLoadConfig = config.LoadConfig
+ oauthSaveConfig = config.SaveConfig
+ oauthFetchAntigravityProject = providers.FetchAntigravityProjectID
+ oauthFetchGoogleUserEmailFunc = fetchGoogleUserEmail
+)
+
+type oauthFlow struct {
+ ID string
+ Provider string
+ Method string
+ Status string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ ExpiresAt time.Time
+ Error string
+ CodeVerifier string
+ OAuthState string
+ RedirectURI string
+ DeviceAuthID string
+ UserCode string
+ VerifyURL string
+ Interval int
+}
+
+type oauthProviderStatus struct {
+ Provider string `json:"provider"`
+ DisplayName string `json:"display_name"`
+ Methods []string `json:"methods"`
+ LoggedIn bool `json:"logged_in"`
+ Status string `json:"status"`
+ AuthMethod string `json:"auth_method,omitempty"`
+ ExpiresAt string `json:"expires_at,omitempty"`
+ AccountID string `json:"account_id,omitempty"`
+ Email string `json:"email,omitempty"`
+ ProjectID string `json:"project_id,omitempty"`
+}
+
+type oauthFlowResponse struct {
+ FlowID string `json:"flow_id"`
+ Provider string `json:"provider"`
+ Method string `json:"method"`
+ Status string `json:"status"`
+ ExpiresAt string `json:"expires_at,omitempty"`
+ Error string `json:"error,omitempty"`
+ UserCode string `json:"user_code,omitempty"`
+ VerifyURL string `json:"verify_url,omitempty"`
+ Interval int `json:"interval,omitempty"`
+}
+
+// registerOAuthRoutes binds OAuth login/logout endpoints to the ServeMux.
+func (h *Handler) registerOAuthRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("GET /api/oauth/providers", h.handleListOAuthProviders)
+ mux.HandleFunc("POST /api/oauth/login", h.handleOAuthLogin)
+ mux.HandleFunc("GET /api/oauth/flows/{id}", h.handleGetOAuthFlow)
+ mux.HandleFunc("POST /api/oauth/flows/{id}/poll", h.handlePollOAuthFlow)
+ mux.HandleFunc("POST /api/oauth/logout", h.handleOAuthLogout)
+ mux.HandleFunc("GET /oauth/callback", h.handleOAuthCallback)
+}
+
+func (h *Handler) handleListOAuthProviders(w http.ResponseWriter, r *http.Request) {
+ providersResp := make([]oauthProviderStatus, 0, len(oauthProviderOrder))
+
+ for _, provider := range oauthProviderOrder {
+ cred, err := oauthGetCredential(provider)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to load credentials: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ item := oauthProviderStatus{
+ Provider: provider,
+ DisplayName: oauthProviderLabels[provider],
+ Methods: oauthProviderMethods[provider],
+ Status: "not_logged_in",
+ }
+ if cred != nil {
+ item.LoggedIn = true
+ item.AuthMethod = cred.AuthMethod
+ item.AccountID = cred.AccountID
+ item.Email = cred.Email
+ item.ProjectID = cred.ProjectID
+ if !cred.ExpiresAt.IsZero() {
+ item.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
+ }
+ switch {
+ case cred.IsExpired():
+ item.Status = "expired"
+ case cred.NeedsRefresh():
+ item.Status = "needs_refresh"
+ default:
+ item.Status = "connected"
+ }
+ }
+
+ providersResp = append(providersResp, item)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "providers": providersResp,
+ })
+}
+
+func (h *Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ if err != nil {
+ http.Error(w, "failed to read request body", http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+
+ var req struct {
+ Provider string `json:"provider"`
+ Method string `json:"method"`
+ Token string `json:"token"`
+ }
+ if err = json.Unmarshal(body, &req); err != nil {
+ http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ provider, err := normalizeOAuthProvider(req.Provider)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ method := strings.ToLower(strings.TrimSpace(req.Method))
+ if !isOAuthMethodSupported(provider, method) {
+ http.Error(
+ w,
+ fmt.Sprintf("unsupported login method %q for provider %q", method, provider),
+ http.StatusBadRequest,
+ )
+ return
+ }
+
+ switch method {
+ case oauthMethodToken:
+ token := strings.TrimSpace(req.Token)
+ if token == "" {
+ http.Error(w, "token is required", http.StatusBadRequest)
+ return
+ }
+
+ cred := &auth.AuthCredential{
+ AccessToken: token,
+ Provider: provider,
+ AuthMethod: oauthMethodToken,
+ }
+ if err := h.persistCredentialAndConfig(provider, oauthMethodToken, cred); err != nil {
+ http.Error(w, fmt.Sprintf("token login failed: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "status": "ok",
+ "provider": provider,
+ "method": method,
+ })
+ return
+
+ case oauthMethodDeviceCode:
+ cfg := auth.OpenAIOAuthConfig()
+ info, err := oauthRequestDeviceCode(cfg)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to request device code: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ now := oauthNow()
+ flow := &oauthFlow{
+ ID: newOAuthFlowID(),
+ Provider: provider,
+ Method: method,
+ Status: oauthFlowPending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ ExpiresAt: now.Add(oauthDeviceCodeFlowTTL),
+ DeviceAuthID: info.DeviceAuthID,
+ UserCode: info.UserCode,
+ VerifyURL: info.VerifyURL,
+ Interval: info.Interval,
+ }
+ h.storeOAuthFlow(flow)
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "status": "ok",
+ "provider": provider,
+ "method": method,
+ "flow_id": flow.ID,
+ "user_code": flow.UserCode,
+ "verify_url": flow.VerifyURL,
+ "interval": flow.Interval,
+ "expires_at": flow.ExpiresAt.Format(time.RFC3339),
+ })
+ return
+
+ case oauthMethodBrowser:
+ cfg, err := oauthConfigForProvider(provider)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ pkce, err := oauthGeneratePKCE()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to generate PKCE: %v", err), http.StatusInternalServerError)
+ return
+ }
+ state, err := oauthGenerateState()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ redirectURI := buildOAuthRedirectURI(r)
+ authURL := oauthBuildAuthorizeURL(cfg, pkce, state, redirectURI)
+
+ now := oauthNow()
+ flow := &oauthFlow{
+ ID: newOAuthFlowID(),
+ Provider: provider,
+ Method: method,
+ Status: oauthFlowPending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ ExpiresAt: now.Add(oauthBrowserFlowTTL),
+ CodeVerifier: pkce.CodeVerifier,
+ OAuthState: state,
+ RedirectURI: redirectURI,
+ }
+ h.storeOAuthFlow(flow)
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "status": "ok",
+ "provider": provider,
+ "method": method,
+ "flow_id": flow.ID,
+ "auth_url": authURL,
+ "expires_at": flow.ExpiresAt.Format(time.RFC3339),
+ })
+ return
+ default:
+ http.Error(w, "unsupported login method", http.StatusBadRequest)
+ }
+}
+
+func (h *Handler) handleGetOAuthFlow(w http.ResponseWriter, r *http.Request) {
+ flowID := strings.TrimSpace(r.PathValue("id"))
+ if flowID == "" {
+ http.Error(w, "missing flow id", http.StatusBadRequest)
+ return
+ }
+
+ flow, ok := h.getOAuthFlow(flowID)
+ if !ok {
+ http.Error(w, "flow not found", http.StatusNotFound)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(flowToResponse(flow))
+}
+
+func (h *Handler) handlePollOAuthFlow(w http.ResponseWriter, r *http.Request) {
+ flowID := strings.TrimSpace(r.PathValue("id"))
+ if flowID == "" {
+ http.Error(w, "missing flow id", http.StatusBadRequest)
+ return
+ }
+
+ flow, ok := h.getOAuthFlow(flowID)
+ if !ok {
+ http.Error(w, "flow not found", http.StatusNotFound)
+ return
+ }
+
+ if flow.Method != oauthMethodDeviceCode {
+ http.Error(w, "flow does not support polling", http.StatusBadRequest)
+ return
+ }
+ if flow.Status != oauthFlowPending {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(flowToResponse(flow))
+ return
+ }
+
+ cfg := auth.OpenAIOAuthConfig()
+ cred, err := oauthPollDeviceCodeOnce(cfg, flow.DeviceAuthID, flow.UserCode)
+ if err != nil {
+ if strings.Contains(strings.ToLower(err.Error()), "pending") {
+ updated, _ := h.getOAuthFlow(flowID)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(flowToResponse(updated))
+ return
+ }
+ h.setOAuthFlowError(flowID, fmt.Sprintf("device code poll failed: %v", err))
+ updated, _ := h.getOAuthFlow(flowID)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(flowToResponse(updated))
+ return
+ }
+ if cred == nil {
+ updated, _ := h.getOAuthFlow(flowID)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(flowToResponse(updated))
+ return
+ }
+
+ if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil {
+ h.setOAuthFlowError(flowID, fmt.Sprintf("failed to save credential: %v", err))
+ updated, _ := h.getOAuthFlow(flowID)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(flowToResponse(updated))
+ return
+ }
+
+ h.setOAuthFlowSuccess(flowID)
+ updated, _ := h.getOAuthFlow(flowID)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(flowToResponse(updated))
+}
+
+func (h *Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
+ state := strings.TrimSpace(r.URL.Query().Get("state"))
+ if state == "" {
+ renderOAuthCallbackPage(w, "", oauthFlowError, "Missing state", "missing_state")
+ return
+ }
+
+ flow, ok := h.getOAuthFlowByState(state)
+ if !ok {
+ renderOAuthCallbackPage(w, "", oauthFlowError, "OAuth flow not found", "flow_not_found")
+ return
+ }
+
+ if flow.Status != oauthFlowPending {
+ renderOAuthCallbackPage(w, flow.ID, flow.Status, "Flow already completed", flow.Error)
+ return
+ }
+
+ if errMsg := strings.TrimSpace(r.URL.Query().Get("error")); errMsg != "" {
+ if desc := strings.TrimSpace(r.URL.Query().Get("error_description")); desc != "" {
+ errMsg += ": " + desc
+ }
+ h.setOAuthFlowError(flow.ID, errMsg)
+ renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Authorization failed", errMsg)
+ return
+ }
+
+ code := strings.TrimSpace(r.URL.Query().Get("code"))
+ if code == "" {
+ h.setOAuthFlowError(flow.ID, "missing authorization code")
+ renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Missing authorization code", "missing_code")
+ return
+ }
+
+ cfg, err := oauthConfigForProvider(flow.Provider)
+ if err != nil {
+ h.setOAuthFlowError(flow.ID, err.Error())
+ renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Unsupported provider", err.Error())
+ return
+ }
+
+ cred, err := oauthExchangeCodeForTokens(cfg, code, flow.CodeVerifier, flow.RedirectURI)
+ if err != nil {
+ h.setOAuthFlowError(flow.ID, fmt.Sprintf("token exchange failed: %v", err))
+ renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Token exchange failed", err.Error())
+ return
+ }
+
+ if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil {
+ h.setOAuthFlowError(flow.ID, fmt.Sprintf("failed to save credential: %v", err))
+ renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Failed to save credential", err.Error())
+ return
+ }
+
+ h.setOAuthFlowSuccess(flow.ID)
+ renderOAuthCallbackPage(w, flow.ID, oauthFlowSuccess, "Authentication successful", "")
+}
+
+func (h *Handler) handleOAuthLogout(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ if err != nil {
+ http.Error(w, "failed to read request body", http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+
+ var req struct {
+ Provider string `json:"provider"`
+ }
+ if err = json.Unmarshal(body, &req); err != nil {
+ http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ provider, err := normalizeOAuthProvider(req.Provider)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if err := oauthDeleteCredential(provider); err != nil {
+ http.Error(w, fmt.Sprintf("failed to delete credential: %v", err), http.StatusInternalServerError)
+ return
+ }
+ if err := h.syncProviderAuthMethod(provider, ""); err != nil {
+ http.Error(w, fmt.Sprintf("failed to update config: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "status": "ok",
+ "provider": provider,
+ })
+}
+
+func renderOAuthCallbackPage(w http.ResponseWriter, flowID, status, title, errMsg string) {
+ payload := map[string]string{
+ "type": "picoclaw-oauth-result",
+ "flowId": flowID,
+ "status": status,
+ }
+ if errMsg != "" {
+ payload["error"] = errMsg
+ }
+ payloadJSON, _ := json.Marshal(payload)
+
+ message := title
+ if errMsg != "" {
+ message = fmt.Sprintf("%s: %s", title, errMsg)
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if status == oauthFlowSuccess {
+ w.WriteHeader(http.StatusOK)
+ } else {
+ w.WriteHeader(http.StatusBadRequest)
+ }
+
+ _, _ = fmt.Fprintf(
+ w,
+ "PicoClaw OAuth%s
%s
You can close this window.
",
+ string(payloadJSON),
+ html.EscapeString(title),
+ html.EscapeString(message),
+ )
+}
+
+func normalizeOAuthProvider(raw string) (string, error) {
+ provider := strings.ToLower(strings.TrimSpace(raw))
+ switch provider {
+ case "antigravity":
+ return oauthProviderGoogleAntigravity, nil
+ case oauthProviderOpenAI, oauthProviderAnthropic, oauthProviderGoogleAntigravity:
+ return provider, nil
+ default:
+ return "", fmt.Errorf("unsupported provider %q", raw)
+ }
+}
+
+func isOAuthMethodSupported(provider, method string) bool {
+ methods := oauthProviderMethods[provider]
+ for _, m := range methods {
+ if m == method {
+ return true
+ }
+ }
+ return false
+}
+
+func oauthConfigForProvider(provider string) (auth.OAuthProviderConfig, error) {
+ switch provider {
+ case oauthProviderOpenAI:
+ return auth.OpenAIOAuthConfig(), nil
+ case oauthProviderGoogleAntigravity:
+ return auth.GoogleAntigravityOAuthConfig(), nil
+ default:
+ return auth.OAuthProviderConfig{}, fmt.Errorf("provider %q does not support browser oauth", provider)
+ }
+}
+
+func oauthMethodTokenOrOAuth(method string) string {
+ if method == oauthMethodToken {
+ return oauthMethodToken
+ }
+ return "oauth"
+}
+
+func buildOAuthRedirectURI(r *http.Request) string {
+ scheme := "http"
+ if r.TLS != nil {
+ scheme = "https"
+ }
+ if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
+ scheme = strings.Split(forwarded, ",")[0]
+ }
+ return fmt.Sprintf("%s://%s/oauth/callback", scheme, r.Host)
+}
+
+func flowToResponse(flow *oauthFlow) oauthFlowResponse {
+ resp := oauthFlowResponse{
+ FlowID: flow.ID,
+ Provider: flow.Provider,
+ Method: flow.Method,
+ Status: flow.Status,
+ Error: flow.Error,
+ }
+ if !flow.ExpiresAt.IsZero() {
+ resp.ExpiresAt = flow.ExpiresAt.Format(time.RFC3339)
+ }
+ if flow.Method == oauthMethodDeviceCode {
+ resp.UserCode = flow.UserCode
+ resp.VerifyURL = flow.VerifyURL
+ resp.Interval = flow.Interval
+ }
+ return resp
+}
+
+func newOAuthFlowID() string {
+ buf := make([]byte, 16)
+ if _, err := rand.Read(buf); err != nil {
+ return fmt.Sprintf("oauth_%d", time.Now().UnixNano())
+ }
+ return hex.EncodeToString(buf)
+}
+
+func (h *Handler) storeOAuthFlow(flow *oauthFlow) {
+ now := oauthNow()
+ h.oauthMu.Lock()
+ defer h.oauthMu.Unlock()
+
+ h.gcOAuthFlowsLocked(now)
+ h.oauthFlows[flow.ID] = flow
+ if flow.OAuthState != "" {
+ h.oauthState[flow.OAuthState] = flow.ID
+ }
+}
+
+func (h *Handler) getOAuthFlow(flowID string) (*oauthFlow, bool) {
+ now := oauthNow()
+ h.oauthMu.Lock()
+ defer h.oauthMu.Unlock()
+
+ h.gcOAuthFlowsLocked(now)
+ flow, ok := h.oauthFlows[flowID]
+ if !ok {
+ return nil, false
+ }
+ cp := *flow
+ return &cp, true
+}
+
+func (h *Handler) getOAuthFlowByState(state string) (*oauthFlow, bool) {
+ now := oauthNow()
+ h.oauthMu.Lock()
+ defer h.oauthMu.Unlock()
+
+ h.gcOAuthFlowsLocked(now)
+ flowID, ok := h.oauthState[state]
+ if !ok {
+ return nil, false
+ }
+ flow, ok := h.oauthFlows[flowID]
+ if !ok {
+ delete(h.oauthState, state)
+ return nil, false
+ }
+ cp := *flow
+ return &cp, true
+}
+
+func (h *Handler) setOAuthFlowSuccess(flowID string) {
+ now := oauthNow()
+ h.oauthMu.Lock()
+ defer h.oauthMu.Unlock()
+
+ flow, ok := h.oauthFlows[flowID]
+ if !ok {
+ return
+ }
+ flow.Status = oauthFlowSuccess
+ flow.Error = ""
+ flow.UpdatedAt = now
+ if flow.OAuthState != "" {
+ delete(h.oauthState, flow.OAuthState)
+ }
+}
+
+func (h *Handler) setOAuthFlowError(flowID, errMsg string) {
+ now := oauthNow()
+ h.oauthMu.Lock()
+ defer h.oauthMu.Unlock()
+
+ flow, ok := h.oauthFlows[flowID]
+ if !ok {
+ return
+ }
+ flow.Status = oauthFlowError
+ flow.Error = errMsg
+ flow.UpdatedAt = now
+ if flow.OAuthState != "" {
+ delete(h.oauthState, flow.OAuthState)
+ }
+}
+
+func (h *Handler) gcOAuthFlowsLocked(now time.Time) {
+ for id, flow := range h.oauthFlows {
+ if flow.Status == oauthFlowPending && !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) {
+ flow.Status = oauthFlowExpired
+ flow.Error = "flow expired"
+ flow.UpdatedAt = now
+ if flow.OAuthState != "" {
+ delete(h.oauthState, flow.OAuthState)
+ }
+ }
+
+ if flow.Status != oauthFlowPending && now.Sub(flow.UpdatedAt) > oauthTerminalFlowGC {
+ if flow.OAuthState != "" {
+ delete(h.oauthState, flow.OAuthState)
+ }
+ delete(h.oauthFlows, id)
+ }
+ }
+}
+
+func (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred *auth.AuthCredential) error {
+ if cred == nil {
+ return fmt.Errorf("empty credential")
+ }
+
+ cp := *cred
+ cp.Provider = provider
+ if cp.AuthMethod == "" {
+ cp.AuthMethod = authMethod
+ }
+
+ if provider == oauthProviderGoogleAntigravity {
+ if cp.Email == "" {
+ email, err := oauthFetchGoogleUserEmailFunc(cp.AccessToken)
+ if err != nil {
+ log.Printf("oauth warning: could not fetch google email: %v", err)
+ } else {
+ cp.Email = email
+ }
+ }
+ if cp.ProjectID == "" {
+ projectID, err := oauthFetchAntigravityProject(cp.AccessToken)
+ if err != nil {
+ log.Printf("oauth warning: could not fetch antigravity project id: %v", err)
+ } else {
+ cp.ProjectID = projectID
+ }
+ }
+ }
+
+ if err := oauthSetCredential(provider, &cp); err != nil {
+ return fmt.Errorf("saving credential: %w", err)
+ }
+ if err := h.syncProviderAuthMethod(provider, authMethod); err != nil {
+ return fmt.Errorf("syncing provider auth config: %w", err)
+ }
+ return nil
+}
+
+func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
+ cfg, err := oauthLoadConfig(h.configPath)
+ if err != nil {
+ return err
+ }
+
+ switch provider {
+ case oauthProviderOpenAI:
+ cfg.Providers.OpenAI.AuthMethod = authMethod
+ case oauthProviderAnthropic:
+ cfg.Providers.Anthropic.AuthMethod = authMethod
+ case oauthProviderGoogleAntigravity:
+ cfg.Providers.Antigravity.AuthMethod = authMethod
+ default:
+ return fmt.Errorf("unsupported provider %q", provider)
+ }
+
+ found := false
+ for i := range cfg.ModelList {
+ if modelBelongsToProvider(provider, cfg.ModelList[i].Model) {
+ cfg.ModelList[i].AuthMethod = authMethod
+ found = true
+ }
+ }
+
+ if !found && authMethod != "" {
+ cfg.ModelList = append(cfg.ModelList, defaultModelConfigForProvider(provider, authMethod))
+ }
+
+ return oauthSaveConfig(h.configPath, cfg)
+}
+
+func modelBelongsToProvider(provider, model string) bool {
+ lower := strings.ToLower(strings.TrimSpace(model))
+ switch provider {
+ case oauthProviderOpenAI:
+ return lower == "openai" || strings.HasPrefix(lower, "openai/")
+ case oauthProviderAnthropic:
+ return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/")
+ case oauthProviderGoogleAntigravity:
+ return lower == "antigravity" ||
+ lower == "google-antigravity" ||
+ strings.HasPrefix(lower, "antigravity/") ||
+ strings.HasPrefix(lower, "google-antigravity/")
+ default:
+ return false
+ }
+}
+
+func defaultModelConfigForProvider(provider, authMethod string) config.ModelConfig {
+ switch provider {
+ case oauthProviderOpenAI:
+ return config.ModelConfig{
+ ModelName: "gpt-5.2",
+ Model: "openai/gpt-5.2",
+ AuthMethod: authMethod,
+ }
+ case oauthProviderAnthropic:
+ return config.ModelConfig{
+ ModelName: "claude-sonnet-4.6",
+ Model: "anthropic/claude-sonnet-4.6",
+ AuthMethod: authMethod,
+ }
+ case oauthProviderGoogleAntigravity:
+ return config.ModelConfig{
+ ModelName: "gemini-flash",
+ Model: "antigravity/gemini-3-flash",
+ AuthMethod: authMethod,
+ }
+ default:
+ return config.ModelConfig{}
+ }
+}
+
+func fetchGoogleUserEmail(accessToken string) (string, error) {
+ req, err := http.NewRequest(http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("userinfo request failed: %s", string(body))
+ }
+
+ var userInfo struct {
+ Email string `json:"email"`
+ }
+ if err := json.Unmarshal(body, &userInfo); err != nil {
+ return "", err
+ }
+ if userInfo.Email == "" {
+ return "", fmt.Errorf("empty email in userinfo response")
+ }
+ return userInfo.Email, nil
+}
diff --git a/web/backend/api/oauth_test.go b/web/backend/api/oauth_test.go
new file mode 100644
index 000000000..2103e1efc
--- /dev/null
+++ b/web/backend/api/oauth_test.go
@@ -0,0 +1,293 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/auth"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestOAuthLoginRejectsUnsupportedMethod(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/api/oauth/login",
+ strings.NewReader(`{"provider":"anthropic","method":"browser"}`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+}
+
+func TestOAuthBrowserFlowCreatedAndQueried(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+
+ oauthGeneratePKCE = func() (auth.PKCECodes, error) {
+ return auth.PKCECodes{CodeVerifier: "verifier-1", CodeChallenge: "challenge-1"}, nil
+ }
+ oauthGenerateState = func() (string, error) { return "state-1", nil }
+ oauthBuildAuthorizeURL = func(cfg auth.OAuthProviderConfig, pkce auth.PKCECodes, state, redirectURI string) string {
+ return "https://example.com/authorize?state=" + state
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/api/oauth/login",
+ strings.NewReader(`{"provider":"openai","method":"browser"}`),
+ )
+ req.Host = "localhost:18800"
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var loginResp map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &loginResp); err != nil {
+ t.Fatalf("unmarshal login response: %v", err)
+ }
+ flowID, _ := loginResp["flow_id"].(string)
+ if flowID == "" {
+ t.Fatalf("flow_id is empty: %v", loginResp)
+ }
+ if loginResp["auth_url"] != "https://example.com/authorize?state=state-1" {
+ t.Fatalf("unexpected auth_url: %v", loginResp["auth_url"])
+ }
+
+ rec2 := httptest.NewRecorder()
+ req2 := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/"+flowID, nil)
+ mux.ServeHTTP(rec2, req2)
+ if rec2.Code != http.StatusOK {
+ t.Fatalf("flow status code = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String())
+ }
+ var flowResp oauthFlowResponse
+ if err := json.Unmarshal(rec2.Body.Bytes(), &flowResp); err != nil {
+ t.Fatalf("unmarshal flow response: %v", err)
+ }
+ if flowResp.Status != oauthFlowPending {
+ t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowPending)
+ }
+ if flowResp.Method != oauthMethodBrowser {
+ t.Fatalf("flow method = %q, want %q", flowResp.Method, oauthMethodBrowser)
+ }
+}
+
+func TestOAuthFlowExpiresWhenQueried(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+
+ now := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC)
+ oauthNow = func() time.Time { return now }
+
+ h := NewHandler(configPath)
+ h.storeOAuthFlow(&oauthFlow{
+ ID: "expired-flow",
+ Provider: oauthProviderOpenAI,
+ Method: oauthMethodBrowser,
+ Status: oauthFlowPending,
+ CreatedAt: now.Add(-20 * time.Minute),
+ UpdatedAt: now.Add(-20 * time.Minute),
+ ExpiresAt: now.Add(-1 * time.Minute),
+ })
+
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/expired-flow", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ var flowResp oauthFlowResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &flowResp); err != nil {
+ t.Fatalf("unmarshal flow response: %v", err)
+ }
+ if flowResp.Status != oauthFlowExpired {
+ t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowExpired)
+ }
+}
+
+func TestOAuthCallbackUnknownState(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/oauth/callback?state=unknown&code=abc", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
+ }
+ if !strings.Contains(rec.Body.String(), "OAuth flow not found") {
+ t.Fatalf("unexpected body: %s", rec.Body.String())
+ }
+}
+
+func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig error: %v", err)
+ }
+ cfg.Providers.OpenAI.AuthMethod = "oauth"
+ cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
+ ModelName: "gpt-5.2",
+ Model: "openai/gpt-5.2",
+ AuthMethod: "oauth",
+ })
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig error: %v", err)
+ }
+ if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
+ AccessToken: "token-before-logout",
+ Provider: oauthProviderOpenAI,
+ AuthMethod: "oauth",
+ }); err != nil {
+ t.Fatalf("SetCredential error: %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cred, err := auth.GetCredential(oauthProviderOpenAI)
+ if err != nil {
+ t.Fatalf("GetCredential error: %v", err)
+ }
+ if cred != nil {
+ t.Fatalf("expected credential deleted, got %#v", cred)
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig error: %v", err)
+ }
+ if updated.Providers.OpenAI.AuthMethod != "" {
+ t.Fatalf("providers.openai.auth_method = %q, want empty", updated.Providers.OpenAI.AuthMethod)
+ }
+ for _, m := range updated.ModelList {
+ if strings.HasPrefix(m.Model, "openai/") && m.AuthMethod != "" {
+ t.Fatalf("openai model auth_method = %q, want empty", m.AuthMethod)
+ }
+ }
+}
+
+func setupOAuthTestEnv(t *testing.T) (string, func()) {
+ t.Helper()
+
+ tmp := t.TempDir()
+ oldHome := os.Getenv("HOME")
+ oldPicoHome := os.Getenv("PICOCLAW_HOME")
+
+ if err := os.Setenv("HOME", tmp); err != nil {
+ t.Fatalf("set HOME: %v", err)
+ }
+ if err := os.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")); err != nil {
+ t.Fatalf("set PICOCLAW_HOME: %v", err)
+ }
+
+ cfg := config.DefaultConfig()
+ cfg.ModelList = []config.ModelConfig{{
+ ModelName: "custom-default",
+ Model: "openai/gpt-4o",
+ APIKey: "sk-default",
+ }}
+ cfg.Agents.Defaults.ModelName = "custom-default"
+
+ configPath := filepath.Join(tmp, "config.json")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig error: %v", err)
+ }
+
+ cleanup := func() {
+ _ = os.Setenv("HOME", oldHome)
+ if oldPicoHome == "" {
+ _ = os.Unsetenv("PICOCLAW_HOME")
+ } else {
+ _ = os.Setenv("PICOCLAW_HOME", oldPicoHome)
+ }
+ }
+ return configPath, cleanup
+}
+
+func resetOAuthHooks(t *testing.T) {
+ t.Helper()
+
+ origNow := oauthNow
+ origGeneratePKCE := oauthGeneratePKCE
+ origGenerateState := oauthGenerateState
+ origBuildAuthorizeURL := oauthBuildAuthorizeURL
+ origRequestDeviceCode := oauthRequestDeviceCode
+ origPollDeviceCodeOnce := oauthPollDeviceCodeOnce
+ origExchangeCodeForTokens := oauthExchangeCodeForTokens
+ origGetCredential := oauthGetCredential
+ origSetCredential := oauthSetCredential
+ origDeleteCredential := oauthDeleteCredential
+ origLoadConfig := oauthLoadConfig
+ origSaveConfig := oauthSaveConfig
+ origFetchProject := oauthFetchAntigravityProject
+ origFetchGoogleEmail := oauthFetchGoogleUserEmailFunc
+
+ t.Cleanup(func() {
+ oauthNow = origNow
+ oauthGeneratePKCE = origGeneratePKCE
+ oauthGenerateState = origGenerateState
+ oauthBuildAuthorizeURL = origBuildAuthorizeURL
+ oauthRequestDeviceCode = origRequestDeviceCode
+ oauthPollDeviceCodeOnce = origPollDeviceCodeOnce
+ oauthExchangeCodeForTokens = origExchangeCodeForTokens
+ oauthGetCredential = origGetCredential
+ oauthSetCredential = origSetCredential
+ oauthDeleteCredential = origDeleteCredential
+ oauthLoadConfig = origLoadConfig
+ oauthSaveConfig = origSaveConfig
+ oauthFetchAntigravityProject = origFetchProject
+ oauthFetchGoogleUserEmailFunc = origFetchGoogleEmail
+ })
+}
diff --git a/web/backend/api/router.go b/web/backend/api/router.go
index b6550c212..b92356a1d 100644
--- a/web/backend/api/router.go
+++ b/web/backend/api/router.go
@@ -1,16 +1,24 @@
package api
-import "net/http"
+import (
+ "net/http"
+ "sync"
+)
// Handler serves HTTP API requests.
type Handler struct {
configPath string
+ oauthMu sync.Mutex
+ oauthFlows map[string]*oauthFlow
+ oauthState map[string]string
}
// NewHandler creates an instance of the API handler.
func NewHandler(configPath string) *Handler {
return &Handler{
configPath: configPath,
+ oauthFlows: make(map[string]*oauthFlow),
+ oauthState: make(map[string]string),
}
}
@@ -28,6 +36,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Session history
h.registerSessionRoutes(mux)
+ // OAuth login and credential management
+ h.registerOAuthRoutes(mux)
+
// Model list management
h.registerModelRoutes(mux)
}
diff --git a/web/frontend/src/api/oauth.ts b/web/frontend/src/api/oauth.ts
new file mode 100644
index 000000000..a1ed1afcb
--- /dev/null
+++ b/web/frontend/src/api/oauth.ts
@@ -0,0 +1,102 @@
+export type OAuthProvider = "openai" | "anthropic" | "google-antigravity"
+export type OAuthMethod = "browser" | "device_code" | "token"
+
+export interface OAuthProviderStatus {
+ provider: OAuthProvider
+ display_name: string
+ methods: OAuthMethod[]
+ logged_in: boolean
+ status: "connected" | "expired" | "needs_refresh" | "not_logged_in"
+ auth_method?: string
+ expires_at?: string
+ account_id?: string
+ email?: string
+ project_id?: string
+}
+
+export interface OAuthFlowState {
+ flow_id: string
+ provider: OAuthProvider
+ method: OAuthMethod
+ status: "pending" | "success" | "error" | "expired"
+ expires_at?: string
+ error?: string
+ user_code?: string
+ verify_url?: string
+ interval?: number
+}
+
+export interface OAuthLoginRequest {
+ provider: OAuthProvider
+ method: OAuthMethod
+ token?: string
+}
+
+export interface OAuthLoginResponse {
+ status: string
+ provider: OAuthProvider
+ method: OAuthMethod
+ flow_id?: string
+ auth_url?: string
+ user_code?: string
+ verify_url?: string
+ interval?: number
+ expires_at?: string
+}
+
+interface OAuthProvidersResponse {
+ providers: OAuthProviderStatus[]
+}
+
+const BASE_URL = ""
+
+async function request(path: string, options?: RequestInit): Promise {
+ const res = await fetch(`${BASE_URL}${path}`, options)
+ if (!res.ok) {
+ const message = await res.text()
+ throw new Error(message || `API error: ${res.status} ${res.statusText}`)
+ }
+ return res.json() as Promise
+}
+
+export async function getOAuthProviders(): Promise {
+ return request("/api/oauth/providers")
+}
+
+export async function loginOAuth(
+ payload: OAuthLoginRequest,
+): Promise {
+ return request("/api/oauth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function getOAuthFlow(flowID: string): Promise {
+ return request(
+ `/api/oauth/flows/${encodeURIComponent(flowID)}`,
+ )
+}
+
+export async function pollOAuthFlow(flowID: string): Promise {
+ return request(
+ `/api/oauth/flows/${encodeURIComponent(flowID)}/poll`,
+ {
+ method: "POST",
+ },
+ )
+}
+
+export async function logoutOAuth(
+ provider: OAuthProvider,
+): Promise<{ status: string; provider: OAuthProvider }> {
+ return request<{ status: string; provider: OAuthProvider }>(
+ "/api/oauth/logout",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ provider }),
+ },
+ )
+}
diff --git a/web/frontend/src/components/credentials/anthropic-credential-card.tsx b/web/frontend/src/components/credentials/anthropic-credential-card.tsx
new file mode 100644
index 000000000..be2b655ea
--- /dev/null
+++ b/web/frontend/src/components/credentials/anthropic-credential-card.tsx
@@ -0,0 +1,102 @@
+import { IconKey, IconLoader2, IconPlayerStopFilled } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { OAuthProviderStatus } from "@/api/oauth"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+
+import { CredentialCard } from "./credential-card"
+
+interface AnthropicCredentialCardProps {
+ status?: OAuthProviderStatus
+ activeAction: string
+ token: string
+ onTokenChange: (value: string) => void
+ onStopLoading: () => void
+ onSaveToken: () => void
+ onAskLogout: () => void
+}
+
+export function AnthropicCredentialCard({
+ status,
+ activeAction,
+ token,
+ onTokenChange,
+ onStopLoading,
+ onSaveToken,
+ onAskLogout,
+}: AnthropicCredentialCardProps) {
+ const { t } = useTranslation()
+ const actionBusy = activeAction !== ""
+ const tokenLoading = activeAction === "anthropic:token"
+ const stopLabel = t("credentials.actions.stopLoading", "Stop Loading")
+
+ return (
+
+
+
+ onTokenChange(e.target.value)}
+ type="password"
+ placeholder={t(
+ "credentials.fields.anthropicToken",
+ "Anthropic token",
+ )}
+ />
+
+ {tokenLoading && (
+
+ )}
+
+
+
+ }
+ footer={
+ status?.logged_in ? (
+
+ ) : null
+ }
+ />
+ )
+}
diff --git a/web/frontend/src/components/credentials/antigravity-credential-card.tsx b/web/frontend/src/components/credentials/antigravity-credential-card.tsx
new file mode 100644
index 000000000..787bf9fff
--- /dev/null
+++ b/web/frontend/src/components/credentials/antigravity-credential-card.tsx
@@ -0,0 +1,101 @@
+import {
+ IconLoader2,
+ IconLockOpen,
+ IconPlayerStopFilled,
+} from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { OAuthProviderStatus } from "@/api/oauth"
+import { Button } from "@/components/ui/button"
+
+import { CredentialCard } from "./credential-card"
+
+interface AntigravityCredentialCardProps {
+ status?: OAuthProviderStatus
+ activeAction: string
+ onStopLoading: () => void
+ onStartBrowserOAuth: () => void
+ onAskLogout: () => void
+}
+
+export function AntigravityCredentialCard({
+ status,
+ activeAction,
+ onStopLoading,
+ onStartBrowserOAuth,
+ onAskLogout,
+}: AntigravityCredentialCardProps) {
+ const { t } = useTranslation()
+ const actionBusy = activeAction !== ""
+ const browserLoading = activeAction === "google-antigravity:browser"
+
+ return (
+
+ {status?.email && (
+
+ {t("credentials.labels.email", "Email")}: {status.email}
+
+ )}
+ {status?.project_id && (
+
+ {t("credentials.labels.project", "Project")}: {status.project_id}
+
+ )}
+
+ }
+ actions={
+
+
+
+ {browserLoading && (
+
+ )}
+
+
+ }
+ footer={
+ status?.logged_in ? (
+
+ ) : null
+ }
+ />
+ )
+}
diff --git a/web/frontend/src/components/credentials/credential-card.tsx b/web/frontend/src/components/credentials/credential-card.tsx
new file mode 100644
index 000000000..c9bae3bbf
--- /dev/null
+++ b/web/frontend/src/components/credentials/credential-card.tsx
@@ -0,0 +1,44 @@
+import type { ReactNode } from "react"
+
+import type { OAuthProviderStatus } from "@/api/oauth"
+
+import { ProviderStatusLine } from "./provider-status-line"
+
+interface CredentialCardProps {
+ title: string
+ description: string
+ status: OAuthProviderStatus["status"]
+ authMethod?: string
+ details?: ReactNode
+ actions: ReactNode
+ footer?: ReactNode
+}
+
+export function CredentialCard({
+ title,
+ description,
+ status,
+ authMethod,
+ details,
+ actions,
+ footer,
+}: CredentialCardProps) {
+ return (
+
+
+
{title}
+
{description}
+
+
+
+
+ {details}
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/credentials/credentials-page.tsx b/web/frontend/src/components/credentials/credentials-page.tsx
new file mode 100644
index 000000000..34f701e75
--- /dev/null
+++ b/web/frontend/src/components/credentials/credentials-page.tsx
@@ -0,0 +1,132 @@
+import { IconLoader2 } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import { PageHeader } from "@/components/page-header"
+import { useCredentialsPage } from "@/hooks/use-credentials-page"
+
+import { AnthropicCredentialCard } from "./anthropic-credential-card"
+import { AntigravityCredentialCard } from "./antigravity-credential-card"
+import { DeviceCodeSheet } from "./device-code-sheet"
+import { LogoutConfirmDialog } from "./logout-confirm-dialog"
+import { OpenAICredentialCard } from "./openai-credential-card"
+
+export function CredentialsPage() {
+ const { t } = useTranslation()
+ const {
+ loading,
+ error,
+ activeAction,
+ activeFlow,
+ flowHint,
+ openAIToken,
+ anthropicToken,
+ openaiStatus,
+ anthropicStatus,
+ antigravityStatus,
+ logoutDialogOpen,
+ logoutConfirmProvider,
+ logoutProviderLabel,
+ deviceSheetOpen,
+ deviceFlow,
+ setOpenAIToken,
+ setAnthropicToken,
+ startBrowserOAuth,
+ startOpenAIDeviceCode,
+ stopLoading,
+ saveToken,
+ askLogout,
+ handleConfirmLogout,
+ handleLogoutDialogOpenChange,
+ handleDeviceSheetOpenChange,
+ } = useCredentialsPage()
+
+ return (
+
+
+
+
+
+
+ {t(
+ "credentials.description",
+ "Manage OAuth and token-based credentials for supported providers.",
+ )}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {activeFlow && (
+
+
+ {t("credentials.flow.current", "Current authentication status")}
+
+
{flowHint}
+
+ )}
+
+ {loading ? (
+
+
+ {t("credentials.loading", "Loading credentials...")}
+
+ ) : (
+
+
void startBrowserOAuth("openai")}
+ onStartDeviceCode={() => void startOpenAIDeviceCode()}
+ onStopLoading={stopLoading}
+ onSaveToken={() => void saveToken("openai", openAIToken.trim())}
+ onAskLogout={() => askLogout("openai")}
+ />
+
+
+ void saveToken("anthropic", anthropicToken.trim())
+ }
+ onAskLogout={() => askLogout("anthropic")}
+ />
+
+
+ void startBrowserOAuth("google-antigravity")
+ }
+ onAskLogout={() => askLogout("google-antigravity")}
+ />
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/credentials/device-code-sheet.tsx b/web/frontend/src/components/credentials/device-code-sheet.tsx
new file mode 100644
index 000000000..0c1f94e5b
--- /dev/null
+++ b/web/frontend/src/components/credentials/device-code-sheet.tsx
@@ -0,0 +1,97 @@
+import { IconRefresh } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { OAuthFlowState } from "@/api/oauth"
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+interface DeviceCodeSheetProps {
+ open: boolean
+ flow: OAuthFlowState | null
+ flowHint: string
+ onOpenChange: (open: boolean) => void
+}
+
+export function DeviceCodeSheet({
+ open,
+ flow,
+ flowHint,
+ onOpenChange,
+}: DeviceCodeSheetProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+ {t("credentials.device.title", "OpenAI Device Login")}
+
+
+ {t(
+ "credentials.device.description",
+ "Open the verification page and enter the code below. This page will refresh automatically.",
+ )}
+
+
+
+
+
+
+ {t("credentials.device.code", "User Code")}
+
+
+ {flow?.user_code || "-"}
+
+
+
+
+
+
+
+ {t("credentials.device.polling", "Polling login status...")}
+
+
+ {flow && (
+
+ {flowHint}
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/credentials/logout-confirm-dialog.tsx b/web/frontend/src/components/credentials/logout-confirm-dialog.tsx
new file mode 100644
index 000000000..2443a0217
--- /dev/null
+++ b/web/frontend/src/components/credentials/logout-confirm-dialog.tsx
@@ -0,0 +1,57 @@
+import { IconLoader2 } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+
+interface LogoutConfirmDialogProps {
+ open: boolean
+ providerLabel: string
+ isSubmitting: boolean
+ onOpenChange: (open: boolean) => void
+ onConfirm: () => void | Promise
+}
+
+export function LogoutConfirmDialog({
+ open,
+ providerLabel,
+ isSubmitting,
+ onOpenChange,
+ onConfirm,
+}: LogoutConfirmDialogProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+ {t("credentials.logoutDialog.title", "Logout provider?")}
+
+
+ {t(
+ "credentials.logoutDialog.description",
+ "This will remove your saved credential for {{provider}}.",
+ { provider: providerLabel },
+ )}
+
+
+
+ {t("common.cancel", "Cancel")}
+
+ {isSubmitting && }
+ {t("credentials.actions.logout", "Logout")}
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/credentials/openai-credential-card.tsx b/web/frontend/src/components/credentials/openai-credential-card.tsx
new file mode 100644
index 000000000..a8f643f45
--- /dev/null
+++ b/web/frontend/src/components/credentials/openai-credential-card.tsx
@@ -0,0 +1,161 @@
+import {
+ IconBrandOpenai,
+ IconClockHour4,
+ IconKey,
+ IconLoader2,
+ IconPlayerStopFilled,
+} from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { OAuthProviderStatus } from "@/api/oauth"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+
+import { CredentialCard } from "./credential-card"
+
+interface OpenAICredentialCardProps {
+ status?: OAuthProviderStatus
+ activeAction: string
+ token: string
+ onTokenChange: (value: string) => void
+ onStartBrowserOAuth: () => void
+ onStartDeviceCode: () => void
+ onStopLoading: () => void
+ onSaveToken: () => void
+ onAskLogout: () => void
+}
+
+export function OpenAICredentialCard({
+ status,
+ activeAction,
+ token,
+ onTokenChange,
+ onStartBrowserOAuth,
+ onStartDeviceCode,
+ onStopLoading,
+ onSaveToken,
+ onAskLogout,
+}: OpenAICredentialCardProps) {
+ const { t } = useTranslation()
+ const actionBusy = activeAction !== ""
+ const browserLoading = activeAction === "openai:browser"
+ const deviceLoading = activeAction === "openai:device"
+ const oauthLoading = browserLoading || deviceLoading
+ const tokenLoading = activeAction === "openai:token"
+
+ return (
+
+ {t("credentials.labels.account", "Account")}: {status.account_id}
+
+ ) : null
+ }
+ actions={
+
+
+
+
+
+
+ {oauthLoading && !deviceLoading && (
+
+ )}
+
+
+
+
+
+
+
+ onTokenChange(e.target.value)}
+ type="password"
+ placeholder={t(
+ "credentials.fields.openaiToken",
+ "OpenAI token",
+ )}
+ />
+
+ {tokenLoading && (
+
+ )}
+
+
+
+
+ }
+ footer={
+ status?.logged_in ? (
+
+ ) : null
+ }
+ />
+ )
+}
diff --git a/web/frontend/src/components/credentials/provider-status-line.tsx b/web/frontend/src/components/credentials/provider-status-line.tsx
new file mode 100644
index 000000000..77229071d
--- /dev/null
+++ b/web/frontend/src/components/credentials/provider-status-line.tsx
@@ -0,0 +1,43 @@
+import { useTranslation } from "react-i18next"
+
+import type { OAuthProviderStatus } from "@/api/oauth"
+
+interface ProviderStatusLineProps {
+ status: OAuthProviderStatus["status"]
+ authMethod?: string
+}
+
+export function ProviderStatusLine({
+ status,
+ authMethod,
+}: ProviderStatusLineProps) {
+ const { t } = useTranslation()
+
+ const style =
+ status === "connected"
+ ? "bg-green-500/10 text-green-700 dark:text-green-300"
+ : status === "needs_refresh"
+ ? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
+ : status === "expired"
+ ? "bg-red-500/10 text-red-700 dark:text-red-300"
+ : "bg-muted text-muted-foreground"
+
+ return (
+
+
+ {status === "connected"
+ ? t("credentials.status.connected", "Connected")
+ : status === "needs_refresh"
+ ? t("credentials.status.needsRefresh", "Needs refresh")
+ : status === "expired"
+ ? t("credentials.status.expired", "Expired")
+ : t("credentials.status.notLoggedIn", "Not logged in")}
+
+ {authMethod && (
+
+ {authMethod}
+
+ )}
+
+ )
+}
diff --git a/web/frontend/src/hooks/use-credentials-page.ts b/web/frontend/src/hooks/use-credentials-page.ts
new file mode 100644
index 000000000..0f0bb050e
--- /dev/null
+++ b/web/frontend/src/hooks/use-credentials-page.ts
@@ -0,0 +1,458 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useTranslation } from "react-i18next"
+
+import {
+ type OAuthFlowState,
+ type OAuthProvider,
+ type OAuthProviderStatus,
+ getOAuthFlow,
+ getOAuthProviders,
+ loginOAuth,
+ logoutOAuth,
+ pollOAuthFlow,
+} from "@/api/oauth"
+
+type FlowWatchMode = "" | "status" | "poll"
+
+function getProviderLabel(provider: OAuthProvider | ""): string {
+ if (provider === "openai") return "OpenAI"
+ if (provider === "anthropic") return "Anthropic"
+ if (provider === "google-antigravity") return "Google Antigravity"
+ return ""
+}
+
+export function useCredentialsPage() {
+ const { t } = useTranslation()
+ const [providers, setProviders] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState("")
+
+ const [activeAction, setActiveAction] = useState("")
+ const [activeFlow, setActiveFlow] = useState(null)
+ const actionTokenRef = useRef(0)
+
+ const [watchFlowID, setWatchFlowID] = useState("")
+ const [watchMode, setWatchMode] = useState("")
+ const [pollIntervalMs, setPollIntervalMs] = useState(2000)
+
+ const [openAIToken, setOpenAIToken] = useState("")
+ const [anthropicToken, setAnthropicToken] = useState("")
+
+ const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
+ const [logoutConfirmProvider, setLogoutConfirmProvider] = useState<
+ OAuthProvider | ""
+ >("")
+
+ const [deviceSheetOpen, setDeviceSheetOpen] = useState(false)
+ const [deviceFlow, setDeviceFlow] = useState(null)
+
+ const loadProviders = useCallback(async () => {
+ try {
+ const data = await getOAuthProviders()
+ setProviders(data.providers)
+ setError("")
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : t("credentials.errors.loadFailed", "Failed to load credentials"),
+ )
+ } finally {
+ setLoading(false)
+ }
+ }, [t])
+
+ useEffect(() => {
+ void loadProviders()
+ }, [loadProviders])
+
+ useEffect(() => {
+ if (!watchFlowID || !watchMode) {
+ return
+ }
+
+ let canceled = false
+ let timer: ReturnType | null = null
+
+ const step = async () => {
+ try {
+ const flow =
+ watchMode === "poll"
+ ? await pollOAuthFlow(watchFlowID)
+ : await getOAuthFlow(watchFlowID)
+
+ if (canceled) {
+ return
+ }
+
+ setActiveFlow(flow)
+ setDeviceFlow((prev) =>
+ prev?.flow_id === flow.flow_id ? { ...prev, ...flow } : prev,
+ )
+
+ if (flow.status === "pending") {
+ timer = setTimeout(step, pollIntervalMs)
+ return
+ }
+
+ if (watchMode === "poll") {
+ setDeviceSheetOpen(false)
+ }
+
+ setWatchFlowID("")
+ setWatchMode("")
+ setActiveAction("")
+ await loadProviders()
+ } catch (err) {
+ if (canceled) {
+ return
+ }
+ setWatchFlowID("")
+ setWatchMode("")
+ setActiveAction("")
+ setError(
+ err instanceof Error
+ ? err.message
+ : t(
+ "credentials.errors.flowFailed",
+ "Failed to check authentication flow",
+ ),
+ )
+ }
+ }
+
+ void step()
+
+ return () => {
+ canceled = true
+ if (timer) {
+ clearTimeout(timer)
+ }
+ }
+ }, [loadProviders, pollIntervalMs, t, watchFlowID, watchMode])
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search)
+ const flowID = params.get("oauth_flow_id")
+ if (!flowID) {
+ return
+ }
+
+ setWatchFlowID(flowID)
+ setWatchMode("status")
+ setPollIntervalMs(700)
+
+ window.history.replaceState({}, "", window.location.pathname)
+ }, [])
+
+ useEffect(() => {
+ const onMessage = (event: MessageEvent) => {
+ const data = event.data as
+ | { type?: string; flowId?: string; status?: string }
+ | undefined
+ if (!data || data.type !== "picoclaw-oauth-result" || !data.flowId) {
+ return
+ }
+
+ setWatchFlowID(data.flowId)
+ setWatchMode("status")
+ setPollIntervalMs(700)
+ }
+
+ window.addEventListener("message", onMessage)
+ return () => window.removeEventListener("message", onMessage)
+ }, [])
+
+ const providersMap = useMemo(() => {
+ const map = new Map()
+ for (const item of providers) {
+ map.set(item.provider, item)
+ }
+ return map
+ }, [providers])
+
+ const openaiStatus = providersMap.get("openai")
+ const anthropicStatus = providersMap.get("anthropic")
+ const antigravityStatus = providersMap.get("google-antigravity")
+
+ const bumpActionToken = useCallback(() => {
+ actionTokenRef.current += 1
+ return actionTokenRef.current
+ }, [])
+
+ const isActionTokenCurrent = useCallback((token: number) => {
+ return actionTokenRef.current === token
+ }, [])
+
+ const startBrowserOAuth = useCallback(
+ async (provider: OAuthProvider) => {
+ const actionToken = bumpActionToken()
+ setActiveAction(`${provider}:browser`)
+ setError("")
+
+ const authTab = window.open("", "_blank")
+ if (!authTab) {
+ if (!isActionTokenCurrent(actionToken)) {
+ return
+ }
+ setActiveAction("")
+ setError(
+ t(
+ "credentials.errors.popupBlocked",
+ "Unable to open a new tab. Please allow popups and try again.",
+ ),
+ )
+ return
+ }
+
+ try {
+ const resp = await loginOAuth({ provider, method: "browser" })
+ if (!isActionTokenCurrent(actionToken)) {
+ authTab.close()
+ return
+ }
+ if (!resp.auth_url || !resp.flow_id) {
+ throw new Error(
+ t(
+ "credentials.errors.invalidBrowserResponse",
+ "Invalid browser login response",
+ ),
+ )
+ }
+
+ authTab.location.href = resp.auth_url
+
+ setActiveFlow({
+ flow_id: resp.flow_id,
+ provider,
+ method: "browser",
+ status: "pending",
+ expires_at: resp.expires_at,
+ })
+ setWatchFlowID(resp.flow_id)
+ setWatchMode("status")
+ setPollIntervalMs(2000)
+ } catch (err) {
+ if (!isActionTokenCurrent(actionToken)) {
+ authTab.close()
+ return
+ }
+ authTab.close()
+ setActiveAction("")
+ setError(
+ err instanceof Error
+ ? err.message
+ : t("credentials.errors.loginFailed", "Login failed"),
+ )
+ }
+ },
+ [bumpActionToken, isActionTokenCurrent, t],
+ )
+
+ const startOpenAIDeviceCode = useCallback(async () => {
+ const actionToken = bumpActionToken()
+ setActiveAction("openai:device")
+ setError("")
+
+ try {
+ const resp = await loginOAuth({
+ provider: "openai",
+ method: "device_code",
+ })
+ if (!isActionTokenCurrent(actionToken)) {
+ return
+ }
+ if (!resp.flow_id || !resp.user_code || !resp.verify_url) {
+ throw new Error(
+ t(
+ "credentials.errors.invalidDeviceResponse",
+ "Invalid device code response",
+ ),
+ )
+ }
+
+ const flow: OAuthFlowState = {
+ flow_id: resp.flow_id,
+ provider: "openai",
+ method: "device_code",
+ status: "pending",
+ user_code: resp.user_code,
+ verify_url: resp.verify_url,
+ interval: resp.interval,
+ expires_at: resp.expires_at,
+ }
+
+ setDeviceFlow(flow)
+ setDeviceSheetOpen(true)
+ setActiveFlow(flow)
+ setWatchFlowID(resp.flow_id)
+ setWatchMode("poll")
+ setPollIntervalMs(Math.max(1000, (resp.interval ?? 5) * 1000))
+ } catch (err) {
+ if (!isActionTokenCurrent(actionToken)) {
+ return
+ }
+ setActiveAction("")
+ setError(
+ err instanceof Error
+ ? err.message
+ : t("credentials.errors.loginFailed", "Login failed"),
+ )
+ }
+ }, [bumpActionToken, isActionTokenCurrent, t])
+
+ const saveToken = useCallback(
+ async (provider: OAuthProvider, token: string) => {
+ const actionID = `${provider}:token`
+ setActiveAction(actionID)
+ setError("")
+
+ try {
+ await loginOAuth({ provider, method: "token", token })
+ if (provider === "openai") {
+ setOpenAIToken("")
+ }
+ if (provider === "anthropic") {
+ setAnthropicToken("")
+ }
+ await loadProviders()
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : t("credentials.errors.loginFailed", "Login failed"),
+ )
+ } finally {
+ setActiveAction("")
+ }
+ },
+ [loadProviders, t],
+ )
+
+ const doLogout = useCallback(
+ async (provider: OAuthProvider) => {
+ const actionID = `${provider}:logout`
+ setActiveAction(actionID)
+ setError("")
+
+ try {
+ await logoutOAuth(provider)
+ await loadProviders()
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : t("credentials.errors.logoutFailed", "Logout failed"),
+ )
+ } finally {
+ setActiveAction("")
+ }
+ },
+ [loadProviders, t],
+ )
+
+ const askLogout = useCallback((provider: OAuthProvider) => {
+ setLogoutConfirmProvider(provider)
+ setLogoutDialogOpen(true)
+ }, [])
+
+ const handleConfirmLogout = useCallback(async () => {
+ if (!logoutConfirmProvider) {
+ return
+ }
+ await doLogout(logoutConfirmProvider)
+ setLogoutDialogOpen(false)
+ setLogoutConfirmProvider("")
+ }, [doLogout, logoutConfirmProvider])
+
+ const handleLogoutDialogOpenChange = useCallback((open: boolean) => {
+ setLogoutDialogOpen(open)
+ if (!open) {
+ setLogoutConfirmProvider("")
+ }
+ }, [])
+
+ const handleDeviceSheetOpenChange = useCallback(
+ (open: boolean) => {
+ setDeviceSheetOpen(open)
+ if (open) {
+ return
+ }
+
+ if (watchMode === "poll") {
+ setWatchFlowID("")
+ setWatchMode("")
+ if (activeAction === "openai:device") {
+ setActiveAction("")
+ }
+ }
+
+ setDeviceFlow(null)
+ if (
+ activeFlow?.method === "device_code" &&
+ activeFlow.status === "pending"
+ ) {
+ setActiveFlow(null)
+ }
+ },
+ [activeAction, activeFlow, watchMode],
+ )
+
+ const stopLoading = useCallback(() => {
+ bumpActionToken()
+ setWatchFlowID("")
+ setWatchMode("")
+ setActiveAction("")
+ setDeviceSheetOpen(false)
+ setDeviceFlow(null)
+ setActiveFlow((prev) => (prev?.status === "pending" ? null : prev))
+ }, [bumpActionToken])
+
+ const logoutProviderLabel = getProviderLabel(logoutConfirmProvider)
+
+ const flowHint = useMemo(() => {
+ if (!activeFlow) {
+ return ""
+ }
+ if (activeFlow.status === "pending") {
+ return t("credentials.flow.pending", "Waiting for authorization...")
+ }
+ if (activeFlow.status === "success") {
+ return t("credentials.flow.success", "Authentication successful")
+ }
+ if (activeFlow.status === "expired") {
+ return t("credentials.flow.expired", "Authentication session expired")
+ }
+ return (
+ activeFlow.error || t("credentials.flow.error", "Authentication failed")
+ )
+ }, [activeFlow, t])
+
+ return {
+ loading,
+ error,
+ activeAction,
+ activeFlow,
+ flowHint,
+ openAIToken,
+ anthropicToken,
+ openaiStatus,
+ anthropicStatus,
+ antigravityStatus,
+ logoutDialogOpen,
+ logoutConfirmProvider,
+ logoutProviderLabel,
+ deviceSheetOpen,
+ deviceFlow,
+ setOpenAIToken,
+ setAnthropicToken,
+ startBrowserOAuth,
+ startOpenAIDeviceCode,
+ stopLoading,
+ saveToken,
+ askLogout,
+ handleConfirmLogout,
+ handleLogoutDialogOpenChange,
+ handleDeviceSheetOpenChange,
+ }
+}
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index bf9f896e9..ae7349fae 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -73,6 +73,70 @@
"cancel": "Cancel",
"save": "Save"
},
+ "credentials": {
+ "description": "Manage OAuth and token-based credentials for supported providers.",
+ "loading": "Loading credentials...",
+ "providers": {
+ "openai": {
+ "description": "Supports browser OAuth, device code, and token login."
+ },
+ "anthropic": {
+ "description": "Uses token login for Claude access."
+ },
+ "antigravity": {
+ "description": "Uses browser OAuth for Google Cloud Code Assist."
+ }
+ },
+ "status": {
+ "connected": "Connected",
+ "needsRefresh": "Needs refresh",
+ "expired": "Expired",
+ "notLoggedIn": "Not logged in"
+ },
+ "actions": {
+ "browser": "Browser OAuth",
+ "deviceCode": "Device Code",
+ "saveToken": "Save",
+ "logout": "Logout"
+ },
+ "logoutDialog": {
+ "title": "Logout provider?",
+ "description": "This will remove your saved credential for {{provider}}."
+ },
+ "fields": {
+ "openaiToken": "OpenAI token",
+ "anthropicToken": "Anthropic token"
+ },
+ "labels": {
+ "account": "Account",
+ "email": "Email",
+ "project": "Project"
+ },
+ "errors": {
+ "loadFailed": "Failed to load credentials",
+ "flowFailed": "Failed to check authentication flow",
+ "loginFailed": "Login failed",
+ "logoutFailed": "Logout failed",
+ "invalidBrowserResponse": "Invalid browser login response",
+ "invalidDeviceResponse": "Invalid device code response",
+ "popupBlocked": "Unable to open a new tab. Please allow popups and try again."
+ },
+ "flow": {
+ "current": "Current authentication status",
+ "pending": "Waiting for authorization...",
+ "success": "Authentication successful",
+ "error": "Authentication failed",
+ "expired": "Authentication session expired"
+ },
+ "device": {
+ "title": "OpenAI Device Login",
+ "description": "Open the verification page and enter the code below. This page will refresh automatically.",
+ "code": "User Code",
+ "url": "Verification URL",
+ "polling": "Polling login status...",
+ "open": "Open Verification Page"
+ }
+ },
"models": {
"description": "Configure API keys for AI providers. Only configured models are available for chat.",
"loadError": "Failed to load models",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index eb278b992..723a70af3 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -73,6 +73,70 @@
"cancel": "取消",
"save": "保存"
},
+ "credentials": {
+ "description": "管理已支持服务商的 OAuth 与 Token 凭据。",
+ "loading": "正在加载凭据...",
+ "providers": {
+ "openai": {
+ "description": "支持浏览器 OAuth、设备码和 Token 登录。"
+ },
+ "anthropic": {
+ "description": "使用 Token 登录 Claude。"
+ },
+ "antigravity": {
+ "description": "使用浏览器 OAuth 登录 Google Cloud Code Assist。"
+ }
+ },
+ "status": {
+ "connected": "已连接",
+ "needsRefresh": "即将过期",
+ "expired": "已过期",
+ "notLoggedIn": "未登录"
+ },
+ "actions": {
+ "browser": "浏览器 OAuth",
+ "deviceCode": "设备码",
+ "saveToken": "保存",
+ "logout": "退出登录"
+ },
+ "logoutDialog": {
+ "title": "确认退出登录?",
+ "description": "这将删除 {{provider}} 的已保存凭据。"
+ },
+ "fields": {
+ "openaiToken": "OpenAI Token",
+ "anthropicToken": "Anthropic Token"
+ },
+ "labels": {
+ "account": "账号",
+ "email": "邮箱",
+ "project": "项目"
+ },
+ "errors": {
+ "loadFailed": "加载凭据失败",
+ "flowFailed": "查询授权流程失败",
+ "loginFailed": "登录失败",
+ "logoutFailed": "退出登录失败",
+ "invalidBrowserResponse": "浏览器登录响应无效",
+ "invalidDeviceResponse": "设备码响应无效",
+ "popupBlocked": "无法打开新标签页,请允许弹窗后重试。"
+ },
+ "flow": {
+ "current": "当前授权状态",
+ "pending": "等待授权中...",
+ "success": "认证成功",
+ "error": "认证失败",
+ "expired": "授权会话已过期"
+ },
+ "device": {
+ "title": "OpenAI 设备码登录",
+ "description": "请打开验证页面并输入下方代码,此页面会自动刷新授权状态。",
+ "code": "用户代码",
+ "url": "验证地址",
+ "polling": "正在轮询登录状态...",
+ "open": "打开验证页面"
+ }
+ },
"models": {
"description": "为 AI 服务商配置 API Key。只有已配置的模型可用于对话。",
"loadError": "加载模型列表失败",
diff --git a/web/frontend/src/routes/credentials.tsx b/web/frontend/src/routes/credentials.tsx
index 2abbcf4fd..349a6b734 100644
--- a/web/frontend/src/routes/credentials.tsx
+++ b/web/frontend/src/routes/credentials.tsx
@@ -1,30 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"
-import { useTranslation } from "react-i18next"
-import { PageHeader } from "@/components/page-header"
+import { CredentialsPage } from "@/components/credentials/credentials-page"
export const Route = createFileRoute("/credentials")({
component: CredentialsPage,
})
-
-function CredentialsPage() {
- const { t } = useTranslation()
- return (
-
-
-
-
-
- {t("navigation.credentials", "Credentials")}
-
-
- {t(
- "pages.credentials.description",
- "Securely manage your API keys and credentials.",
- )}
-
-
-
-
- )
-}