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} +
+ +
+
{actions}
+
{footer}
+
+
+ ) +} 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.url", "Verification URL")} +

+ + {flow?.verify_url || "-"} + +
+ +
+ + {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.", - )} -

-
-
-
- ) -}