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
This commit is contained in:
wenjie 2026-03-06 19:04:06 +08:00
parent e676092dd4
commit 42d4d4cc74
16 changed files with 2575 additions and 25 deletions

844
web/backend/api/oauth.go Normal file
View file

@ -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,
"<!doctype html><html><head><meta charset=\"utf-8\"><title>PicoClaw OAuth</title></head><body><script>(function(){var payload=%s;var hasOpener=false;try{if(window.opener&&!window.opener.closed){window.opener.postMessage(payload,window.location.origin);hasOpener=true}}catch(e){}var target='/credentials?oauth_flow_id='+encodeURIComponent(payload.flowId||'')+'&oauth_status='+encodeURIComponent(payload.status||'');setTimeout(function(){if(hasOpener){window.close();return}window.location.replace(target)},800)})();</script><div style=\"font-family:Inter,system-ui,sans-serif;padding:24px\"><h2>%s</h2><p>%s</p><p>You can close this window.</p></div></body></html>",
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
}

View file

@ -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
})
}

View file

@ -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)
}

View file

@ -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<T>(path: string, options?: RequestInit): Promise<T> {
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<T>
}
export async function getOAuthProviders(): Promise<OAuthProvidersResponse> {
return request<OAuthProvidersResponse>("/api/oauth/providers")
}
export async function loginOAuth(
payload: OAuthLoginRequest,
): Promise<OAuthLoginResponse> {
return request<OAuthLoginResponse>("/api/oauth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
}
export async function getOAuthFlow(flowID: string): Promise<OAuthFlowState> {
return request<OAuthFlowState>(
`/api/oauth/flows/${encodeURIComponent(flowID)}`,
)
}
export async function pollOAuthFlow(flowID: string): Promise<OAuthFlowState> {
return request<OAuthFlowState>(
`/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 }),
},
)
}

View file

@ -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 (
<CredentialCard
title="Anthropic"
description={t(
"credentials.providers.anthropic.description",
"Uses token login for Claude access.",
)}
status={status?.status ?? "not_logged_in"}
authMethod={status?.auth_method}
actions={
<div className="border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3">
<div className="flex h-full flex-col gap-3">
<div className="flex h-full items-center gap-2">
<Input
value={token}
onChange={(e) => onTokenChange(e.target.value)}
type="password"
placeholder={t(
"credentials.fields.anthropicToken",
"Anthropic token",
)}
/>
<Button
size="sm"
className="w-fit"
disabled={actionBusy || !token.trim()}
onClick={onSaveToken}
>
{tokenLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconKey className="size-4" />
{t("credentials.actions.saveToken", "Save")}
</Button>
{tokenLoading && (
<Button
size="icon-sm"
variant="ghost"
onClick={onStopLoading}
aria-label={stopLabel}
title={stopLabel}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<IconPlayerStopFilled className="size-4" />
</Button>
)}
</div>
</div>
</div>
}
footer={
status?.logged_in ? (
<Button
variant="ghost"
size="sm"
disabled={actionBusy}
onClick={onAskLogout}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{activeAction === "anthropic:logout" && (
<IconLoader2 className="size-4 animate-spin" />
)}
{t("credentials.actions.logout", "Logout")}
</Button>
) : null
}
/>
)
}

View file

@ -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 (
<CredentialCard
title="Google Antigravity"
description={t(
"credentials.providers.antigravity.description",
"Uses browser OAuth for Google Cloud Code Assist.",
)}
status={status?.status ?? "not_logged_in"}
authMethod={status?.auth_method}
details={
<div className="space-y-1">
{status?.email && (
<p>
{t("credentials.labels.email", "Email")}: {status.email}
</p>
)}
{status?.project_id && (
<p>
{t("credentials.labels.project", "Project")}: {status.project_id}
</p>
)}
</div>
}
actions={
<div className="border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={actionBusy}
onClick={onStartBrowserOAuth}
>
{browserLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconLockOpen className="size-4" />
{t("credentials.actions.browser", "Browser OAuth")}
</Button>
{browserLoading && (
<Button
size="icon-xs"
variant="secondary"
onClick={onStopLoading}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<IconPlayerStopFilled className="size-3" />
</Button>
)}
</div>
</div>
}
footer={
status?.logged_in ? (
<Button
variant="ghost"
size="sm"
disabled={actionBusy}
onClick={onAskLogout}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{activeAction === "google-antigravity:logout" && (
<IconLoader2 className="size-4 animate-spin" />
)}
{t("credentials.actions.logout", "Logout")}
</Button>
) : null
}
/>
)
}

View file

@ -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 (
<section className="bg-card flex h-full flex-col rounded-xl border p-4">
<div className="min-h-16">
<h3 className="text-base font-semibold">{title}</h3>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
</div>
<ProviderStatusLine status={status} authMethod={authMethod} />
<div className="text-muted-foreground mt-3 min-h-11 text-xs leading-5">
{details}
</div>
<div className="mt-auto flex flex-col gap-4 pt-4">
<div className="min-h-[112px]">{actions}</div>
<div className="min-h-8">{footer}</div>
</div>
</section>
)
}

View file

@ -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 (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.credentials", "Credentials")} />
<div className="min-h-0 flex-1 overflow-y-auto px-4 sm:px-6">
<div className="pt-2">
<p className="text-muted-foreground text-sm">
{t(
"credentials.description",
"Manage OAuth and token-based credentials for supported providers.",
)}
</p>
</div>
{error && (
<div className="text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm">
{error}
</div>
)}
{activeFlow && (
<div className="bg-muted mt-4 rounded-lg border px-4 py-3 text-sm">
<p className="font-medium">
{t("credentials.flow.current", "Current authentication status")}
</p>
<p className="text-muted-foreground mt-1">{flowHint}</p>
</div>
)}
{loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-10 text-sm">
<IconLoader2 className="size-4 animate-spin" />
{t("credentials.loading", "Loading credentials...")}
</div>
) : (
<div className="grid grid-cols-1 gap-4 py-5 lg:auto-rows-fr lg:grid-cols-3">
<OpenAICredentialCard
status={openaiStatus}
activeAction={activeAction}
token={openAIToken}
onTokenChange={setOpenAIToken}
onStartBrowserOAuth={() => void startBrowserOAuth("openai")}
onStartDeviceCode={() => void startOpenAIDeviceCode()}
onStopLoading={stopLoading}
onSaveToken={() => void saveToken("openai", openAIToken.trim())}
onAskLogout={() => askLogout("openai")}
/>
<AnthropicCredentialCard
status={anthropicStatus}
activeAction={activeAction}
token={anthropicToken}
onTokenChange={setAnthropicToken}
onStopLoading={stopLoading}
onSaveToken={() =>
void saveToken("anthropic", anthropicToken.trim())
}
onAskLogout={() => askLogout("anthropic")}
/>
<AntigravityCredentialCard
status={antigravityStatus}
activeAction={activeAction}
onStopLoading={stopLoading}
onStartBrowserOAuth={() =>
void startBrowserOAuth("google-antigravity")
}
onAskLogout={() => askLogout("google-antigravity")}
/>
</div>
)}
</div>
<LogoutConfirmDialog
open={logoutDialogOpen}
providerLabel={logoutProviderLabel}
isSubmitting={activeAction === `${logoutConfirmProvider}:logout`}
onOpenChange={handleLogoutDialogOpenChange}
onConfirm={handleConfirmLogout}
/>
<DeviceCodeSheet
open={deviceSheetOpen}
flow={deviceFlow}
flowHint={flowHint}
onOpenChange={handleDeviceSheetOpenChange}
/>
</div>
)
}

View file

@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="data-[side=right]:!w-full data-[side=right]:sm:!w-[480px] data-[side=right]:sm:!max-w-[480px]"
>
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>
{t("credentials.device.title", "OpenAI Device Login")}
</SheetTitle>
<SheetDescription>
{t(
"credentials.device.description",
"Open the verification page and enter the code below. This page will refresh automatically.",
)}
</SheetDescription>
</SheetHeader>
<div className="space-y-4 px-6 py-5">
<div>
<p className="text-muted-foreground text-xs uppercase">
{t("credentials.device.code", "User Code")}
</p>
<p className="mt-1 rounded-md border px-3 py-2 font-mono text-lg font-semibold tracking-wide">
{flow?.user_code || "-"}
</p>
</div>
<div>
<p className="text-muted-foreground text-xs uppercase">
{t("credentials.device.url", "Verification URL")}
</p>
<a
href={flow?.verify_url || "#"}
target="_blank"
rel="noreferrer"
className="text-primary mt-1 block text-sm break-all underline"
>
{flow?.verify_url || "-"}
</a>
</div>
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<IconRefresh className="size-4" />
{t("credentials.device.polling", "Polling login status...")}
</div>
{flow && (
<div className="bg-muted rounded-md border px-3 py-2 text-sm">
{flowHint}
</div>
)}
</div>
<SheetFooter className="border-t px-6 py-4">
<Button variant="outline" asChild disabled={!flow?.verify_url}>
<a href={flow?.verify_url || "#"} target="_blank" rel="noreferrer">
{t("credentials.device.open", "Open Verification Page")}
</a>
</Button>
<Button onClick={() => onOpenChange(false)}>
{t("common.cancel", "Close")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View file

@ -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<void>
}
export function LogoutConfirmDialog({
open,
providerLabel,
isSubmitting,
onOpenChange,
onConfirm,
}: LogoutConfirmDialogProps) {
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("credentials.logoutDialog.title", "Logout provider?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"credentials.logoutDialog.description",
"This will remove your saved credential for {{provider}}.",
{ provider: providerLabel },
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} variant="destructive">
{isSubmitting && <IconLoader2 className="size-4 animate-spin" />}
{t("credentials.actions.logout", "Logout")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -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 (
<CredentialCard
title="OpenAI"
description={t(
"credentials.providers.openai.description",
"Supports browser OAuth, device code, and token login.",
)}
status={status?.status ?? "not_logged_in"}
authMethod={status?.auth_method}
details={
status?.account_id ? (
<p>
{t("credentials.labels.account", "Account")}: {status.account_id}
</p>
) : null
}
actions={
<div className="border-muted flex h-[120px] flex-col rounded-lg border p-3">
<div className="flex h-full flex-col gap-3">
<div className="min-h-8">
<div className="flex flex-nowrap items-center gap-2 overflow-x-auto">
<Button
size="sm"
variant="outline"
disabled={actionBusy}
onClick={onStartBrowserOAuth}
>
{browserLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconBrandOpenai className="size-4" />
{t("credentials.actions.browser", "Browser OAuth")}
</Button>
{oauthLoading && !deviceLoading && (
<Button
size="icon-xs"
variant="secondary"
onClick={onStopLoading}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<IconPlayerStopFilled className="size-4" />
</Button>
)}
<Button
size="sm"
variant="outline"
disabled={actionBusy}
onClick={onStartDeviceCode}
>
{deviceLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconClockHour4 className="size-4" />
{t("credentials.actions.deviceCode", "Device Code")}
</Button>
</div>
</div>
<div className="min-h-9 flex-1">
<div className="flex h-full items-center gap-2">
<Input
value={token}
onChange={(e) => onTokenChange(e.target.value)}
type="password"
placeholder={t(
"credentials.fields.openaiToken",
"OpenAI token",
)}
/>
<Button
size="sm"
disabled={actionBusy || !token.trim()}
onClick={onSaveToken}
>
{tokenLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconKey className="size-4" />
{t("credentials.actions.saveToken", "Save")}
</Button>
{tokenLoading && (
<Button
size="icon-sm"
variant="ghost"
onClick={onStopLoading}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<IconPlayerStopFilled className="size-4" />
</Button>
)}
</div>
</div>
</div>
</div>
}
footer={
status?.logged_in ? (
<Button
variant="ghost"
size="sm"
disabled={actionBusy}
onClick={onAskLogout}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{activeAction === "openai:logout" && (
<IconLoader2 className="size-4 animate-spin" />
)}
{t("credentials.actions.logout", "Logout")}
</Button>
) : null
}
/>
)
}

View file

@ -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 (
<div className="flex items-center justify-between gap-2">
<span className={`rounded px-2 py-1 text-xs font-medium ${style}`}>
{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")}
</span>
{authMethod && (
<span className="text-muted-foreground text-xs uppercase">
{authMethod}
</span>
)}
</div>
)
}

View file

@ -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<OAuthProviderStatus[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [activeAction, setActiveAction] = useState("")
const [activeFlow, setActiveFlow] = useState<OAuthFlowState | null>(null)
const actionTokenRef = useRef(0)
const [watchFlowID, setWatchFlowID] = useState("")
const [watchMode, setWatchMode] = useState<FlowWatchMode>("")
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<OAuthFlowState | null>(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<typeof setTimeout> | 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<OAuthProvider, OAuthProviderStatus>()
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,
}
}

View file

@ -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",

View file

@ -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": "加载模型列表失败",

View file

@ -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 (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.credentials", "Credentials")} />
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{t("navigation.credentials", "Credentials")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t(
"pages.credentials.description",
"Securely manage your API keys and credentials.",
)}
</p>
</div>
</div>
</div>
)
}