From d86651ff550a0426853f2e605cc6e7e17cac05eb Mon Sep 17 00:00:00 2001 From: wangbo Date: Sun, 10 May 2026 23:22:26 +0800 Subject: [PATCH] feat: refine api key permissions and admin routes --- .../internal/httpapi/access_rule_handlers.go | 49 +++ .../httpapi/core_flow_integration_test.go | 17 +- apps/api/internal/httpapi/handlers.go | 46 ++ apps/api/internal/httpapi/server.go | 108 +++-- apps/api/internal/store/access_rules.go | 145 +++++- apps/api/internal/store/identity_admin.go | 15 +- apps/api/internal/store/platform_models.go | 37 +- apps/api/internal/store/postgres.go | 95 +++- apps/web/package.json | 3 + apps/web/src/App.tsx | 70 ++- apps/web/src/api.ts | 113 +++-- apps/web/src/components/ui/calendar.tsx | 54 +++ .../src/components/ui/date-time-picker.tsx | 87 ++++ apps/web/src/components/ui/index.ts | 3 + apps/web/src/components/ui/popover.tsx | 24 + apps/web/src/pages/WorkspacePage.tsx | 262 +++++++++-- .../pages/admin/AccessPermissionEditor.tsx | 416 ++++++++++++++++++ apps/web/src/pages/admin/AccessRulesPanel.tsx | 386 +--------------- apps/web/src/styles/pages.css | 98 ++++- apps/web/src/styles/ui.css | 154 +++++++ apps/web/src/types.ts | 1 + packages/contracts/src/index.ts | 1 + pnpm-lock.yaml | 31 ++ 23 files changed, 1683 insertions(+), 532 deletions(-) create mode 100644 apps/web/src/components/ui/calendar.tsx create mode 100644 apps/web/src/components/ui/date-time-picker.tsx create mode 100644 apps/web/src/components/ui/popover.tsx create mode 100644 apps/web/src/pages/admin/AccessPermissionEditor.tsx diff --git a/apps/api/internal/httpapi/access_rule_handlers.go b/apps/api/internal/httpapi/access_rule_handlers.go index 4b9c38f..d75d13f 100644 --- a/apps/api/internal/httpapi/access_rule_handlers.go +++ b/apps/api/internal/httpapi/access_rule_handlers.go @@ -2,9 +2,11 @@ package httpapi import ( "encoding/json" + "errors" "net/http" "strings" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) @@ -18,6 +20,21 @@ func (s *Server) listAccessRules(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items}) } +func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) { + user, _ := auth.UserFromContext(r.Context()) + items, err := s.store.ListAPIKeyAccessRules(r.Context(), user) + if err != nil { + if errors.Is(err, store.ErrLocalUserRequired) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.logger.Error("list api key access rules failed", "error", err) + writeError(w, http.StatusInternalServerError, "list api key access rules failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + func (s *Server) createAccessRule(w http.ResponseWriter, r *http.Request) { var input store.AccessRuleInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { @@ -60,6 +77,38 @@ func (s *Server) batchAccessRules(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items}) } +func (s *Server) batchAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) { + user, _ := auth.UserFromContext(r.Context()) + var input store.AccessRuleBatchInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validAccessRuleBatchInput(input) || input.SubjectType != "api_key" { + writeError(w, http.StatusBadRequest, "api key subject, effect and resources are required") + return + } + items, err := s.store.BatchAPIKeyAccessRules(r.Context(), input, user) + if err != nil { + if errors.Is(err, store.ErrLocalUserRequired) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "api key not found") + return + } + if errors.Is(err, store.ErrAccessRuleResourceDenied) { + writeError(w, http.StatusForbidden, "resource is not available for current user group") + return + } + s.logger.Error("batch api key access rules failed", "error", err) + writeError(w, http.StatusInternalServerError, "batch api key access rules failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + func (s *Server) updateAccessRule(w http.ResponseWriter, r *http.Request) { var input store.AccessRuleInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { diff --git a/apps/api/internal/httpapi/core_flow_integration_test.go b/apps/api/internal/httpapi/core_flow_integration_test.go index 9a1bab8..11e2c44 100644 --- a/apps/api/internal/httpapi/core_flow_integration_test.go +++ b/apps/api/internal/httpapi/core_flow_integration_test.go @@ -123,6 +123,7 @@ func TestCoreLocalFlow(t *testing.T) { if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil { t.Fatalf("promote smoke user: %v", err) } + doJSON(t, server.URL, http.MethodGet, "/api/admin/models", apiKeyResponse.Secret, nil, http.StatusForbidden, nil) inviteCode := "INVITE-" + suffixText if _, err := testPool.Exec(ctx, ` @@ -173,7 +174,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { PlatformKey string `json:"platformKey"` Status string `json:"status"` } - doJSON(t, server.URL, http.MethodPost, "/api/v1/platforms", apiKeyResponse.Secret, map[string]any{ + doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{ "provider": "openai", "platformKey": "openai-smoke-" + suffixText, "name": "OpenAI Smoke", @@ -194,7 +195,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { ModelType []string `json:"modelType"` } `json:"items"` } - doJSON(t, server.URL, http.MethodGet, "/api/v1/catalog/base-models", apiKeyResponse.Secret, nil, http.StatusOK, &baseModels) + doJSON(t, server.URL, http.MethodGet, "/api/admin/catalog/base-models", loginResponse.AccessToken, nil, http.StatusOK, &baseModels) if len(baseModels.Items) < 300 { t.Fatalf("server-main seed should include the migrated base model catalog: got %d", len(baseModels.Items)) } @@ -213,7 +214,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { CanonicalModelKey string `json:"canonicalModelKey"` ModelAlias string `json:"modelAlias"` } - doJSON(t, server.URL, http.MethodPost, "/api/v1/catalog/base-models", apiKeyResponse.Secret, baseModelInput, http.StatusCreated, &createdBaseModel) + doJSON(t, server.URL, http.MethodPost, "/api/admin/catalog/base-models", loginResponse.AccessToken, baseModelInput, http.StatusCreated, &createdBaseModel) if createdBaseModel.ID == "" || createdBaseModel.CanonicalModelKey != baseModelInput["canonicalModelKey"] { t.Fatalf("unexpected created base model: %+v", createdBaseModel) } @@ -221,11 +222,11 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { var updatedBaseModel struct { ModelAlias string `json:"modelAlias"` } - doJSON(t, server.URL, http.MethodPatch, "/api/v1/catalog/base-models/"+createdBaseModel.ID, apiKeyResponse.Secret, baseModelInput, http.StatusOK, &updatedBaseModel) + doJSON(t, server.URL, http.MethodPatch, "/api/admin/catalog/base-models/"+createdBaseModel.ID, loginResponse.AccessToken, baseModelInput, http.StatusOK, &updatedBaseModel) if updatedBaseModel.ModelAlias != "Smoke Base Model Updated" { t.Fatalf("unexpected updated base model: %+v", updatedBaseModel) } - doJSON(t, server.URL, http.MethodDelete, "/api/v1/catalog/base-models/"+createdBaseModel.ID, apiKeyResponse.Secret, nil, http.StatusNoContent, nil) + doJSON(t, server.URL, http.MethodDelete, "/api/admin/catalog/base-models/"+createdBaseModel.ID, loginResponse.AccessToken, nil, http.StatusNoContent, nil) var taskResponse struct { Task struct { @@ -321,7 +322,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { var failedPlatform struct { ID string `json:"id"` } - doJSON(t, server.URL, http.MethodPost, "/api/v1/platforms", apiKeyResponse.Secret, map[string]any{ + doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{ "provider": "openai", "platformKey": "openai-fail-" + suffixText, "name": "OpenAI Retryable Failure", @@ -333,7 +334,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { var successPlatform struct { ID string `json:"id"` } - doJSON(t, server.URL, http.MethodPost, "/api/v1/platforms", apiKeyResponse.Secret, map[string]any{ + doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{ "provider": "openai", "platformKey": "openai-success-" + suffixText, "name": "OpenAI Retry Success", @@ -344,7 +345,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { }, http.StatusCreated, &successPlatform) for _, platformID := range []string{failedPlatform.ID, successPlatform.ID} { var platformModel map[string]any - doJSON(t, server.URL, http.MethodPost, "/api/v1/platforms/"+platformID+"/models", apiKeyResponse.Secret, map[string]any{ + doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platformID+"/models", loginResponse.AccessToken, map[string]any{ "canonicalModelKey": "openai:gpt-4o-mini", "modelName": failoverModel, "modelAlias": failoverModel, diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 3b418ed..b4adff5 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -142,6 +142,33 @@ func (s *Server) listPlatforms(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": platforms}) } +func (s *Server) listPlayablePlatforms(w http.ResponseWriter, r *http.Request) { + user, _ := auth.UserFromContext(r.Context()) + models, err := s.store.ListAccessiblePlatformModels(r.Context(), user) + if err != nil { + s.logger.Error("list playable platform models failed", "error", err) + writeError(w, http.StatusInternalServerError, "list playable platforms failed") + return + } + allowedPlatformIDs := map[string]bool{} + for _, model := range models { + allowedPlatformIDs[model.PlatformID] = true + } + platforms, err := s.store.ListPlatforms(r.Context()) + if err != nil { + s.logger.Error("list platforms failed", "error", err) + writeError(w, http.StatusInternalServerError, "list playable platforms failed") + return + } + filtered := platforms[:0] + for _, platform := range platforms { + if platform.Status == "enabled" && allowedPlatformIDs[platform.ID] { + filtered = append(filtered, platform) + } + } + writeJSON(w, http.StatusOK, map[string]any{"items": filtered}) +} + func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) { var input store.CreatePlatformInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { @@ -406,6 +433,25 @@ func (s *Server) disableAPIKey(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "disable api key failed") } +func (s *Server) deleteAPIKey(w http.ResponseWriter, r *http.Request) { + user, _ := auth.UserFromContext(r.Context()) + err := s.store.DeleteAPIKey(r.Context(), r.PathValue("apiKeyID"), user) + if err == nil { + w.WriteHeader(http.StatusNoContent) + return + } + if errors.Is(err, store.ErrLocalUserRequired) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "api key not found") + return + } + s.logger.Error("delete api key failed", "error", err) + writeError(w, http.StatusInternalServerError, "delete api key failed") +} + func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) { user, _ := auth.UserFromContext(r.Context()) var body map[string]any diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 60f3a2c..6875514 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -38,58 +38,63 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han mux.Handle("GET /api/v1/me", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.me))) mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders))) mux.Handle("GET /api/v1/public/catalog/base-models", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listBaseModels))) - mux.Handle("GET /api/v1/catalog/providers", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listCatalogProviders))) - mux.Handle("POST /api/v1/catalog/providers", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createCatalogProvider))) - mux.Handle("PATCH /api/v1/catalog/providers/{providerID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateCatalogProvider))) - mux.Handle("DELETE /api/v1/catalog/providers/{providerID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteCatalogProvider))) - mux.Handle("GET /api/v1/catalog/base-models", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listBaseModels))) - mux.Handle("POST /api/v1/catalog/base-models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createBaseModel))) - mux.Handle("POST /api/v1/catalog/base-models/reset-all", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.resetAllBaseModels))) - mux.Handle("PATCH /api/v1/catalog/base-models/{baseModelID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateBaseModel))) - mux.Handle("POST /api/v1/catalog/base-models/{baseModelID}/reset", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.resetBaseModel))) - mux.Handle("DELETE /api/v1/catalog/base-models/{baseModelID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteBaseModel))) - mux.Handle("GET /api/v1/tenants", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listTenants))) - mux.Handle("POST /api/v1/tenants", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createTenant))) - mux.Handle("PATCH /api/v1/tenants/{tenantID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateTenant))) - mux.Handle("DELETE /api/v1/tenants/{tenantID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteTenant))) - mux.Handle("GET /api/v1/users", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUsers))) - mux.Handle("POST /api/v1/users", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createGatewayUser))) - mux.Handle("PATCH /api/v1/users/{userID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateGatewayUser))) - mux.Handle("DELETE /api/v1/users/{userID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteGatewayUser))) - mux.Handle("GET /api/v1/user-groups", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUserGroups))) - mux.Handle("POST /api/v1/user-groups", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createUserGroup))) - mux.Handle("PATCH /api/v1/user-groups/{groupID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateUserGroup))) - mux.Handle("DELETE /api/v1/user-groups/{groupID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteUserGroup))) - mux.Handle("GET /api/v1/access-rules", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listAccessRules))) - mux.Handle("POST /api/v1/access-rules", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createAccessRule))) - mux.Handle("POST /api/v1/access-rules/batch", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.batchAccessRules))) - mux.Handle("PATCH /api/v1/access-rules/{ruleID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateAccessRule))) - mux.Handle("DELETE /api/v1/access-rules/{ruleID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteAccessRule))) + mux.Handle("GET /api/admin/catalog/providers", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listCatalogProviders))) + mux.Handle("POST /api/admin/catalog/providers", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createCatalogProvider))) + mux.Handle("PATCH /api/admin/catalog/providers/{providerID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateCatalogProvider))) + mux.Handle("DELETE /api/admin/catalog/providers/{providerID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteCatalogProvider))) + mux.Handle("GET /api/admin/catalog/base-models", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listBaseModels))) + mux.Handle("POST /api/admin/catalog/base-models", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createBaseModel))) + mux.Handle("POST /api/admin/catalog/base-models/reset-all", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.resetAllBaseModels))) + mux.Handle("PATCH /api/admin/catalog/base-models/{baseModelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateBaseModel))) + mux.Handle("POST /api/admin/catalog/base-models/{baseModelID}/reset", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.resetBaseModel))) + mux.Handle("DELETE /api/admin/catalog/base-models/{baseModelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteBaseModel))) + mux.Handle("GET /api/admin/tenants", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listTenants))) + mux.Handle("POST /api/admin/tenants", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createTenant))) + mux.Handle("PATCH /api/admin/tenants/{tenantID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateTenant))) + mux.Handle("DELETE /api/admin/tenants/{tenantID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteTenant))) + mux.Handle("GET /api/admin/users", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listUsers))) + mux.Handle("POST /api/admin/users", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createGatewayUser))) + mux.Handle("PATCH /api/admin/users/{userID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateGatewayUser))) + mux.Handle("DELETE /api/admin/users/{userID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteGatewayUser))) + mux.Handle("GET /api/admin/user-groups", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listUserGroups))) + mux.Handle("POST /api/admin/user-groups", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createUserGroup))) + mux.Handle("PATCH /api/admin/user-groups/{groupID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateUserGroup))) + mux.Handle("DELETE /api/admin/user-groups/{groupID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteUserGroup))) + mux.Handle("GET /api/admin/access-rules", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAccessRules))) + mux.Handle("POST /api/admin/access-rules", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createAccessRule))) + mux.Handle("POST /api/admin/access-rules/batch", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.batchAccessRules))) + mux.Handle("PATCH /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateAccessRule))) + mux.Handle("DELETE /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteAccessRule))) mux.Handle("GET /api/v1/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeys))) mux.Handle("POST /api/v1/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey))) + mux.Handle("GET /api/v1/api-keys/access-rules", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules))) + mux.Handle("POST /api/v1/api-keys/access-rules/batch", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.batchAPIKeyAccessRules))) mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey))) + mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey))) mux.Handle("GET /api/playground/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys))) - mux.Handle("GET /api/v1/pricing/rules", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPricingRules))) - mux.Handle("GET /api/v1/pricing/rule-sets", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPricingRuleSets))) - mux.Handle("POST /api/v1/pricing/rule-sets", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPricingRuleSet))) - mux.Handle("PATCH /api/v1/pricing/rule-sets/{ruleSetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updatePricingRuleSet))) - mux.Handle("DELETE /api/v1/pricing/rule-sets/{ruleSetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deletePricingRuleSet))) + mux.Handle("GET /api/admin/pricing/rules", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRules))) + mux.Handle("GET /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRuleSets))) + mux.Handle("POST /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPricingRuleSet))) + mux.Handle("PATCH /api/admin/pricing/rule-sets/{ruleSetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updatePricingRuleSet))) + mux.Handle("DELETE /api/admin/pricing/rule-sets/{ruleSetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deletePricingRuleSet))) mux.Handle("POST /api/v1/pricing/estimate", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.estimatePricing))) - mux.Handle("GET /api/v1/runtime/policy-sets", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listRuntimePolicySets))) - mux.Handle("POST /api/v1/runtime/policy-sets", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createRuntimePolicySet))) - mux.Handle("PATCH /api/v1/runtime/policy-sets/{policySetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateRuntimePolicySet))) - mux.Handle("DELETE /api/v1/runtime/policy-sets/{policySetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteRuntimePolicySet))) - mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPlatforms))) - mux.Handle("POST /api/v1/platforms", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatform))) - mux.Handle("PATCH /api/v1/platforms/{platformID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updatePlatform))) - mux.Handle("DELETE /api/v1/platforms/{platformID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deletePlatform))) - mux.Handle("PUT /api/v1/platforms/{platformID}/models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.replacePlatformModels))) - mux.Handle("POST /api/v1/platforms/{platformID}/models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel))) - mux.Handle("POST /api/v1/platform-models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel))) - mux.Handle("DELETE /api/v1/platform-models/{modelID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deletePlatformModel))) - mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listModels))) + mux.Handle("GET /api/admin/runtime/policy-sets", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRuntimePolicySets))) + mux.Handle("POST /api/admin/runtime/policy-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createRuntimePolicySet))) + mux.Handle("PATCH /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRuntimePolicySet))) + mux.Handle("DELETE /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteRuntimePolicySet))) + mux.Handle("GET /api/admin/platforms", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPlatforms))) + mux.Handle("POST /api/admin/platforms", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatform))) + mux.Handle("PATCH /api/admin/platforms/{platformID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updatePlatform))) + mux.Handle("DELETE /api/admin/platforms/{platformID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deletePlatform))) + mux.Handle("PUT /api/admin/platforms/{platformID}/models", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.replacePlatformModels))) + mux.Handle("POST /api/admin/platforms/{platformID}/models", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel))) + mux.Handle("POST /api/admin/platform-models", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel))) + mux.Handle("DELETE /api/admin/platform-models/{modelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deletePlatformModel))) + mux.Handle("GET /api/admin/models", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModels))) + mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayablePlatforms))) + mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels))) mux.Handle("GET /api/v1/playground/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels))) - mux.Handle("GET /api/v1/runtime/rate-limit-windows", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows))) + mux.Handle("GET /api/admin/runtime/rate-limit-windows", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows))) mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions", false))) mux.Handle("POST /api/v1/responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", false))) mux.Handle("POST /api/v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", false))) @@ -109,6 +114,17 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han return server.recover(server.cors(mux)) } +func (s *Server) requireAdmin(permission auth.Permission, next http.Handler) http.Handler { + return s.auth.Require(permission, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, _ := auth.UserFromContext(r.Context()) + if user != nil && strings.TrimSpace(user.APIKeyID) != "" { + writeError(w, http.StatusForbidden, "admin api does not accept api key credentials") + return + } + next.ServeHTTP(w, r) + })) +} + func (s *Server) cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") diff --git a/apps/api/internal/store/access_rules.go b/apps/api/internal/store/access_rules.go index d85eacc..f30a23c 100644 --- a/apps/api/internal/store/access_rules.go +++ b/apps/api/internal/store/access_rules.go @@ -3,6 +3,7 @@ package store import ( "context" "encoding/json" + "errors" "strings" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" @@ -66,6 +67,35 @@ ORDER BY resource_type ASC, priority ASC, subject_type ASC, created_at DESC`) return items, rows.Err() } +func (s *Store) ListAPIKeyAccessRules(ctx context.Context, user *auth.User) ([]AccessRule, error) { + gatewayUserID := localGatewayUserID(user) + if gatewayUserID == "" { + return nil, ErrLocalUserRequired + } + rows, err := s.pool.Query(ctx, ` +SELECT `+apiKeyAccessRuleColumns+` +FROM gateway_access_rules ar +JOIN gateway_api_keys k ON k.id = ar.subject_id +WHERE ar.subject_type = 'api_key' + AND k.gateway_user_id = $1::uuid + AND k.deleted_at IS NULL +ORDER BY ar.resource_type ASC, ar.priority ASC, ar.created_at DESC`, gatewayUserID) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]AccessRule, 0) + for rows.Next() { + item, err := scanAccessRule(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + func (s *Store) CreateAccessRule(ctx context.Context, input AccessRuleInput) (AccessRule, error) { input = normalizeAccessRuleInput(input) conditions, _ := json.Marshal(emptyObjectIfNil(input.Conditions)) @@ -191,6 +221,38 @@ DO UPDATE SET priority = EXCLUDED.priority, return s.ListAccessRules(ctx) } +func (s *Store) BatchAPIKeyAccessRules(ctx context.Context, input AccessRuleBatchInput, user *auth.User) ([]AccessRule, error) { + gatewayUserID := localGatewayUserID(user) + if gatewayUserID == "" { + return nil, ErrLocalUserRequired + } + input = normalizeAccessRuleBatchInput(input) + if input.SubjectType != "api_key" { + return nil, pgx.ErrNoRows + } + var exists bool + if err := s.pool.QueryRow(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM gateway_api_keys + WHERE id = $1::uuid + AND gateway_user_id = $2::uuid + AND deleted_at IS NULL +)`, input.SubjectID, gatewayUserID).Scan(&exists); err != nil { + return nil, err + } + if !exists { + return nil, pgx.ErrNoRows + } + if err := s.ensureAPIKeyAccessRuleResourcesAllowed(ctx, user, input.UpsertResources); err != nil { + return nil, err + } + if _, err := s.BatchAccessRules(ctx, input); err != nil { + return nil, err + } + return s.ListAPIKeyAccessRules(ctx, user) +} + func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.User, candidates []RuntimeModelCandidate) ([]RuntimeModelCandidate, error) { if len(candidates) == 0 { return candidates, nil @@ -221,6 +283,10 @@ func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.Us } func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) { + accessUser, err := s.resolveCurrentAccessUser(ctx, user) + if err != nil { + return nil, err + } models, err := s.ListModels(ctx) if err != nil { return nil, err @@ -241,7 +307,80 @@ func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.Use enabled = append(enabled, model) } } - return s.filterPlatformModelsByAccessRules(ctx, user, enabled) + return s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled) +} + +func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user *auth.User, resources []AccessRuleResourceInput) error { + resources = dedupeAccessRuleResources(resources) + if len(resources) == 0 { + return nil + } + allowed, err := s.accessibleAccessRuleResources(ctx, user) + if err != nil { + return err + } + for _, resource := range resources { + if !allowed[resource.ResourceType+":"+resource.ResourceID] { + return ErrAccessRuleResourceDenied + } + } + return nil +} + +func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User) (map[string]bool, error) { + models, err := s.ListAccessiblePlatformModels(ctx, user) + if err != nil { + return nil, err + } + allowed := map[string]bool{} + for _, model := range models { + allowed["platform:"+model.PlatformID] = true + allowed["platform_model:"+model.ID] = true + if model.BaseModelID != "" { + allowed["base_model:"+model.BaseModelID] = true + } + } + return allowed, nil +} + +func (s *Store) resolveCurrentAccessUser(ctx context.Context, user *auth.User) (*auth.User, error) { + if user == nil { + return nil, nil + } + gatewayUserID := localGatewayUserID(user) + if gatewayUserID == "" { + return user, nil + } + next := *user + var userGroupID string + var err error + if strings.TrimSpace(user.APIKeyID) != "" { + err = s.pool.QueryRow(ctx, ` +SELECT COALESCE(k.user_group_id::text, u.default_user_group_id::text, '') +FROM gateway_users u +JOIN gateway_api_keys k ON k.gateway_user_id = u.id +WHERE u.id = $1::uuid + AND k.id = $2::uuid + AND u.status = 'active' + AND u.deleted_at IS NULL + AND k.status = 'active' + AND k.deleted_at IS NULL`, gatewayUserID, user.APIKeyID).Scan(&userGroupID) + } else { + err = s.pool.QueryRow(ctx, ` +SELECT COALESCE(default_user_group_id::text, '') +FROM gateway_users +WHERE id = $1::uuid + AND status = 'active' + AND deleted_at IS NULL`, gatewayUserID).Scan(&userGroupID) + } + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return &next, nil + } + return nil, err + } + next.UserGroupID = userGroupID + return &next, nil } func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) { @@ -432,6 +571,10 @@ const accessRuleColumns = ` id::text, subject_type, subject_id::text, resource_type, resource_id::text, effect, priority, min_permission_level, conditions, metadata, status, created_at, updated_at` +const apiKeyAccessRuleColumns = ` +ar.id::text, ar.subject_type, ar.subject_id::text, ar.resource_type, ar.resource_id::text, ar.effect, +ar.priority, ar.min_permission_level, ar.conditions, ar.metadata, ar.status, ar.created_at, ar.updated_at` + func scanAccessRule(row scanner) (AccessRule, error) { var item AccessRule var conditions []byte diff --git a/apps/api/internal/store/identity_admin.go b/apps/api/internal/store/identity_admin.go index ec7d659..235f59f 100644 --- a/apps/api/internal/store/identity_admin.go +++ b/apps/api/internal/store/identity_admin.go @@ -258,14 +258,25 @@ RETURNING `+userGroupColumns, } func (s *Store) DeleteUserGroup(ctx context.Context, id string) error { - result, err := s.pool.Exec(ctx, `DELETE FROM gateway_user_groups WHERE id = $1::uuid`, id) + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, ` +DELETE FROM gateway_access_rules +WHERE subject_type = 'user_group' AND subject_id = $1::uuid`, id); err != nil { + return err + } + result, err := tx.Exec(ctx, `DELETE FROM gateway_user_groups WHERE id = $1::uuid`, id) if err != nil { return err } if result.RowsAffected() == 0 { return pgx.ErrNoRows } - return nil + return tx.Commit(ctx) } const tenantColumns = ` diff --git a/apps/api/internal/store/platform_models.go b/apps/api/internal/store/platform_models.go index 1acb1fe..ebeab8a 100644 --- a/apps/api/internal/store/platform_models.go +++ b/apps/api/internal/store/platform_models.go @@ -48,13 +48,27 @@ func (s *Store) ReplacePlatformModels(ctx context.Context, platformID string, in } if len(keptIDs) == 0 { - if _, err := tx.Exec(ctx, `DELETE FROM platform_models WHERE platform_id = $1::uuid`, platformID); err != nil { + if _, err := tx.Exec(ctx, ` +WITH deleted AS ( + DELETE FROM platform_models + WHERE platform_id = $1::uuid + RETURNING id +) +DELETE FROM gateway_access_rules +WHERE resource_type = 'platform_model' + AND resource_id IN (SELECT id FROM deleted)`, platformID); err != nil { return nil, err } } else if _, err := tx.Exec(ctx, ` -DELETE FROM platform_models -WHERE platform_id = $1::uuid - AND NOT (id::text = ANY($2::text[]))`, platformID, keptIDs); err != nil { +WITH deleted AS ( + DELETE FROM platform_models + WHERE platform_id = $1::uuid + AND NOT (id::text = ANY($2::text[])) + RETURNING id +) +DELETE FROM gateway_access_rules +WHERE resource_type = 'platform_model' + AND resource_id IN (SELECT id FROM deleted)`, platformID, keptIDs); err != nil { return nil, err } @@ -224,14 +238,25 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_ } func (s *Store) DeletePlatformModel(ctx context.Context, id string) error { - result, err := s.pool.Exec(ctx, `DELETE FROM platform_models WHERE id = $1::uuid`, id) + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + result, err := tx.Exec(ctx, `DELETE FROM platform_models WHERE id = $1::uuid`, id) if err != nil { return err } if result.RowsAffected() == 0 { return pgx.ErrNoRows } - return nil + if _, err := tx.Exec(ctx, ` +DELETE FROM gateway_access_rules +WHERE resource_type = 'platform_model' AND resource_id = $1::uuid`, id); err != nil { + return err + } + return tx.Commit(ctx) } func (s *Store) lookupBaseModel(ctx context.Context, q platformModelQuerier, id string, canonicalKey string, modelName string) (modelCatalogSnapshot, error) { diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index 8f48d41..9794bc5 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -22,12 +22,13 @@ type Store struct { } var ( - ErrInvalidCredentials = errors.New("invalid account or password") - ErrInvalidInvitation = errors.New("invalid or expired invitation code") - ErrLocalUserRequired = errors.New("local gateway user is required") - ErrProtectedDefault = errors.New("protected default resource cannot be deleted") - ErrUserAlreadyExists = errors.New("user already exists") - ErrWeakPassword = errors.New("password must be at least 8 characters") + ErrInvalidCredentials = errors.New("invalid account or password") + ErrInvalidInvitation = errors.New("invalid or expired invitation code") + ErrAccessRuleResourceDenied = errors.New("access rule resource is not available") + ErrLocalUserRequired = errors.New("local gateway user is required") + ErrProtectedDefault = errors.New("protected default resource cannot be deleted") + ErrUserAlreadyExists = errors.New("user already exists") + ErrWeakPassword = errors.New("password must be at least 8 characters") ) func Connect(ctx context.Context, databaseURL string) (*Store, error) { @@ -102,6 +103,7 @@ type APIKey struct { TenantKey string `json:"tenantKey,omitempty"` UserID string `json:"userId,omitempty"` KeyPrefix string `json:"keyPrefix"` + Secret string `json:"secret,omitempty"` Name string `json:"name"` Scopes []string `json:"scopes,omitempty"` UserGroupID string `json:"userGroupId,omitempty"` @@ -627,14 +629,45 @@ RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), C } func (s *Store) DeletePlatform(ctx context.Context, id string) error { - result, err := s.pool.Exec(ctx, `DELETE FROM integration_platforms WHERE id = $1::uuid`, id) + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + rows, err := tx.Query(ctx, `SELECT id::text FROM platform_models WHERE platform_id = $1::uuid`, id) + if err != nil { + return err + } + modelIDs := make([]string, 0) + for rows.Next() { + var modelID string + if err := rows.Scan(&modelID); err != nil { + rows.Close() + return err + } + modelIDs = append(modelIDs, modelID) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + + result, err := tx.Exec(ctx, `DELETE FROM integration_platforms WHERE id = $1::uuid`, id) if err != nil { return err } if result.RowsAffected() == 0 { return pgx.ErrNoRows } - return nil + if _, err := tx.Exec(ctx, ` +DELETE FROM gateway_access_rules +WHERE (resource_type = 'platform' AND resource_id = $1::uuid) + OR (resource_type = 'platform_model' AND resource_id::text = ANY($2::text[]))`, id, modelIDs); err != nil { + return err + } + return tx.Commit(ctx) } func (s *Store) ListModels(ctx context.Context) ([]PlatformModel, error) { @@ -962,7 +995,11 @@ SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text, COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''), key_prefix, name, scopes, COALESCE(user_group_id::text, ''), rate_limit_policy, quota_policy, status, COALESCE(expires_at::text, ''), - COALESCE(last_used_at::text, ''), created_at, updated_at + COALESCE(last_used_at::text, ''), created_at, updated_at, + CASE WHEN status = 'active' AND (expires_at IS NULL OR expires_at > now()) + THEN COALESCE(key_secret, '') + ELSE '' + END FROM gateway_api_keys WHERE gateway_user_id = $1::uuid AND deleted_at IS NULL ORDER BY created_at DESC`, gatewayUserID) @@ -973,7 +1010,8 @@ ORDER BY created_at DESC`, gatewayUserID) items := make([]APIKey, 0) for rows.Next() { - item, err := scanAPIKey(rows) + var secret string + item, err := scanAPIKeyWithSecret(rows, &secret) if err != nil { return nil, err } @@ -1155,13 +1193,45 @@ RETURNING id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text return item, nil } +func (s *Store) DeleteAPIKey(ctx context.Context, apiKeyID string, user *auth.User) error { + gatewayUserID := localGatewayUserID(user) + if gatewayUserID == "" { + return ErrLocalUserRequired + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + result, err := tx.Exec(ctx, ` +UPDATE gateway_api_keys +SET status = 'revoked', + key_secret = NULL, + deleted_at = now(), + updated_at = now() +WHERE id = $1::uuid AND gateway_user_id = $2::uuid AND deleted_at IS NULL`, apiKeyID, gatewayUserID) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return pgx.ErrNoRows + } + if _, err := tx.Exec(ctx, ` +DELETE FROM gateway_access_rules +WHERE subject_type = 'api_key' AND subject_id = $1::uuid`, apiKeyID); err != nil { + return err + } + return tx.Commit(ctx) +} + func (s *Store) VerifyLocalAPIKey(ctx context.Context, secret string) (*auth.User, error) { prefix := apiKeyPrefix(secret) if prefix == "" { return nil, auth.ErrUnauthorized } rows, err := s.pool.Query(ctx, ` -SELECT k.id::text, k.key_hash, k.key_prefix, k.name, COALESCE(k.user_group_id::text, ''), +SELECT k.id::text, k.key_hash, k.key_prefix, k.name, COALESCE(k.user_group_id::text, u.default_user_group_id::text, ''), u.id::text, u.username, u.roles, COALESCE(u.gateway_tenant_id::text, ''), COALESCE(u.tenant_id, ''), COALESCE(u.tenant_key, '') FROM gateway_api_keys k @@ -1722,6 +1792,9 @@ func scanAPIKeyWithSecret(scanner apiKeyScanner, secret *string) (APIKey, error) item.Scopes = decodeStringArray(scopesBytes) item.RateLimitPolicy = decodeObject(rateLimitPolicy) item.QuotaPolicy = decodeObject(quotaPolicy) + if secret != nil { + item.Secret = *secret + } return item, nil } diff --git a/apps/web/package.json b/apps/web/package.json index 3e755e5..3a369ce 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@easyai-ai-gateway/contracts": "workspace:*", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@streamdown/cjk": "^1.0.3", @@ -24,9 +25,11 @@ "@vitejs/plugin-react": "^5.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "katex": "^0.16.45", "lucide-react": "^1.14.0", "react": "^19.0.0", + "react-day-picker": "^10.0.0", "react-dom": "^19.0.0", "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a8d7834..13fe34b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -22,6 +22,7 @@ import type { } from '@easyai-ai-gateway/contracts'; import { batchAccessRules, + batchApiKeyAccessRules, createAccessRule, createApiKey, createGatewayUser, @@ -29,6 +30,7 @@ import { createTenant, createUserGroup, deleteAccessRule, + deleteApiKey, deleteGatewayUser, deletePlatform, deleteTenant, @@ -36,6 +38,7 @@ import { getHealth, getTask, listAccessRules, + listApiKeyAccessRules, listApiKeys, listBaseModels, listCatalogProviders, @@ -148,7 +151,7 @@ export function App() { const [users, setUsers] = useState([]); const [userGroups, setUserGroups] = useState([]); const [apiKeys, setApiKeys] = useState([]); - const [apiKeyForm, setApiKeyForm] = useState({ name: 'Local smoke key' }); + const [apiKeyForm, setApiKeyForm] = useState({ name: 'Local smoke key', expiresAt: '' }); const [apiKeySecret, setApiKeySecret] = useState(''); const [apiKeySecretsById, setApiKeySecretsById] = useState>({}); const [selectedPlaygroundApiKeyId, setSelectedPlaygroundApiKeyId] = useState(''); @@ -234,6 +237,10 @@ export function App() { await ensureRouteData(nextToken, true); } + function invalidateDataKeys(...keys: DataKey[]) { + keys.forEach((key) => loadedDataKeysRef.current.delete(key)); + } + async function ensureRouteData(nextToken = token, force = false) { await ensureData(dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)), nextToken, force); } @@ -321,7 +328,9 @@ export function App() { setUserGroups((await listUserGroups(nextToken)).items); return; case 'accessRules': - setAccessRules((await listAccessRules(nextToken)).items); + setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys' + ? listApiKeyAccessRules(nextToken) + : listAccessRules(nextToken))).items); return; case 'apiKeys': setApiKeys((await listApiKeys(nextToken)).items); @@ -369,16 +378,22 @@ export function App() { setCoreState('loading'); setCoreMessage(''); try { - const response = await createApiKey(token, { name: apiKeyForm.name, scopes: ['chat', 'image', 'video'] }); + const response = await createApiKey(token, { + name: apiKeyForm.name, + scopes: ['chat', 'image', 'video'], + expiresAt: apiKeyForm.expiresAt ? new Date(apiKeyForm.expiresAt).toISOString() : undefined, + }); setApiKeySecret(response.secret); setApiKeySecretsById((current) => ({ ...current, [response.apiKey.id]: response.secret })); setSelectedPlaygroundApiKeyId(response.apiKey.id); setApiKeys((current) => [response.apiKey, ...current.filter((item) => item.id !== response.apiKey.id)]); + setApiKeyForm({ name: '', expiresAt: '' }); setCoreState('ready'); setCoreMessage('API Key 已创建,secret 仅展示一次。'); } catch (err) { setCoreState('error'); setCoreMessage(err instanceof Error ? err.message : '创建 API Key 失败'); + throw err; } } @@ -461,6 +476,7 @@ export function App() { try { const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input); setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]); + invalidateDataKeys('playgroundModels'); setCoreState('ready'); setCoreMessage(userId ? '用户已更新。' : '用户已创建。'); } catch (err) { @@ -508,6 +524,7 @@ export function App() { setUserGroups((current) => current.filter((group) => group.id !== groupId)); setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant)); setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user)); + invalidateDataKeys('playgroundModels'); setCoreState('ready'); setCoreMessage('用户组已删除。'); } catch (err) { @@ -517,12 +534,35 @@ export function App() { } } + async function removeAPIKey(apiKeyId: string) { + setCoreState('loading'); + setCoreMessage(''); + try { + await deleteApiKey(token, apiKeyId); + setApiKeys((current) => current.filter((item) => item.id !== apiKeyId)); + setAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'api_key' && rule.subjectId === apiKeyId))); + setApiKeySecretsById((current) => { + const next = { ...current }; + delete next[apiKeyId]; + return next; + }); + if (selectedPlaygroundApiKeyId === apiKeyId) setSelectedPlaygroundApiKeyId(''); + setCoreState('ready'); + setCoreMessage('API Key 已删除,对应权限策略已同步清理。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '删除 API Key 失败'); + throw err; + } + } + async function saveAccessRule(input: GatewayAccessRuleUpsertRequest, ruleId?: string) { setCoreState('loading'); setCoreMessage(''); try { const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input); setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]); + invalidateDataKeys('playgroundModels'); setCoreState('ready'); setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。'); } catch (err) { @@ -538,6 +578,7 @@ export function App() { try { await deleteAccessRule(token, ruleId); setAccessRules((current) => current.filter((rule) => rule.id !== ruleId)); + invalidateDataKeys('playgroundModels'); setCoreState('ready'); setCoreMessage('访问权限规则已删除。'); } catch (err) { @@ -553,6 +594,7 @@ export function App() { try { const response = await batchAccessRules(token, input); setAccessRules(response.items); + invalidateDataKeys('playgroundModels'); setCoreState('ready'); setCoreMessage('访问权限已更新。'); } catch (err) { @@ -562,6 +604,21 @@ export function App() { } } + async function batchSaveAPIKeyAccessRules(input: GatewayAccessRuleBatchRequest) { + setCoreState('loading'); + setCoreMessage(''); + try { + const response = await batchApiKeyAccessRules(token, input); + setAccessRules(response.items); + setCoreState('ready'); + setCoreMessage('API Key 权限已更新。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '批量更新 API Key 权限失败'); + throw err; + } + } + async function submitTask(event: FormEvent) { event.preventDefault(); const credential = apiKeySecret || token; @@ -695,9 +752,14 @@ export function App() { > { - return request>('/api/v1/platforms', { token }); + return request>('/api/admin/platforms', { token }); } export async function listModels(token: string): Promise> { - return request>('/api/v1/models', { token }); + return request>('/api/admin/models', { token }); } export async function listPlayableModels(token: string): Promise> { - return request>('/api/v1/playground/models', { token }); + return request>('/api/v1/models', { token }); } export async function listPublicCatalogProviders(): Promise> { @@ -81,14 +81,14 @@ export async function listPublicCatalogProviders(): Promise> { - return request>('/api/v1/catalog/providers', { token }); + return request>('/api/admin/catalog/providers', { token }); } export async function createCatalogProvider( token: string, input: CatalogProviderUpsertRequest, ): Promise { - return request('/api/v1/catalog/providers', { + return request('/api/admin/catalog/providers', { body: input, method: 'POST', token, @@ -100,7 +100,7 @@ export async function updateCatalogProvider( providerId: string, input: CatalogProviderUpsertRequest, ): Promise { - return request(`/api/v1/catalog/providers/${providerId}`, { + return request(`/api/admin/catalog/providers/${providerId}`, { body: input, method: 'PATCH', token, @@ -108,7 +108,7 @@ export async function updateCatalogProvider( } export async function deleteCatalogProvider(token: string, providerId: string): Promise { - await request(`/api/v1/catalog/providers/${providerId}`, { + await request(`/api/admin/catalog/providers/${providerId}`, { method: 'DELETE', token, }); @@ -119,11 +119,11 @@ export async function listPublicBaseModels(): Promise> { - return request>('/api/v1/catalog/base-models', { token }); + return request>('/api/admin/catalog/base-models', { token }); } export async function createBaseModel(token: string, input: BaseModelUpsertRequest): Promise { - return request('/api/v1/catalog/base-models', { + return request('/api/admin/catalog/base-models', { body: input, method: 'POST', token, @@ -135,7 +135,7 @@ export async function updateBaseModel( baseModelId: string, input: BaseModelUpsertRequest, ): Promise { - return request(`/api/v1/catalog/base-models/${baseModelId}`, { + return request(`/api/admin/catalog/base-models/${baseModelId}`, { body: input, method: 'PATCH', token, @@ -143,39 +143,39 @@ export async function updateBaseModel( } export async function resetBaseModel(token: string, baseModelId: string): Promise { - return request(`/api/v1/catalog/base-models/${baseModelId}/reset`, { + return request(`/api/admin/catalog/base-models/${baseModelId}/reset`, { method: 'POST', token, }); } export async function resetAllBaseModels(token: string): Promise> { - return request>('/api/v1/catalog/base-models/reset-all', { + return request>('/api/admin/catalog/base-models/reset-all', { method: 'POST', token, }); } export async function deleteBaseModel(token: string, baseModelId: string): Promise { - await request(`/api/v1/catalog/base-models/${baseModelId}`, { + await request(`/api/admin/catalog/base-models/${baseModelId}`, { method: 'DELETE', token, }); } export async function listPricingRules(token: string): Promise> { - return request>('/api/v1/pricing/rules', { token }); + return request>('/api/admin/pricing/rules', { token }); } export async function listPricingRuleSets(token: string): Promise> { - return request>('/api/v1/pricing/rule-sets', { token }); + return request>('/api/admin/pricing/rule-sets', { token }); } export async function createPricingRuleSet( token: string, input: PricingRuleSetUpsertRequest, ): Promise { - return request('/api/v1/pricing/rule-sets', { + return request('/api/admin/pricing/rule-sets', { body: input, method: 'POST', token, @@ -187,7 +187,7 @@ export async function updatePricingRuleSet( ruleSetId: string, input: PricingRuleSetUpsertRequest, ): Promise { - return request(`/api/v1/pricing/rule-sets/${ruleSetId}`, { + return request(`/api/admin/pricing/rule-sets/${ruleSetId}`, { body: input, method: 'PATCH', token, @@ -195,21 +195,21 @@ export async function updatePricingRuleSet( } export async function deletePricingRuleSet(token: string, ruleSetId: string): Promise { - await request(`/api/v1/pricing/rule-sets/${ruleSetId}`, { + await request(`/api/admin/pricing/rule-sets/${ruleSetId}`, { method: 'DELETE', token, }); } export async function listRuntimePolicySets(token: string): Promise> { - return request>('/api/v1/runtime/policy-sets', { token }); + return request>('/api/admin/runtime/policy-sets', { token }); } export async function createRuntimePolicySet( token: string, input: RuntimePolicySetUpsertRequest, ): Promise { - return request('/api/v1/runtime/policy-sets', { + return request('/api/admin/runtime/policy-sets', { body: input, method: 'POST', token, @@ -221,7 +221,7 @@ export async function updateRuntimePolicySet( policySetId: string, input: RuntimePolicySetUpsertRequest, ): Promise { - return request(`/api/v1/runtime/policy-sets/${policySetId}`, { + return request(`/api/admin/runtime/policy-sets/${policySetId}`, { body: input, method: 'PATCH', token, @@ -229,18 +229,18 @@ export async function updateRuntimePolicySet( } export async function deleteRuntimePolicySet(token: string, policySetId: string): Promise { - await request(`/api/v1/runtime/policy-sets/${policySetId}`, { + await request(`/api/admin/runtime/policy-sets/${policySetId}`, { method: 'DELETE', token, }); } export async function listTenants(token: string): Promise> { - return request>('/api/v1/tenants', { token }); + return request>('/api/admin/tenants', { token }); } export async function createTenant(token: string, input: GatewayTenantUpsertRequest): Promise { - return request('/api/v1/tenants', { + return request('/api/admin/tenants', { body: input, method: 'POST', token, @@ -248,7 +248,7 @@ export async function createTenant(token: string, input: GatewayTenantUpsertRequ } export async function updateTenant(token: string, tenantId: string, input: GatewayTenantUpsertRequest): Promise { - return request(`/api/v1/tenants/${tenantId}`, { + return request(`/api/admin/tenants/${tenantId}`, { body: input, method: 'PATCH', token, @@ -256,18 +256,18 @@ export async function updateTenant(token: string, tenantId: string, input: Gatew } export async function deleteTenant(token: string, tenantId: string): Promise { - await request(`/api/v1/tenants/${tenantId}`, { + await request(`/api/admin/tenants/${tenantId}`, { method: 'DELETE', token, }); } export async function listUsers(token: string): Promise> { - return request>('/api/v1/users', { token }); + return request>('/api/admin/users', { token }); } export async function createGatewayUser(token: string, input: GatewayUserUpsertRequest): Promise { - return request('/api/v1/users', { + return request('/api/admin/users', { body: input, method: 'POST', token, @@ -275,7 +275,7 @@ export async function createGatewayUser(token: string, input: GatewayUserUpsertR } export async function updateGatewayUser(token: string, userId: string, input: GatewayUserUpsertRequest): Promise { - return request(`/api/v1/users/${userId}`, { + return request(`/api/admin/users/${userId}`, { body: input, method: 'PATCH', token, @@ -283,18 +283,18 @@ export async function updateGatewayUser(token: string, userId: string, input: Ga } export async function deleteGatewayUser(token: string, userId: string): Promise { - await request(`/api/v1/users/${userId}`, { + await request(`/api/admin/users/${userId}`, { method: 'DELETE', token, }); } export async function listUserGroups(token: string): Promise> { - return request>('/api/v1/user-groups', { token }); + return request>('/api/admin/user-groups', { token }); } export async function createUserGroup(token: string, input: UserGroupUpsertRequest): Promise { - return request('/api/v1/user-groups', { + return request('/api/admin/user-groups', { body: input, method: 'POST', token, @@ -302,7 +302,7 @@ export async function createUserGroup(token: string, input: UserGroupUpsertReque } export async function updateUserGroup(token: string, groupId: string, input: UserGroupUpsertRequest): Promise { - return request(`/api/v1/user-groups/${groupId}`, { + return request(`/api/admin/user-groups/${groupId}`, { body: input, method: 'PATCH', token, @@ -310,18 +310,22 @@ export async function updateUserGroup(token: string, groupId: string, input: Use } export async function deleteUserGroup(token: string, groupId: string): Promise { - await request(`/api/v1/user-groups/${groupId}`, { + await request(`/api/admin/user-groups/${groupId}`, { method: 'DELETE', token, }); } export async function listAccessRules(token: string): Promise> { - return request>('/api/v1/access-rules', { token }); + return request>('/api/admin/access-rules', { token }); +} + +export async function listApiKeyAccessRules(token: string): Promise> { + return request>('/api/v1/api-keys/access-rules', { token }); } export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise { - return request('/api/v1/access-rules', { + return request('/api/admin/access-rules', { body: input, method: 'POST', token, @@ -329,7 +333,15 @@ export async function createAccessRule(token: string, input: GatewayAccessRuleUp } export async function batchAccessRules(token: string, input: GatewayAccessRuleBatchRequest): Promise> { - return request>('/api/v1/access-rules/batch', { + return request>('/api/admin/access-rules/batch', { + body: input, + method: 'POST', + token, + }); +} + +export async function batchApiKeyAccessRules(token: string, input: GatewayAccessRuleBatchRequest): Promise> { + return request>('/api/v1/api-keys/access-rules/batch', { body: input, method: 'POST', token, @@ -341,7 +353,7 @@ export async function updateAccessRule( ruleId: string, input: GatewayAccessRuleUpsertRequest, ): Promise { - return request(`/api/v1/access-rules/${ruleId}`, { + return request(`/api/admin/access-rules/${ruleId}`, { body: input, method: 'PATCH', token, @@ -349,7 +361,7 @@ export async function updateAccessRule( } export async function deleteAccessRule(token: string, ruleId: string): Promise { - await request(`/api/v1/access-rules/${ruleId}`, { + await request(`/api/admin/access-rules/${ruleId}`, { method: 'DELETE', token, }); @@ -365,7 +377,7 @@ export async function listPlayableApiKeys(token: string): Promise { return request('/api/v1/api-keys', { body: input, @@ -374,8 +386,15 @@ export async function createApiKey( }); } +export async function deleteApiKey(token: string, apiKeyId: string): Promise { + await request(`/api/v1/api-keys/${apiKeyId}`, { + method: 'DELETE', + token, + }); +} + export async function createPlatform(token: string, input: PlatformCreateInput): Promise { - return request('/api/v1/platforms', { + return request('/api/admin/platforms', { body: input, method: 'POST', token, @@ -383,7 +402,7 @@ export async function createPlatform(token: string, input: PlatformCreateInput): } export async function updatePlatform(token: string, platformId: string, input: PlatformCreateInput): Promise { - return request(`/api/v1/platforms/${platformId}`, { + return request(`/api/admin/platforms/${platformId}`, { body: input, method: 'PATCH', token, @@ -391,7 +410,7 @@ export async function updatePlatform(token: string, platformId: string, input: P } export async function deletePlatform(token: string, platformId: string): Promise { - await request(`/api/v1/platforms/${platformId}`, { + await request(`/api/admin/platforms/${platformId}`, { method: 'DELETE', token, }); @@ -402,7 +421,7 @@ export async function createPlatformModel( platformId: string, input: PlatformModelBindingInput, ): Promise { - return request(`/api/v1/platforms/${platformId}/models`, { + return request(`/api/admin/platforms/${platformId}/models`, { body: input, method: 'POST', token, @@ -414,7 +433,7 @@ export async function replacePlatformModels( platformId: string, models: PlatformModelBindingInput[], ): Promise> { - return request>(`/api/v1/platforms/${platformId}/models`, { + return request>(`/api/admin/platforms/${platformId}/models`, { body: { models }, method: 'PUT', token, @@ -422,7 +441,7 @@ export async function replacePlatformModels( } export async function deletePlatformModel(token: string, modelId: string): Promise { - await request(`/api/v1/platform-models/${modelId}`, { + await request(`/api/admin/platform-models/${modelId}`, { method: 'DELETE', token, }); @@ -528,7 +547,7 @@ export async function getTask(token: string, taskId: string): Promise> { - return request>('/api/v1/runtime/rate-limit-windows', { token }); + return request>('/api/admin/runtime/rate-limit-windows', { token }); } async function request( diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx new file mode 100644 index 0000000..fb1654a --- /dev/null +++ b/apps/web/src/components/ui/calendar.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from 'lucide-react'; +import { DayPicker, type ChevronProps, type DayPickerProps } from 'react-day-picker'; +import { cn } from '../../lib/utils'; + +export function Calendar(props: DayPickerProps) { + const { className, classNames, showOutsideDays = true, ...rest } = props; + return ( + + ); +} + +function CalendarChevron(props: ChevronProps) { + const Icon = props.orientation === 'left' + ? ChevronLeft + : props.orientation === 'right' + ? ChevronRight + : props.orientation === 'up' + ? ChevronUp + : ChevronDown; + return ; +} diff --git a/apps/web/src/components/ui/date-time-picker.tsx b/apps/web/src/components/ui/date-time-picker.tsx new file mode 100644 index 0000000..56e260f --- /dev/null +++ b/apps/web/src/components/ui/date-time-picker.tsx @@ -0,0 +1,87 @@ +import { useMemo, useState } from 'react'; +import { format } from 'date-fns'; +import { CalendarIcon, X } from 'lucide-react'; +import { Button } from './button'; +import { Calendar } from './calendar'; +import { Input } from './input'; +import { Label } from './label'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; + +export function DateTimePicker(props: { + disabled?: boolean; + placeholder?: string; + value: string; + onChange: (value: string) => void; +}) { + const [open, setOpen] = useState(false); + const selectedDate = useMemo(() => parseLocalDateTime(props.value), [props.value]); + const timeValue = selectedDate ? formatTimeValue(selectedDate) : ''; + + function updateDate(nextDate: Date | undefined) { + if (!nextDate) return; + const current = selectedDate ?? new Date(); + const next = new Date(nextDate); + next.setHours(current.getHours(), current.getMinutes(), 0, 0); + props.onChange(formatLocalDateTime(next)); + } + + function updateTime(value: string) { + const [hours, minutes] = value.split(':').map((item) => Number(item)); + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return; + const next = selectedDate ? new Date(selectedDate) : new Date(); + next.setHours(hours, minutes, 0, 0); + props.onChange(formatLocalDateTime(next)); + } + + return ( + + + + + + +
+ + +
+
+
+ ); +} + +function parseLocalDateTime(value: string) { + if (!value) return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +function formatLocalDateTime(date: Date) { + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} + +function formatTimeValue(date: Date) { + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function pad(value: number) { + return String(value).padStart(2, '0'); +} diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts index 0c3ec0b..2aad302 100644 --- a/apps/web/src/components/ui/index.ts +++ b/apps/web/src/components/ui/index.ts @@ -1,13 +1,16 @@ export * from './badge'; export * from './button'; +export * from './calendar'; export * from './card'; export * from './checkbox'; export * from './confirm-dialog'; +export * from './date-time-picker'; export * from './dialog'; export * from './form-item'; export * from './input'; export * from './label'; export * from './message'; +export * from './popover'; export * from './select'; export * from './separator'; export * from './table'; diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx new file mode 100644 index 0000000..4b7052f --- /dev/null +++ b/apps/web/src/components/ui/popover.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import { cn } from '../../lib/utils'; + +export const Popover = PopoverPrimitive.Root; +export const PopoverTrigger = PopoverPrimitive.Trigger; +export const PopoverAnchor = PopoverPrimitive.Anchor; + +export const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ align = 'center', className, sideOffset = 6, ...props }, ref) => ( + + + +)); + +PopoverContent.displayName = PopoverPrimitive.Content.displayName; diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index 95ef81a..1fda859 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -1,8 +1,10 @@ -import type { FormEvent, ReactNode } from 'react'; -import { CreditCard, KeyRound, ListChecks, UserRound } from 'lucide-react'; +import { useMemo, useState, type FormEvent, type ReactNode } from 'react'; +import { Copy, CreditCard, KeyRound, ListChecks, Plus, ShieldCheck, Trash2, UserRound } from 'lucide-react'; +import type { GatewayAccessRuleBatchRequest, GatewayApiKey, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; import type { ConsoleData } from '../app-state'; import { EntityTable } from '../components/EntityTable'; -import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Tabs } from '../components/ui'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, FormDialog, Input, Label, Table, TableCell, TableHead, TableRow, Tabs } from '../components/ui'; +import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor'; import type { ApiKeyForm, LoadState, WorkspaceSection } from '../types'; const tabs = [ @@ -15,12 +17,17 @@ const tabs = [ export function WorkspacePage(props: { apiKeyForm: ApiKeyForm; apiKeySecret: string; + apiKeySecretsById: Record; + apiKeyPolicyModels: PlatformModel[]; data: ConsoleData; + message: string; section: WorkspaceSection; state: LoadState; + onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; + onDeleteApiKey: (apiKeyId: string) => Promise; onApiKeyFormChange: (value: ApiKeyForm) => void; onSectionChange: (value: WorkspaceSection) => void; - onSubmitApiKey: (event: FormEvent) => void; + onSubmitApiKey: (event: FormEvent) => void | Promise; onUseApiKeyForPlayground: (apiKeyId?: string) => void; }) { return ( @@ -101,53 +108,191 @@ function BillingPanel() { function ApiKeyPanel(props: { apiKeyForm: ApiKeyForm; apiKeySecret: string; + apiKeySecretsById: Record; + apiKeyPolicyModels: PlatformModel[]; data: ConsoleData; + message: string; state: LoadState; + onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; + onDeleteApiKey: (apiKeyId: string) => Promise; onApiKeyFormChange: (value: ApiKeyForm) => void; - onSubmitApiKey: (event: FormEvent) => void; + onSubmitApiKey: (event: FormEvent) => void | Promise; onUseApiKeyForPlayground: (apiKeyId?: string) => void; }) { - const latestUsableKey = props.apiKeySecret ? props.data.apiKeys[0] : undefined; + const [createOpen, setCreateOpen] = useState(false); + const [policyApiKeyId, setPolicyApiKeyId] = useState(''); + const [pendingDelete, setPendingDelete] = useState(null); + const [localMessage, setLocalMessage] = useState(''); + const selectedPolicyKey = useMemo( + () => props.data.apiKeys.find((item) => item.id === policyApiKeyId), + [policyApiKeyId, props.data.apiKeys], + ); + const permissionPlatforms = useMemo(() => platformsForPermissionTree(props.apiKeyPolicyModels), [props.apiKeyPolicyModels]); + + async function copyApiKey(item: GatewayApiKey) { + const secret = apiKeySecretFor(item, props.apiKeySecretsById); + if (!secret) return; + await navigator.clipboard.writeText(secret); + setLocalMessage(`已复制 ${item.name || item.keyPrefix}`); + } + + async function submitCreate(event: FormEvent) { + try { + await props.onSubmitApiKey(event); + setCreateOpen(false); + } catch { + return; + } + } + + async function confirmDeleteApiKey() { + if (!pendingDelete) return; + await props.onDeleteApiKey(pendingDelete.id); + setPendingDelete(null); + } + return ( -
+ <> - 创建 API Key +
+ API Key +

按 Key 维护调用凭证、最近使用时间和平台/模型权限策略。

+
+
-
- - - {props.apiKeySecret && ( -
- {props.apiKeySecret} - + {(localMessage || props.message) &&

{localMessage || props.message}

} + + + 名称 + API Key + 权限策略 + 最近使用 + 有效期 + 状态 + 创建时间 + 操作 + + {props.data.apiKeys.length ? props.data.apiKeys.map((item) => { + const secret = apiKeySecretFor(item, props.apiKeySecretsById); + const summary = countAccessPermissionRules(props.data.accessRules, 'api_key', item.id); + return ( + + + + {item.name} + {item.keyPrefix} + + + + + {secret ? maskApiKey(secret) : item.keyPrefix} + + + + + + + {formatDateTime(item.lastUsedAt)} + {formatDateTime(item.expiresAt)} + {item.status} + {formatDateTime(item.createdAt)} + + + + + ); + }) : ( +
+ 暂无 API Key + 点击右上角创建调用凭证。
)} - +
- - - API Key 列表 - - - [item.name, item.keyPrefix, item.status, new Date(item.createdAt).toLocaleString()])} + + + + + + )} + open={createOpen} + title="创建 API Key" + onClose={() => setCreateOpen(false)} + onSubmit={(event) => void submitCreate(event)} + > + + - -
+ + + + setPolicyApiKeyId('')}>关闭} + open={Boolean(selectedPolicyKey)} + title={selectedPolicyKey ? `权限策略:${selectedPolicyKey.name}` : '权限策略'} + onClose={() => setPolicyApiKeyId('')} + onSubmit={(event) => event.preventDefault()} + > + + + + setPendingDelete(null)} + onConfirm={confirmDeleteApiKey} + /> + ); } @@ -195,3 +340,48 @@ function InfoItem(props: { label: string; value: string }) { ); } + +function apiKeySecretFor(item: GatewayApiKey, secretsById: Record) { + return secretsById[item.id] || item.secret || ''; +} + +function maskApiKey(secret: string) { + if (secret.length <= 18) return secret; + return `${secret.slice(0, 12)}...${secret.slice(-4)}`; +} + +function permissionSummaryText(summary: ReturnType) { + const allow = summary.allow.platforms + summary.allow.models; + const deny = summary.deny.platforms + summary.deny.models; + if (allow === 0 && deny === 0) return '未配置'; + return `允许 ${allow} / 拒绝 ${deny}`; +} + +function formatDateTime(value?: string) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return date.toLocaleString(); +} + +function platformsForPermissionTree(models: PlatformModel[]): IntegrationPlatform[] { + const byPlatform = new Map(); + for (const model of models) { + if (byPlatform.has(model.platformId)) continue; + const name = model.platformName || model.provider || model.platformId; + byPlatform.set(model.platformId, { + id: model.platformId, + provider: model.provider || '', + platformKey: model.platformId, + name, + authType: '', + status: 'enabled', + priority: 100, + defaultPricingMode: 'inherit', + defaultDiscountFactor: 1, + createdAt: '', + updatedAt: '', + }); + } + return Array.from(byPlatform.values()).sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/apps/web/src/pages/admin/AccessPermissionEditor.tsx b/apps/web/src/pages/admin/AccessPermissionEditor.tsx new file mode 100644 index 0000000..8e27f5a --- /dev/null +++ b/apps/web/src/pages/admin/AccessPermissionEditor.tsx @@ -0,0 +1,416 @@ +import { useEffect, useMemo, useState } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import type { + GatewayAccessEffect, + GatewayAccessRule, + GatewayAccessRuleBatchRequest, + GatewayAccessRuleResourceRequest, + GatewayAccessResourceType, + GatewayAccessSubjectType, + IntegrationPlatform, + PlatformModel, +} from '@easyai-ai-gateway/contracts'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, Input } from '../../components/ui'; +import type { LoadState } from '../../types'; + +type Effect = Extract; +type ResourceType = Extract; +type ResourceKey = `${ResourceType}:${string}`; + +type PlatformNode = { + id: string; + name: string; + subtitle: string; + models: Array<{ + id: string; + name: string; + subtitle: string; + }>; +}; + +export function AccessPermissionEditor(props: { + accessRules: GatewayAccessRule[]; + emptySubjectText?: string; + metadataMode?: string; + platformModels: PlatformModel[]; + platforms: IntegrationPlatform[]; + state: LoadState; + subjectId: string; + subjectType: Extract; + onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; +}) { + const [allowSearch, setAllowSearch] = useState(''); + const [denySearch, setDenySearch] = useState(''); + const [allowExpanded, setAllowExpanded] = useState>(() => new Set(props.platforms.map((item) => item.id))); + const [denyExpanded, setDenyExpanded] = useState>(() => new Set(props.platforms.map((item) => item.id))); + const [localError, setLocalError] = useState(''); + + const platformTree = useMemo(() => buildPlatformTree(props.platforms, props.platformModels), [props.platformModels, props.platforms]); + const subjectRules = useMemo( + () => props.accessRules.filter((rule) => rule.subjectType === props.subjectType && rule.subjectId === props.subjectId && rule.status === 'active'), + [props.accessRules, props.subjectId, props.subjectType], + ); + const ruleByEffectAndResource = useMemo(() => buildRuleIndex(subjectRules), [subjectRules]); + const allowTree = useMemo(() => filterTree(platformTree, allowSearch), [allowSearch, platformTree]); + const denyTree = useMemo(() => filterTree(platformTree, denySearch), [denySearch, platformTree]); + + useEffect(() => { + setAllowExpanded((current) => mergeExpanded(current, props.platforms)); + setDenyExpanded((current) => mergeExpanded(current, props.platforms)); + }, [props.platforms]); + + async function setPermission(effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) { + const resourceKey = makeResourceKey(resourceType, resourceId); + await applyPermissionBatch(effect, enabled ? [resourceKey] : [], enabled ? [] : [resourceKey]); + } + + async function setPlatformPermission(effect: Effect, platform: PlatformNode, enabled: boolean) { + const keys = [ + makeResourceKey('platform', platform.id), + ...platform.models.map((model) => makeResourceKey('platform_model', model.id)), + ]; + await batchApply(effect, keys, enabled); + } + + async function selectAll(effect: Effect, nodes: PlatformNode[]) { + await batchApply(effect, visibleResourceKeys(nodes), true); + } + + async function reverseVisible(effect: Effect, nodes: PlatformNode[]) { + const keys = visibleResourceKeys(nodes); + const upsertKeys: ResourceKey[] = []; + const deleteKeys: ResourceKey[] = []; + for (const key of keys) { + const checked = Boolean(ruleByEffectAndResource.get(`${effect}:${key}`)); + if (checked) deleteKeys.push(key); + else upsertKeys.push(key); + } + await applyPermissionBatch(effect, upsertKeys, deleteKeys); + } + + async function clearEffect(effect: Effect) { + const keys = subjectRules + .filter((rule) => rule.effect === effect) + .map(resourceKeyFromRule) + .filter((key): key is ResourceKey => Boolean(key)); + await applyPermissionBatch(effect, [], keys, '访问权限清空失败'); + } + + async function batchApply(effect: Effect, resourceKeys: ResourceKey[], enabled: boolean) { + await applyPermissionBatch(effect, enabled ? resourceKeys : [], enabled ? [] : resourceKeys); + } + + async function applyPermissionBatch( + effect: Effect, + upsertKeys: ResourceKey[], + deleteKeys: ResourceKey[], + errorMessage = '访问权限更新失败', + ) { + if (!props.subjectId) return; + const upsertResources = dedupeResourceKeys(upsertKeys).map((key) => createPermissionResource(effect, key, props.metadataMode)); + const deleteResources = dedupeResourceKeys(deleteKeys).map(resourceRequestFromKey); + if (upsertResources.length === 0 && deleteResources.length === 0) return; + setLocalError(''); + try { + await props.onBatchAccessRules({ + subjectType: props.subjectType, + subjectId: props.subjectId, + effect, + upsertResources, + deleteResources, + }); + } catch (err) { + setLocalError(err instanceof Error ? err.message : errorMessage); + } + } + + const allowSummary = countEffectRules(subjectRules, 'allow'); + const denySummary = countEffectRules(subjectRules, 'deny'); + + if (!props.subjectId) { + return ( +
+ {props.emptySubjectText ?? '请选择维护对象'} +
+ ); + } + + return ( +
+ {localError &&

{localError}

} +
+ void clearEffect('allow')} + onExpandAll={() => setAllowExpanded(new Set(platformTree.map((item) => item.id)))} + onFoldAll={() => setAllowExpanded(new Set())} + onReverse={() => void reverseVisible('allow', allowTree)} + onSearchChange={setAllowSearch} + onSelectAll={() => void selectAll('allow', allowTree)} + onToggleExpanded={(platformId) => setAllowExpanded(toggleSet(allowExpanded, platformId))} + onTogglePlatformPermission={setPlatformPermission} + onTogglePermission={setPermission} + /> + void clearEffect('deny')} + onExpandAll={() => setDenyExpanded(new Set(platformTree.map((item) => item.id)))} + onFoldAll={() => setDenyExpanded(new Set())} + onReverse={() => void reverseVisible('deny', denyTree)} + onSearchChange={setDenySearch} + onSelectAll={() => void selectAll('deny', denyTree)} + onToggleExpanded={(platformId) => setDenyExpanded(toggleSet(denyExpanded, platformId))} + onTogglePlatformPermission={setPlatformPermission} + onTogglePermission={setPermission} + /> +
+
+ ); +} + +function PermissionTreePanel(props: { + effect: Effect; + emptyText: string; + expanded: Set; + rules: Map; + search: string; + state: LoadState; + summary: { platforms: number; models: number }; + title: string; + tree: PlatformNode[]; + onClear: () => void; + onExpandAll: () => void; + onFoldAll: () => void; + onReverse: () => void; + onSearchChange: (value: string) => void; + onSelectAll: () => void; + onToggleExpanded: (platformId: string) => void; + onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void; + onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void; +}) { + return ( + + +
+ {props.title} +

{props.summary.platforms} 个平台 / {props.summary.models} 个模型

+
+
+ +
+ props.onSearchChange(event.target.value)} /> + + + + + +
+
+ {props.tree.length ? props.tree.map((platform) => ( + + )) : {props.emptyText}} +
+
+
+ ); +} + +function PlatformPermissionNode(props: { + effect: Effect; + expanded: boolean; + platform: PlatformNode; + rules: Map; + onToggleExpanded: (platformId: string) => void; + onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void; + onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void; +}) { + const platformRuleKey = `${props.effect}:${makeResourceKey('platform', props.platform.id)}`; + const checkedModels = props.platform.models.filter((model) => props.rules.has(`${props.effect}:${makeResourceKey('platform_model', model.id)}`)).length; + const platformChecked = props.rules.has(platformRuleKey); + const platformState = platformChecked ? true : checkedModels > 0 ? 'indeterminate' : false; + return ( +
+
+ + props.onTogglePlatformPermission(props.effect, props.platform, checked === true)} + /> + {props.platform.name} + {checkedModels > 0 && {checkedModels}} +
+ {props.expanded && props.platform.models.length > 0 && ( +
+ {props.platform.models.map((model) => { + const modelKey = `${props.effect}:${makeResourceKey('platform_model', model.id)}`; + return ( + + ); + })} +
+ )} +
+ ); +} + +function buildPlatformTree(platforms: IntegrationPlatform[], platformModels: PlatformModel[]): PlatformNode[] { + const modelsByPlatform = new Map(); + for (const model of platformModels) { + const current = modelsByPlatform.get(model.platformId) ?? []; + current.push(model); + modelsByPlatform.set(model.platformId, current); + } + return platforms.map((platform) => ({ + id: platform.id, + name: platform.internalName || platform.name, + subtitle: `${platform.provider} / ${platform.platformKey}`, + models: (modelsByPlatform.get(platform.id) ?? []) + .slice() + .sort((a, b) => modelLabel(a).localeCompare(modelLabel(b))) + .map((model) => ({ + id: model.id, + name: modelLabel(model), + subtitle: `${model.modelType} / ${model.modelName}`, + })), + })).sort((a, b) => a.name.localeCompare(b.name)); +} + +function filterTree(tree: PlatformNode[], keyword: string): PlatformNode[] { + const normalized = keyword.trim().toLowerCase(); + if (!normalized) return tree; + return tree.flatMap((platform) => { + const platformMatched = `${platform.name} ${platform.subtitle}`.toLowerCase().includes(normalized); + const models = platformMatched + ? platform.models + : platform.models.filter((model) => `${model.name} ${model.subtitle}`.toLowerCase().includes(normalized)); + return platformMatched || models.length ? [{ ...platform, models }] : []; + }); +} + +function buildRuleIndex(rules: GatewayAccessRule[]) { + const index = new Map(); + for (const rule of rules) { + if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') continue; + if (rule.effect !== 'allow' && rule.effect !== 'deny') continue; + index.set(`${rule.effect}:${makeResourceKey(rule.resourceType, rule.resourceId)}`, rule); + } + return index; +} + +export function countAccessPermissionRules( + rules: GatewayAccessRule[], + subjectType: Extract, + subjectId: string, +) { + const subjectRules = rules.filter((rule) => rule.subjectType === subjectType && rule.subjectId === subjectId && rule.status === 'active'); + return { + allow: countEffectRules(subjectRules, 'allow'), + deny: countEffectRules(subjectRules, 'deny'), + }; +} + +function countEffectRules(rules: GatewayAccessRule[], effect: Effect) { + return rules.reduce((summary, rule) => { + if (rule.effect !== effect) return summary; + if (rule.resourceType === 'platform') summary.platforms += 1; + if (rule.resourceType === 'platform_model') summary.models += 1; + return summary; + }, { platforms: 0, models: 0 }); +} + +function visibleResourceKeys(nodes: PlatformNode[]): ResourceKey[] { + return nodes.flatMap((platform) => [ + makeResourceKey('platform', platform.id), + ...platform.models.map((model) => makeResourceKey('platform_model', model.id)), + ]); +} + +function createPermissionResource( + effect: Effect, + resourceKey: ResourceKey, + metadataMode = 'access_permission_tree', +): GatewayAccessRuleResourceRequest { + const [resourceType, resourceId] = splitResourceKey(resourceKey); + return { + resourceType, + resourceId, + priority: effect === 'deny' ? 10 : 100, + minPermissionLevel: 0, + conditions: {}, + metadata: { uiMode: metadataMode }, + status: 'active', + }; +} + +function resourceRequestFromKey(resourceKey: ResourceKey): GatewayAccessRuleResourceRequest { + const [resourceType, resourceId] = splitResourceKey(resourceKey); + return { resourceType, resourceId }; +} + +function resourceKeyFromRule(rule: GatewayAccessRule): ResourceKey | undefined { + if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') return undefined; + return makeResourceKey(rule.resourceType, rule.resourceId); +} + +function dedupeResourceKeys(keys: ResourceKey[]) { + return Array.from(new Set(keys.filter(Boolean))); +} + +function mergeExpanded(current: Set, platforms: IntegrationPlatform[]) { + if (current.size > 0) return current; + return new Set(platforms.map((item) => item.id)); +} + +function toggleSet(set: Set, value: string) { + const next = new Set(set); + if (next.has(value)) next.delete(value); + else next.add(value); + return next; +} + +function makeResourceKey(resourceType: ResourceType, resourceId: string): ResourceKey { + return `${resourceType}:${resourceId}`; +} + +function splitResourceKey(key: ResourceKey): [ResourceType, string] { + const [resourceType, ...rest] = key.split(':'); + return [resourceType as ResourceType, rest.join(':')]; +} + +function modelLabel(model: PlatformModel) { + return model.displayName || model.modelAlias || model.modelName; +} diff --git a/apps/web/src/pages/admin/AccessRulesPanel.tsx b/apps/web/src/pages/admin/AccessRulesPanel.tsx index 0666b23..65d639f 100644 --- a/apps/web/src/pages/admin/AccessRulesPanel.tsx +++ b/apps/web/src/pages/admin/AccessRulesPanel.tsx @@ -1,33 +1,16 @@ -import { useEffect, useMemo, useState } from 'react'; -import { ChevronDown, ChevronRight, ShieldCheck } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { ShieldCheck } from 'lucide-react'; import type { BaseModelCatalogItem, - GatewayAccessEffect, - GatewayAccessRuleBatchRequest, - GatewayAccessRuleResourceRequest, - GatewayAccessResourceType, GatewayAccessRule, + GatewayAccessRuleBatchRequest, IntegrationPlatform, PlatformModel, UserGroup, } from '@easyai-ai-gateway/contracts'; -import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, Input, Label, Select } from '../../components/ui'; +import { Badge, Card, CardContent, CardHeader, CardTitle, Label, Select } from '../../components/ui'; import type { LoadState } from '../../types'; - -type Effect = Extract; -type ResourceType = Extract; -type ResourceKey = `${ResourceType}:${string}`; - -type PlatformNode = { - id: string; - name: string; - subtitle: string; - models: Array<{ - id: string; - name: string; - subtitle: string; - }>; -}; +import { AccessPermissionEditor } from './AccessPermissionEditor'; export function AccessRulesPanel(props: { accessRules: GatewayAccessRule[]; @@ -40,20 +23,6 @@ export function AccessRulesPanel(props: { onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; }) { const [selectedGroupId, setSelectedGroupId] = useState(props.userGroups[0]?.id ?? ''); - const [allowSearch, setAllowSearch] = useState(''); - const [denySearch, setDenySearch] = useState(''); - const [allowExpanded, setAllowExpanded] = useState>(() => new Set(props.platforms.map((item) => item.id))); - const [denyExpanded, setDenyExpanded] = useState>(() => new Set(props.platforms.map((item) => item.id))); - const [localError, setLocalError] = useState(''); - - const platformTree = useMemo(() => buildPlatformTree(props.platforms, props.platformModels), [props.platformModels, props.platforms]); - const selectedGroupRules = useMemo( - () => props.accessRules.filter((rule) => rule.subjectType === 'user_group' && rule.subjectId === selectedGroupId && rule.status === 'active'), - [props.accessRules, selectedGroupId], - ); - const ruleByEffectAndResource = useMemo(() => buildRuleIndex(selectedGroupRules), [selectedGroupRules]); - const allowTree = useMemo(() => filterTree(platformTree, allowSearch), [allowSearch, platformTree]); - const denyTree = useMemo(() => filterTree(platformTree, denySearch), [denySearch, platformTree]); useEffect(() => { if (!selectedGroupId && props.userGroups[0]?.id) { @@ -61,79 +30,6 @@ export function AccessRulesPanel(props: { } }, [props.userGroups, selectedGroupId]); - useEffect(() => { - setAllowExpanded((current) => mergeExpanded(current, props.platforms)); - setDenyExpanded((current) => mergeExpanded(current, props.platforms)); - }, [props.platforms]); - - async function setPermission(effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) { - const resourceKey = makeResourceKey(resourceType, resourceId); - await applyPermissionBatch(effect, enabled ? [resourceKey] : [], enabled ? [] : [resourceKey]); - } - - async function setPlatformPermission(effect: Effect, platform: PlatformNode, enabled: boolean) { - const keys = [ - makeResourceKey('platform', platform.id), - ...platform.models.map((model) => makeResourceKey('platform_model', model.id)), - ]; - await batchApply(effect, keys, enabled); - } - - async function selectAll(effect: Effect, nodes: PlatformNode[]) { - await batchApply(effect, visibleResourceKeys(nodes), true); - } - - async function reverseVisible(effect: Effect, nodes: PlatformNode[]) { - const keys = visibleResourceKeys(nodes); - const upsertKeys: ResourceKey[] = []; - const deleteKeys: ResourceKey[] = []; - for (const key of keys) { - const checked = Boolean(ruleByEffectAndResource.get(`${effect}:${key}`)); - if (checked) deleteKeys.push(key); - else upsertKeys.push(key); - } - await applyPermissionBatch(effect, upsertKeys, deleteKeys); - } - - async function clearEffect(effect: Effect) { - const keys = selectedGroupRules - .filter((rule) => rule.effect === effect) - .map(resourceKeyFromRule) - .filter((key): key is ResourceKey => Boolean(key)); - await applyPermissionBatch(effect, [], keys, '访问权限清空失败'); - } - - async function batchApply(effect: Effect, resourceKeys: ResourceKey[], enabled: boolean) { - await applyPermissionBatch(effect, enabled ? resourceKeys : [], enabled ? [] : resourceKeys); - } - - async function applyPermissionBatch( - effect: Effect, - upsertKeys: ResourceKey[], - deleteKeys: ResourceKey[], - errorMessage = '访问权限更新失败', - ) { - if (!selectedGroupId) return; - const upsertResources = dedupeResourceKeys(upsertKeys).map((key) => createPermissionResource(effect, key)); - const deleteResources = dedupeResourceKeys(deleteKeys).map(resourceRequestFromKey); - if (upsertResources.length === 0 && deleteResources.length === 0) return; - setLocalError(''); - try { - await props.onBatchAccessRules({ - subjectType: 'user_group', - subjectId: selectedGroupId, - effect, - upsertResources, - deleteResources, - }); - } catch (err) { - setLocalError(err instanceof Error ? err.message : errorMessage); - } - } - - const allowSummary = countEffectRules(selectedGroupRules, 'allow'); - const denySummary = countEffectRules(selectedGroupRules, 'deny'); - return (
@@ -145,7 +41,7 @@ export function AccessRulesPanel(props: { {props.accessRules.length} 条规则 - {(props.message || localError) &&

{localError || props.message}

} + {props.message &&

{props.message}

}