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:
parent
e676092dd4
commit
42d4d4cc74
16 changed files with 2575 additions and 25 deletions
844
web/backend/api/oauth.go
Normal file
844
web/backend/api/oauth.go
Normal 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
|
||||||
|
}
|
||||||
293
web/backend/api/oauth_test.go
Normal file
293
web/backend/api/oauth_test.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,24 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
// Handler serves HTTP API requests.
|
// Handler serves HTTP API requests.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
configPath string
|
configPath string
|
||||||
|
oauthMu sync.Mutex
|
||||||
|
oauthFlows map[string]*oauthFlow
|
||||||
|
oauthState map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates an instance of the API handler.
|
// NewHandler creates an instance of the API handler.
|
||||||
func NewHandler(configPath string) *Handler {
|
func NewHandler(configPath string) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
configPath: configPath,
|
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
|
// Session history
|
||||||
h.registerSessionRoutes(mux)
|
h.registerSessionRoutes(mux)
|
||||||
|
|
||||||
|
// OAuth login and credential management
|
||||||
|
h.registerOAuthRoutes(mux)
|
||||||
|
|
||||||
// Model list management
|
// Model list management
|
||||||
h.registerModelRoutes(mux)
|
h.registerModelRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
web/frontend/src/api/oauth.ts
Normal file
102
web/frontend/src/api/oauth.ts
Normal 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 }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
web/frontend/src/components/credentials/credential-card.tsx
Normal file
44
web/frontend/src/components/credentials/credential-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
132
web/frontend/src/components/credentials/credentials-page.tsx
Normal file
132
web/frontend/src/components/credentials/credentials-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
458
web/frontend/src/hooks/use-credentials-page.ts
Normal file
458
web/frontend/src/hooks/use-credentials-page.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,70 @@
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save"
|
"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": {
|
"models": {
|
||||||
"description": "Configure API keys for AI providers. Only configured models are available for chat.",
|
"description": "Configure API keys for AI providers. Only configured models are available for chat.",
|
||||||
"loadError": "Failed to load models",
|
"loadError": "Failed to load models",
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,70 @@
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"save": "保存"
|
"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": {
|
"models": {
|
||||||
"description": "为 AI 服务商配置 API Key。只有已配置的模型可用于对话。",
|
"description": "为 AI 服务商配置 API Key。只有已配置的模型可用于对话。",
|
||||||
"loadError": "加载模型列表失败",
|
"loadError": "加载模型列表失败",
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,7 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
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")({
|
export const Route = createFileRoute("/credentials")({
|
||||||
component: CredentialsPage,
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue