feat: refine api key permissions and admin routes
This commit is contained in:
parent
0fc23d7eb8
commit
d86651ff55
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = `
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<GatewayUser[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
|
||||
const [apiKeys, setApiKeys] = useState<GatewayApiKey[]>([]);
|
||||
const [apiKeyForm, setApiKeyForm] = useState<ApiKeyForm>({ name: 'Local smoke key' });
|
||||
const [apiKeyForm, setApiKeyForm] = useState<ApiKeyForm>({ name: 'Local smoke key', expiresAt: '' });
|
||||
const [apiKeySecret, setApiKeySecret] = useState('');
|
||||
const [apiKeySecretsById, setApiKeySecretsById] = useState<Record<string, string>>({});
|
||||
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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const credential = apiKeySecret || token;
|
||||
@ -695,9 +752,14 @@ export function App() {
|
||||
<WorkspacePage
|
||||
apiKeyForm={apiKeyForm}
|
||||
apiKeySecret={apiKeySecret}
|
||||
apiKeySecretsById={apiKeySecretsById}
|
||||
apiKeyPolicyModels={playgroundModels}
|
||||
data={data}
|
||||
message={coreMessage}
|
||||
section={workspaceSection}
|
||||
state={coreState}
|
||||
onBatchAccessRules={batchSaveAPIKeyAccessRules}
|
||||
onDeleteApiKey={removeAPIKey}
|
||||
onApiKeyFormChange={setApiKeyForm}
|
||||
onSectionChange={navigateWorkspaceSection}
|
||||
onSubmitApiKey={submitAPIKey}
|
||||
@ -861,7 +923,7 @@ function dataKeysForRoute(
|
||||
|
||||
if (activePage === 'workspace') {
|
||||
if (workspaceSection === 'overview') return ['users', 'userGroups', 'apiKeys'];
|
||||
if (workspaceSection === 'apiKeys') return ['apiKeys'];
|
||||
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels'];
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@ -65,15 +65,15 @@ export async function loginLocalAccount(input: { account: string; password: stri
|
||||
}
|
||||
|
||||
export async function listPlatforms(token: string): Promise<ListResponse<IntegrationPlatform>> {
|
||||
return request<ListResponse<IntegrationPlatform>>('/api/v1/platforms', { token });
|
||||
return request<ListResponse<IntegrationPlatform>>('/api/admin/platforms', { token });
|
||||
}
|
||||
|
||||
export async function listModels(token: string): Promise<ListResponse<PlatformModel>> {
|
||||
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
|
||||
return request<ListResponse<PlatformModel>>('/api/admin/models', { token });
|
||||
}
|
||||
|
||||
export async function listPlayableModels(token: string): Promise<ListResponse<PlatformModel>> {
|
||||
return request<ListResponse<PlatformModel>>('/api/v1/playground/models', { token });
|
||||
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
|
||||
}
|
||||
|
||||
export async function listPublicCatalogProviders(): Promise<ListResponse<CatalogProvider>> {
|
||||
@ -81,14 +81,14 @@ export async function listPublicCatalogProviders(): Promise<ListResponse<Catalog
|
||||
}
|
||||
|
||||
export async function listCatalogProviders(token: string): Promise<ListResponse<CatalogProvider>> {
|
||||
return request<ListResponse<CatalogProvider>>('/api/v1/catalog/providers', { token });
|
||||
return request<ListResponse<CatalogProvider>>('/api/admin/catalog/providers', { token });
|
||||
}
|
||||
|
||||
export async function createCatalogProvider(
|
||||
token: string,
|
||||
input: CatalogProviderUpsertRequest,
|
||||
): Promise<CatalogProvider> {
|
||||
return request<CatalogProvider>('/api/v1/catalog/providers', {
|
||||
return request<CatalogProvider>('/api/admin/catalog/providers', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
@ -100,7 +100,7 @@ export async function updateCatalogProvider(
|
||||
providerId: string,
|
||||
input: CatalogProviderUpsertRequest,
|
||||
): Promise<CatalogProvider> {
|
||||
return request<CatalogProvider>(`/api/v1/catalog/providers/${providerId}`, {
|
||||
return request<CatalogProvider>(`/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<void> {
|
||||
await request<void>(`/api/v1/catalog/providers/${providerId}`, {
|
||||
await request<void>(`/api/admin/catalog/providers/${providerId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
@ -119,11 +119,11 @@ export async function listPublicBaseModels(): Promise<ListResponse<BaseModelCata
|
||||
}
|
||||
|
||||
export async function listBaseModels(token: string): Promise<ListResponse<BaseModelCatalogItem>> {
|
||||
return request<ListResponse<BaseModelCatalogItem>>('/api/v1/catalog/base-models', { token });
|
||||
return request<ListResponse<BaseModelCatalogItem>>('/api/admin/catalog/base-models', { token });
|
||||
}
|
||||
|
||||
export async function createBaseModel(token: string, input: BaseModelUpsertRequest): Promise<BaseModelCatalogItem> {
|
||||
return request<BaseModelCatalogItem>('/api/v1/catalog/base-models', {
|
||||
return request<BaseModelCatalogItem>('/api/admin/catalog/base-models', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
@ -135,7 +135,7 @@ export async function updateBaseModel(
|
||||
baseModelId: string,
|
||||
input: BaseModelUpsertRequest,
|
||||
): Promise<BaseModelCatalogItem> {
|
||||
return request<BaseModelCatalogItem>(`/api/v1/catalog/base-models/${baseModelId}`, {
|
||||
return request<BaseModelCatalogItem>(`/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<BaseModelCatalogItem> {
|
||||
return request<BaseModelCatalogItem>(`/api/v1/catalog/base-models/${baseModelId}/reset`, {
|
||||
return request<BaseModelCatalogItem>(`/api/admin/catalog/base-models/${baseModelId}/reset`, {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetAllBaseModels(token: string): Promise<ListResponse<BaseModelCatalogItem>> {
|
||||
return request<ListResponse<BaseModelCatalogItem>>('/api/v1/catalog/base-models/reset-all', {
|
||||
return request<ListResponse<BaseModelCatalogItem>>('/api/admin/catalog/base-models/reset-all', {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBaseModel(token: string, baseModelId: string): Promise<void> {
|
||||
await request<void>(`/api/v1/catalog/base-models/${baseModelId}`, {
|
||||
await request<void>(`/api/admin/catalog/base-models/${baseModelId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPricingRules(token: string): Promise<ListResponse<PricingRule>> {
|
||||
return request<ListResponse<PricingRule>>('/api/v1/pricing/rules', { token });
|
||||
return request<ListResponse<PricingRule>>('/api/admin/pricing/rules', { token });
|
||||
}
|
||||
|
||||
export async function listPricingRuleSets(token: string): Promise<ListResponse<PricingRuleSet>> {
|
||||
return request<ListResponse<PricingRuleSet>>('/api/v1/pricing/rule-sets', { token });
|
||||
return request<ListResponse<PricingRuleSet>>('/api/admin/pricing/rule-sets', { token });
|
||||
}
|
||||
|
||||
export async function createPricingRuleSet(
|
||||
token: string,
|
||||
input: PricingRuleSetUpsertRequest,
|
||||
): Promise<PricingRuleSet> {
|
||||
return request<PricingRuleSet>('/api/v1/pricing/rule-sets', {
|
||||
return request<PricingRuleSet>('/api/admin/pricing/rule-sets', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
@ -187,7 +187,7 @@ export async function updatePricingRuleSet(
|
||||
ruleSetId: string,
|
||||
input: PricingRuleSetUpsertRequest,
|
||||
): Promise<PricingRuleSet> {
|
||||
return request<PricingRuleSet>(`/api/v1/pricing/rule-sets/${ruleSetId}`, {
|
||||
return request<PricingRuleSet>(`/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<void> {
|
||||
await request<void>(`/api/v1/pricing/rule-sets/${ruleSetId}`, {
|
||||
await request<void>(`/api/admin/pricing/rule-sets/${ruleSetId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRuntimePolicySets(token: string): Promise<ListResponse<RuntimePolicySet>> {
|
||||
return request<ListResponse<RuntimePolicySet>>('/api/v1/runtime/policy-sets', { token });
|
||||
return request<ListResponse<RuntimePolicySet>>('/api/admin/runtime/policy-sets', { token });
|
||||
}
|
||||
|
||||
export async function createRuntimePolicySet(
|
||||
token: string,
|
||||
input: RuntimePolicySetUpsertRequest,
|
||||
): Promise<RuntimePolicySet> {
|
||||
return request<RuntimePolicySet>('/api/v1/runtime/policy-sets', {
|
||||
return request<RuntimePolicySet>('/api/admin/runtime/policy-sets', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
@ -221,7 +221,7 @@ export async function updateRuntimePolicySet(
|
||||
policySetId: string,
|
||||
input: RuntimePolicySetUpsertRequest,
|
||||
): Promise<RuntimePolicySet> {
|
||||
return request<RuntimePolicySet>(`/api/v1/runtime/policy-sets/${policySetId}`, {
|
||||
return request<RuntimePolicySet>(`/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<void> {
|
||||
await request<void>(`/api/v1/runtime/policy-sets/${policySetId}`, {
|
||||
await request<void>(`/api/admin/runtime/policy-sets/${policySetId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTenants(token: string): Promise<ListResponse<GatewayTenant>> {
|
||||
return request<ListResponse<GatewayTenant>>('/api/v1/tenants', { token });
|
||||
return request<ListResponse<GatewayTenant>>('/api/admin/tenants', { token });
|
||||
}
|
||||
|
||||
export async function createTenant(token: string, input: GatewayTenantUpsertRequest): Promise<GatewayTenant> {
|
||||
return request<GatewayTenant>('/api/v1/tenants', {
|
||||
return request<GatewayTenant>('/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<GatewayTenant> {
|
||||
return request<GatewayTenant>(`/api/v1/tenants/${tenantId}`, {
|
||||
return request<GatewayTenant>(`/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<void> {
|
||||
await request<void>(`/api/v1/tenants/${tenantId}`, {
|
||||
await request<void>(`/api/admin/tenants/${tenantId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUsers(token: string): Promise<ListResponse<GatewayUser>> {
|
||||
return request<ListResponse<GatewayUser>>('/api/v1/users', { token });
|
||||
return request<ListResponse<GatewayUser>>('/api/admin/users', { token });
|
||||
}
|
||||
|
||||
export async function createGatewayUser(token: string, input: GatewayUserUpsertRequest): Promise<GatewayUser> {
|
||||
return request<GatewayUser>('/api/v1/users', {
|
||||
return request<GatewayUser>('/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<GatewayUser> {
|
||||
return request<GatewayUser>(`/api/v1/users/${userId}`, {
|
||||
return request<GatewayUser>(`/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<void> {
|
||||
await request<void>(`/api/v1/users/${userId}`, {
|
||||
await request<void>(`/api/admin/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUserGroups(token: string): Promise<ListResponse<UserGroup>> {
|
||||
return request<ListResponse<UserGroup>>('/api/v1/user-groups', { token });
|
||||
return request<ListResponse<UserGroup>>('/api/admin/user-groups', { token });
|
||||
}
|
||||
|
||||
export async function createUserGroup(token: string, input: UserGroupUpsertRequest): Promise<UserGroup> {
|
||||
return request<UserGroup>('/api/v1/user-groups', {
|
||||
return request<UserGroup>('/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<UserGroup> {
|
||||
return request<UserGroup>(`/api/v1/user-groups/${groupId}`, {
|
||||
return request<UserGroup>(`/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<void> {
|
||||
await request<void>(`/api/v1/user-groups/${groupId}`, {
|
||||
await request<void>(`/api/admin/user-groups/${groupId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAccessRules(token: string): Promise<ListResponse<GatewayAccessRule>> {
|
||||
return request<ListResponse<GatewayAccessRule>>('/api/v1/access-rules', { token });
|
||||
return request<ListResponse<GatewayAccessRule>>('/api/admin/access-rules', { token });
|
||||
}
|
||||
|
||||
export async function listApiKeyAccessRules(token: string): Promise<ListResponse<GatewayAccessRule>> {
|
||||
return request<ListResponse<GatewayAccessRule>>('/api/v1/api-keys/access-rules', { token });
|
||||
}
|
||||
|
||||
export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise<GatewayAccessRule> {
|
||||
return request<GatewayAccessRule>('/api/v1/access-rules', {
|
||||
return request<GatewayAccessRule>('/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<ListResponse<GatewayAccessRule>> {
|
||||
return request<ListResponse<GatewayAccessRule>>('/api/v1/access-rules/batch', {
|
||||
return request<ListResponse<GatewayAccessRule>>('/api/admin/access-rules/batch', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function batchApiKeyAccessRules(token: string, input: GatewayAccessRuleBatchRequest): Promise<ListResponse<GatewayAccessRule>> {
|
||||
return request<ListResponse<GatewayAccessRule>>('/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<GatewayAccessRule> {
|
||||
return request<GatewayAccessRule>(`/api/v1/access-rules/${ruleId}`, {
|
||||
return request<GatewayAccessRule>(`/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<void> {
|
||||
await request<void>(`/api/v1/access-rules/${ruleId}`, {
|
||||
await request<void>(`/api/admin/access-rules/${ruleId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
@ -365,7 +377,7 @@ export async function listPlayableApiKeys(token: string): Promise<ListResponse<P
|
||||
|
||||
export async function createApiKey(
|
||||
token: string,
|
||||
input: { name: string; scopes?: string[] },
|
||||
input: { name: string; scopes?: string[]; expiresAt?: string },
|
||||
): Promise<CreatedGatewayApiKey> {
|
||||
return request<CreatedGatewayApiKey>('/api/v1/api-keys', {
|
||||
body: input,
|
||||
@ -374,8 +386,15 @@ export async function createApiKey(
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteApiKey(token: string, apiKeyId: string): Promise<void> {
|
||||
await request<void>(`/api/v1/api-keys/${apiKeyId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPlatform(token: string, input: PlatformCreateInput): Promise<IntegrationPlatform> {
|
||||
return request<IntegrationPlatform>('/api/v1/platforms', {
|
||||
return request<IntegrationPlatform>('/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<IntegrationPlatform> {
|
||||
return request<IntegrationPlatform>(`/api/v1/platforms/${platformId}`, {
|
||||
return request<IntegrationPlatform>(`/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<void> {
|
||||
await request<void>(`/api/v1/platforms/${platformId}`, {
|
||||
await request<void>(`/api/admin/platforms/${platformId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
@ -402,7 +421,7 @@ export async function createPlatformModel(
|
||||
platformId: string,
|
||||
input: PlatformModelBindingInput,
|
||||
): Promise<PlatformModel> {
|
||||
return request<PlatformModel>(`/api/v1/platforms/${platformId}/models`, {
|
||||
return request<PlatformModel>(`/api/admin/platforms/${platformId}/models`, {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
@ -414,7 +433,7 @@ export async function replacePlatformModels(
|
||||
platformId: string,
|
||||
models: PlatformModelBindingInput[],
|
||||
): Promise<ListResponse<PlatformModel>> {
|
||||
return request<ListResponse<PlatformModel>>(`/api/v1/platforms/${platformId}/models`, {
|
||||
return request<ListResponse<PlatformModel>>(`/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<void> {
|
||||
await request<void>(`/api/v1/platform-models/${modelId}`, {
|
||||
await request<void>(`/api/admin/platform-models/${modelId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
@ -528,7 +547,7 @@ export async function getTask(token: string, taskId: string): Promise<GatewayTas
|
||||
}
|
||||
|
||||
export async function listRateLimitWindows(token: string): Promise<ListResponse<RateLimitWindow>> {
|
||||
return request<ListResponse<RateLimitWindow>>('/api/v1/runtime/rate-limit-windows', { token });
|
||||
return request<ListResponse<RateLimitWindow>>('/api/admin/runtime/rate-limit-windows', { token });
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
|
||||
54
apps/web/src/components/ui/calendar.tsx
Normal file
54
apps/web/src/components/ui/calendar.tsx
Normal file
@ -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 (
|
||||
<DayPicker
|
||||
className={cn('shCalendar', className)}
|
||||
classNames={{
|
||||
root: 'shCalendarRoot',
|
||||
months: 'shCalendarMonths',
|
||||
month: 'shCalendarMonth',
|
||||
month_caption: 'shCalendarCaption',
|
||||
caption_label: 'shCalendarCaptionLabel',
|
||||
nav: 'shCalendarNav',
|
||||
button_previous: 'shCalendarNavButton',
|
||||
button_next: 'shCalendarNavButton',
|
||||
chevron: 'shCalendarChevron',
|
||||
month_grid: 'shCalendarGrid',
|
||||
weekdays: 'shCalendarWeekdays',
|
||||
weekday: 'shCalendarWeekday',
|
||||
weeks: 'shCalendarWeeks',
|
||||
week: 'shCalendarWeek',
|
||||
day: 'shCalendarDay',
|
||||
day_button: 'shCalendarDayButton',
|
||||
outside: 'shCalendarDayOutside',
|
||||
disabled: 'shCalendarDayDisabled',
|
||||
selected: 'shCalendarDaySelected',
|
||||
today: 'shCalendarDayToday',
|
||||
hidden: 'shCalendarDayHidden',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: CalendarChevron,
|
||||
...props.components,
|
||||
}}
|
||||
showOutsideDays={showOutsideDays}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarChevron(props: ChevronProps) {
|
||||
const Icon = props.orientation === 'left'
|
||||
? ChevronLeft
|
||||
: props.orientation === 'right'
|
||||
? ChevronRight
|
||||
: props.orientation === 'up'
|
||||
? ChevronUp
|
||||
: ChevronDown;
|
||||
return <Icon className={cn('shCalendarChevronIcon', props.className)} size={props.size ?? 16} />;
|
||||
}
|
||||
87
apps/web/src/components/ui/date-time-picker.tsx
Normal file
87
apps/web/src/components/ui/date-time-picker.tsx
Normal file
@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="dateTimePickerTrigger"
|
||||
data-empty={!selectedDate}
|
||||
disabled={props.disabled}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<CalendarIcon size={15} />
|
||||
{selectedDate ? format(selectedDate, 'yyyy-MM-dd HH:mm') : <span>{props.placeholder ?? '选择日期时间'}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="dateTimePickerPopover">
|
||||
<Calendar mode="single" selected={selectedDate} onSelect={updateDate} />
|
||||
<div className="dateTimePickerFooter">
|
||||
<Label>
|
||||
时间
|
||||
<Input type="time" value={timeValue} onChange={(event) => updateTime(event.target.value)} />
|
||||
</Label>
|
||||
<Button type="button" variant="ghost" size="icon" title="清除有效期" disabled={!props.value} onClick={() => props.onChange('')}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@ -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';
|
||||
|
||||
24
apps/web/src/components/ui/popover.tsx
Normal file
24
apps/web/src/components/ui/popover.tsx
Normal file
@ -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<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ align = 'center', className, sideOffset = 6, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
className={cn('shPopoverContent', className)}
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
@ -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<string, string>;
|
||||
apiKeyPolicyModels: PlatformModel[];
|
||||
data: ConsoleData;
|
||||
message: string;
|
||||
section: WorkspaceSection;
|
||||
state: LoadState;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||
onSectionChange: (value: WorkspaceSection) => void;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@ -101,53 +108,191 @@ function BillingPanel() {
|
||||
function ApiKeyPanel(props: {
|
||||
apiKeyForm: ApiKeyForm;
|
||||
apiKeySecret: string;
|
||||
apiKeySecretsById: Record<string, string>;
|
||||
apiKeyPolicyModels: PlatformModel[];
|
||||
data: ConsoleData;
|
||||
message: string;
|
||||
state: LoadState;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
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<GatewayApiKey | null>(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<HTMLFormElement>) {
|
||||
try {
|
||||
await props.onSubmitApiKey(event);
|
||||
setCreateOpen(false);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteApiKey() {
|
||||
if (!pendingDelete) return;
|
||||
await props.onDeleteApiKey(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="contentGrid two">
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>创建 API Key</CardTitle>
|
||||
<div>
|
||||
<CardTitle>API Key</CardTitle>
|
||||
<p className="mutedText">按 Key 维护调用凭证、最近使用时间和平台/模型权限策略。</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={15} />
|
||||
创建 API Key
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="formGrid" onSubmit={props.onSubmitApiKey}>
|
||||
<Label>
|
||||
名称
|
||||
<Input value={props.apiKeyForm.name} onChange={(event) => props.onApiKeyFormChange({ name: event.target.value })} />
|
||||
</Label>
|
||||
<Button type="submit" disabled={props.state === 'loading'}>
|
||||
<KeyRound size={15} />
|
||||
创建
|
||||
</Button>
|
||||
{props.apiKeySecret && (
|
||||
<div className="createdApiKeyBox">
|
||||
<code className="secretBox">{props.apiKeySecret}</code>
|
||||
<Button type="button" variant="secondary" onClick={() => props.onUseApiKeyForPlayground(latestUsableKey?.id)}>
|
||||
使用测试
|
||||
</Button>
|
||||
{(localMessage || props.message) && <p className="formMessage">{localMessage || props.message}</p>}
|
||||
<Table className="apiKeyTable">
|
||||
<TableRow className="shTableHeader">
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>权限策略</TableHead>
|
||||
<TableHead>最近使用</TableHead>
|
||||
<TableHead>有效期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
{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 (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<span className="apiKeyNameCell">
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.keyPrefix}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="apiKeySecretCell">
|
||||
<code>{secret ? maskApiKey(secret) : item.keyPrefix}</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={secret ? '复制 API Key' : '暂无可复制的完整 Key'}
|
||||
disabled={!secret}
|
||||
onClick={() => void copyApiKey(item)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button type="button" className="apiKeyPolicyButton" onClick={() => setPolicyApiKeyId(item.id)}>
|
||||
<ShieldCheck size={14} />
|
||||
<span>{permissionSummaryText(summary)}</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>{formatDateTime(item.lastUsedAt)}</TableCell>
|
||||
<TableCell>{formatDateTime(item.expiresAt)}</TableCell>
|
||||
<TableCell><Badge variant={item.status === 'active' ? 'success' : 'secondary'}>{item.status}</Badge></TableCell>
|
||||
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" variant="ghost" size="icon" title="删除 API Key" onClick={() => setPendingDelete(item)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}) : (
|
||||
<div className="emptyState">
|
||||
<strong>暂无 API Key</strong>
|
||||
<span>点击右上角创建调用凭证。</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Key 列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EntityTable
|
||||
columns={['名称', '前缀', '状态', '创建时间']}
|
||||
empty="暂无 API Key"
|
||||
rows={props.data.apiKeys.map((item) => [item.name, item.keyPrefix, item.status, new Date(item.createdAt).toLocaleString()])}
|
||||
|
||||
<FormDialog
|
||||
ariaLabel="创建 API Key"
|
||||
bodyClassName="apiKeyCreateDialogBody"
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button type="submit" size="sm" disabled={props.state === 'loading'}>
|
||||
<KeyRound size={15} />
|
||||
确认创建
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
open={createOpen}
|
||||
title="创建 API Key"
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSubmit={(event) => void submitCreate(event)}
|
||||
>
|
||||
<Label>
|
||||
名称
|
||||
<Input value={props.apiKeyForm.name} placeholder="例如:生产调用 Key" onChange={(event) => props.onApiKeyFormChange({ ...props.apiKeyForm, name: event.target.value })} />
|
||||
</Label>
|
||||
<Label>
|
||||
有效期
|
||||
<DateTimePicker
|
||||
value={props.apiKeyForm.expiresAt}
|
||||
placeholder="不设置则长期有效"
|
||||
onChange={(expiresAt) => props.onApiKeyFormChange({ ...props.apiKeyForm, expiresAt })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</Label>
|
||||
</FormDialog>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel="维护 API Key 权限策略"
|
||||
bodyClassName="apiKeyPolicyDialogBody"
|
||||
className="apiKeyPolicyDialog"
|
||||
footer={<Button type="button" size="sm" onClick={() => setPolicyApiKeyId('')}>关闭</Button>}
|
||||
open={Boolean(selectedPolicyKey)}
|
||||
title={selectedPolicyKey ? `权限策略:${selectedPolicyKey.name}` : '权限策略'}
|
||||
onClose={() => setPolicyApiKeyId('')}
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
>
|
||||
<AccessPermissionEditor
|
||||
accessRules={props.data.accessRules}
|
||||
metadataMode="api_key_permission_tree"
|
||||
platformModels={props.apiKeyPolicyModels}
|
||||
platforms={permissionPlatforms}
|
||||
state={props.state}
|
||||
subjectId={selectedPolicyKey?.id ?? ''}
|
||||
subjectType="api_key"
|
||||
onBatchAccessRules={props.onBatchAccessRules}
|
||||
/>
|
||||
</FormDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除 API Key"
|
||||
description="删除后该 Key 将无法继续调用,关联的平台/模型权限策略会同步删除。"
|
||||
loading={props.state === 'loading'}
|
||||
open={Boolean(pendingDelete)}
|
||||
title={`确认删除 ${pendingDelete?.name ?? ''}?`}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={confirmDeleteApiKey}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -195,3 +340,48 @@ function InfoItem(props: { label: string; value: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function apiKeySecretFor(item: GatewayApiKey, secretsById: Record<string, string>) {
|
||||
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<typeof countAccessPermissionRules>) {
|
||||
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<string, IntegrationPlatform>();
|
||||
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));
|
||||
}
|
||||
|
||||
416
apps/web/src/pages/admin/AccessPermissionEditor.tsx
Normal file
416
apps/web/src/pages/admin/AccessPermissionEditor.tsx
Normal file
@ -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<GatewayAccessEffect, 'allow' | 'deny'>;
|
||||
type ResourceType = Extract<GatewayAccessResourceType, 'platform' | 'platform_model'>;
|
||||
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<GatewayAccessSubjectType, 'user_group' | 'api_key'>;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
}) {
|
||||
const [allowSearch, setAllowSearch] = useState('');
|
||||
const [denySearch, setDenySearch] = useState('');
|
||||
const [allowExpanded, setAllowExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
|
||||
const [denyExpanded, setDenyExpanded] = useState<Set<string>>(() => 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 (
|
||||
<div className="emptyState">
|
||||
<strong>{props.emptySubjectText ?? '请选择维护对象'}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="accessEditorStack">
|
||||
{localError && <p className="formMessage">{localError}</p>}
|
||||
<section className="accessPermissionGrid">
|
||||
<PermissionTreePanel
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="allow"
|
||||
expanded={allowExpanded}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={allowSearch}
|
||||
state={props.state}
|
||||
summary={allowSummary}
|
||||
title="专属使用(平台/模型)"
|
||||
tree={allowTree}
|
||||
onClear={() => 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}
|
||||
/>
|
||||
<PermissionTreePanel
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="deny"
|
||||
expanded={denyExpanded}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={denySearch}
|
||||
state={props.state}
|
||||
summary={denySummary}
|
||||
title="不允许使用(平台/模型)"
|
||||
tree={denyTree}
|
||||
onClear={() => 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}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionTreePanel(props: {
|
||||
effect: Effect;
|
||||
emptyText: string;
|
||||
expanded: Set<string>;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
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 (
|
||||
<Card className="accessPermissionPanel">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
<p className="mutedText">{props.summary.platforms} 个平台 / {props.summary.models} 个模型</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="accessTreeToolbar">
|
||||
<Input size="sm" value={props.search} placeholder="筛选平台/模型" onChange={(event) => props.onSearchChange(event.target.value)} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onExpandAll}>展开</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onFoldAll}>折叠</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onSelectAll}>全选</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onReverse}>反选</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onClear}>清空</Button>
|
||||
</div>
|
||||
<div className="accessTreeBox">
|
||||
{props.tree.length ? props.tree.map((platform) => (
|
||||
<PlatformPermissionNode
|
||||
effect={props.effect}
|
||||
expanded={props.expanded.has(platform.id)}
|
||||
key={platform.id}
|
||||
platform={platform}
|
||||
rules={props.rules}
|
||||
onToggleExpanded={props.onToggleExpanded}
|
||||
onTogglePlatformPermission={props.onTogglePlatformPermission}
|
||||
onTogglePermission={props.onTogglePermission}
|
||||
/>
|
||||
)) : <span className="accessTreeEmpty">{props.emptyText}</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformPermissionNode(props: {
|
||||
effect: Effect;
|
||||
expanded: boolean;
|
||||
platform: PlatformNode;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
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 (
|
||||
<div className="accessTreeNode">
|
||||
<div className="accessTreeRow">
|
||||
<button type="button" className="accessTreeExpand" onClick={() => props.onToggleExpanded(props.platform.id)}>
|
||||
{props.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<Checkbox
|
||||
checked={platformState}
|
||||
onCheckedChange={(checked) => props.onTogglePlatformPermission(props.effect, props.platform, checked === true)}
|
||||
/>
|
||||
<span title={props.platform.subtitle || props.platform.name}>{props.platform.name}</span>
|
||||
{checkedModels > 0 && <Badge variant="secondary">{checkedModels}</Badge>}
|
||||
</div>
|
||||
{props.expanded && props.platform.models.length > 0 && (
|
||||
<div className="accessTreeChildren">
|
||||
{props.platform.models.map((model) => {
|
||||
const modelKey = `${props.effect}:${makeResourceKey('platform_model', model.id)}`;
|
||||
return (
|
||||
<label className="accessTreeRow accessTreeModel" key={model.id}>
|
||||
<span />
|
||||
<Checkbox
|
||||
checked={props.rules.has(modelKey)}
|
||||
onCheckedChange={(checked) => props.onTogglePermission(props.effect, 'platform_model', model.id, checked === true)}
|
||||
/>
|
||||
<span title={model.subtitle || model.name}>{model.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPlatformTree(platforms: IntegrationPlatform[], platformModels: PlatformModel[]): PlatformNode[] {
|
||||
const modelsByPlatform = new Map<string, PlatformModel[]>();
|
||||
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<string, GatewayAccessRule>();
|
||||
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<GatewayAccessSubjectType, 'user_group' | 'api_key'>,
|
||||
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<string>, platforms: IntegrationPlatform[]) {
|
||||
if (current.size > 0) return current;
|
||||
return new Set(platforms.map((item) => item.id));
|
||||
}
|
||||
|
||||
function toggleSet(set: Set<string>, 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;
|
||||
}
|
||||
@ -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<GatewayAccessEffect, 'allow' | 'deny'>;
|
||||
type ResourceType = Extract<GatewayAccessResourceType, 'platform' | 'platform_model'>;
|
||||
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<void>;
|
||||
}) {
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(props.userGroups[0]?.id ?? '');
|
||||
const [allowSearch, setAllowSearch] = useState('');
|
||||
const [denySearch, setDenySearch] = useState('');
|
||||
const [allowExpanded, setAllowExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
|
||||
const [denyExpanded, setDenyExpanded] = useState<Set<string>>(() => 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 (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
@ -145,7 +41,7 @@ export function AccessRulesPanel(props: {
|
||||
<Badge variant="secondary">{props.accessRules.length} 条规则</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
|
||||
{props.message && <p className="formMessage">{props.message}</p>}
|
||||
<div className="accessGroupToolbar">
|
||||
<Label>
|
||||
用户组
|
||||
@ -162,48 +58,16 @@ export function AccessRulesPanel(props: {
|
||||
</Card>
|
||||
|
||||
{selectedGroupId ? (
|
||||
<section className="accessPermissionGrid">
|
||||
<PermissionTreePanel
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="allow"
|
||||
expanded={allowExpanded}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={allowSearch}
|
||||
state={props.state}
|
||||
summary={allowSummary}
|
||||
title="专属使用(平台/模型)"
|
||||
tree={allowTree}
|
||||
onClear={() => 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}
|
||||
/>
|
||||
<PermissionTreePanel
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="deny"
|
||||
expanded={denyExpanded}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={denySearch}
|
||||
state={props.state}
|
||||
summary={denySummary}
|
||||
title="不允许使用(平台/模型)"
|
||||
tree={denyTree}
|
||||
onClear={() => 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}
|
||||
/>
|
||||
</section>
|
||||
<AccessPermissionEditor
|
||||
accessRules={props.accessRules}
|
||||
metadataMode="user_group_permission_tree"
|
||||
platformModels={props.platformModels}
|
||||
platforms={props.platforms}
|
||||
state={props.state}
|
||||
subjectId={selectedGroupId}
|
||||
subjectType="user_group"
|
||||
onBatchAccessRules={props.onBatchAccessRules}
|
||||
/>
|
||||
) : (
|
||||
<div className="emptyState">
|
||||
<strong>暂无用户组</strong>
|
||||
@ -213,221 +77,3 @@ export function AccessRulesPanel(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionTreePanel(props: {
|
||||
effect: Effect;
|
||||
emptyText: string;
|
||||
expanded: Set<string>;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
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 (
|
||||
<Card className="accessPermissionPanel">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
<p className="mutedText">{props.summary.platforms} 个平台 / {props.summary.models} 个模型</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="accessTreeToolbar">
|
||||
<Input size="sm" value={props.search} placeholder="筛选平台/模型" onChange={(event) => props.onSearchChange(event.target.value)} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onExpandAll}>展开</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onFoldAll}>折叠</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onSelectAll}>全选</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onReverse}>反选</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onClear}>清空</Button>
|
||||
</div>
|
||||
<div className="accessTreeBox">
|
||||
{props.tree.length ? props.tree.map((platform) => (
|
||||
<PlatformPermissionNode
|
||||
effect={props.effect}
|
||||
expanded={props.expanded.has(platform.id)}
|
||||
key={platform.id}
|
||||
platform={platform}
|
||||
rules={props.rules}
|
||||
onToggleExpanded={props.onToggleExpanded}
|
||||
onTogglePlatformPermission={props.onTogglePlatformPermission}
|
||||
onTogglePermission={props.onTogglePermission}
|
||||
/>
|
||||
)) : <span className="accessTreeEmpty">{props.emptyText}</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformPermissionNode(props: {
|
||||
effect: Effect;
|
||||
expanded: boolean;
|
||||
platform: PlatformNode;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
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 (
|
||||
<div className="accessTreeNode">
|
||||
<div className="accessTreeRow">
|
||||
<button type="button" className="accessTreeExpand" onClick={() => props.onToggleExpanded(props.platform.id)}>
|
||||
{props.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<Checkbox
|
||||
checked={platformState}
|
||||
onCheckedChange={(checked) => props.onTogglePlatformPermission(props.effect, props.platform, checked === true)}
|
||||
/>
|
||||
<span title={props.platform.subtitle || props.platform.name}>{props.platform.name}</span>
|
||||
{checkedModels > 0 && <Badge variant="secondary">{checkedModels}</Badge>}
|
||||
</div>
|
||||
{props.expanded && props.platform.models.length > 0 && (
|
||||
<div className="accessTreeChildren">
|
||||
{props.platform.models.map((model) => {
|
||||
const modelKey = `${props.effect}:${makeResourceKey('platform_model', model.id)}`;
|
||||
return (
|
||||
<label className="accessTreeRow accessTreeModel" key={model.id}>
|
||||
<span />
|
||||
<Checkbox
|
||||
checked={props.rules.has(modelKey)}
|
||||
onCheckedChange={(checked) => props.onTogglePermission(props.effect, 'platform_model', model.id, checked === true)}
|
||||
/>
|
||||
<span title={model.subtitle || model.name}>{model.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPlatformTree(platforms: IntegrationPlatform[], platformModels: PlatformModel[]): PlatformNode[] {
|
||||
const modelsByPlatform = new Map<string, PlatformModel[]>();
|
||||
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<string, GatewayAccessRule>();
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
): GatewayAccessRuleResourceRequest {
|
||||
const [resourceType, resourceId] = splitResourceKey(resourceKey);
|
||||
return {
|
||||
resourceType,
|
||||
resourceId,
|
||||
priority: effect === 'deny' ? 10 : 100,
|
||||
minPermissionLevel: 0,
|
||||
conditions: {},
|
||||
metadata: { uiMode: 'user_group_permission_tree' },
|
||||
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<string>, platforms: IntegrationPlatform[]) {
|
||||
if (current.size > 0) return current;
|
||||
return new Set(platforms.map((item) => item.id));
|
||||
}
|
||||
|
||||
function toggleSet(set: Set<string>, 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;
|
||||
}
|
||||
|
||||
@ -285,6 +285,11 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.accessEditorStack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.accessPermissionPanel .shCardContent {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@ -388,6 +393,95 @@
|
||||
min-width: 900px;
|
||||
}
|
||||
|
||||
.apiKeyTable {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.apiKeyTable .shTableRow {
|
||||
grid-template-columns: minmax(170px, 1.05fr) minmax(210px, 1.25fr) minmax(150px, 0.9fr) minmax(150px, 0.9fr) minmax(150px, 0.9fr) minmax(96px, 0.55fr) minmax(150px, 0.9fr) minmax(74px, 0.4fr);
|
||||
min-width: 1160px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.apiKeyNameCell,
|
||||
.apiKeySecretCell {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.apiKeyNameCell strong,
|
||||
.apiKeyNameCell small,
|
||||
.apiKeySecretCell code {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.apiKeyNameCell strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.apiKeyNameCell small {
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.apiKeySecretCell {
|
||||
grid-template-columns: minmax(0, 1fr) 30px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.apiKeySecretCell code {
|
||||
min-width: 0;
|
||||
color: var(--text-normal);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.apiKeyPolicyButton {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--surface);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.apiKeyPolicyButton span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.apiKeyCreateDialogBody {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.apiKeyPolicyDialog {
|
||||
width: min(1120px, 100%);
|
||||
}
|
||||
|
||||
.apiKeyPolicyDialogBody {
|
||||
grid-template-columns: 1fr;
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.apiKeyPolicyDialog .accessTreeBox {
|
||||
min-height: 360px;
|
||||
max-height: min(480px, calc(100vh - 360px));
|
||||
}
|
||||
|
||||
.tenantTable .shTableRow {
|
||||
grid-template-columns: minmax(210px, 1.35fr) minmax(110px, 0.7fr) minmax(150px, 0.9fr) minmax(110px, 0.7fr) minmax(90px, 0.55fr) minmax(86px, 0.5fr);
|
||||
min-width: 880px;
|
||||
@ -1314,7 +1408,9 @@
|
||||
.runtimePolicyFormBody,
|
||||
.runtimePolicyRows,
|
||||
.accessPermissionGrid,
|
||||
.accessTreeToolbar {
|
||||
.accessTreeToolbar,
|
||||
.apiKeyCreateDialogBody,
|
||||
.apiKeyPolicyDialogBody {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@ -346,6 +346,16 @@
|
||||
box-shadow: 0 -1px 0 rgba(16, 24, 40, 0.02);
|
||||
}
|
||||
|
||||
.shPopoverContent {
|
||||
z-index: 80;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--popover);
|
||||
color: var(--popover-foreground);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.shInput,
|
||||
.shTextarea {
|
||||
width: 100%;
|
||||
@ -517,6 +527,150 @@
|
||||
.shBadgeWarning { background: #fbf4e8; color: #8a6116; }
|
||||
.shBadgeDestructive { background: #fff3f3; color: #b42318; }
|
||||
|
||||
.shCalendar {
|
||||
padding: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.shCalendarRoot,
|
||||
.shCalendarMonths,
|
||||
.shCalendarMonth,
|
||||
.shCalendarWeeks {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shCalendarCaption {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shCalendarCaptionLabel {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.shCalendarNav {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shCalendarNavButton {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--surface);
|
||||
color: var(--text-normal);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.shCalendarNavButton:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.shCalendarChevronIcon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.shCalendarGrid {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.shCalendarWeekdays,
|
||||
.shCalendarWeek {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 32px);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shCalendarWeekday {
|
||||
display: grid;
|
||||
height: 26px;
|
||||
place-items: center;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.shCalendarDay {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.shCalendarDayButton {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: var(--control-radius);
|
||||
background: transparent;
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.shCalendarDayButton:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.shCalendarDayOutside .shCalendarDayButton {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.shCalendarDayDisabled .shCalendarDayButton {
|
||||
cursor: not-allowed;
|
||||
color: var(--text-faint);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.shCalendarDayToday .shCalendarDayButton {
|
||||
border: 1px solid var(--ring);
|
||||
}
|
||||
|
||||
.shCalendarDaySelected .shCalendarDayButton {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.shCalendarDayHidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.dateTimePickerTrigger {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dateTimePickerTrigger[data-empty="true"] {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.dateTimePickerPopover {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.dateTimePickerFooter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 34px;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.shTable {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@ -40,6 +40,7 @@ export interface TaskForm {
|
||||
|
||||
export interface ApiKeyForm {
|
||||
name: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface PlatformForm {
|
||||
|
||||
@ -454,6 +454,7 @@ export interface GatewayApiKey {
|
||||
tenantKey?: string;
|
||||
userId?: string;
|
||||
keyPrefix: string;
|
||||
secret?: string;
|
||||
name: string;
|
||||
scopes?: string[];
|
||||
userGroupId?: string;
|
||||
|
||||
@ -41,6 +41,9 @@ importers:
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@ -68,6 +71,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
katex:
|
||||
specifier: ^0.16.45
|
||||
version: 0.16.45
|
||||
@ -77,6 +83,9 @@ importers:
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.6
|
||||
react-day-picker:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(react@19.2.6)
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.6(react@19.2.6)
|
||||
@ -765,6 +774,9 @@ packages:
|
||||
'@chevrotain/utils@12.0.0':
|
||||
resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==}
|
||||
|
||||
'@date-fns/tz@1.4.1':
|
||||
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
@ -2683,6 +2695,9 @@ packages:
|
||||
dagre-d3-es@7.0.14:
|
||||
resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==}
|
||||
|
||||
date-fns@4.1.0:
|
||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||
|
||||
dayjs@1.11.20:
|
||||
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||
|
||||
@ -3595,6 +3610,12 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
react-day-picker@10.0.0:
|
||||
resolution: {integrity: sha512-lrEXo5wFPsq5LTcayelM3BPueD00v7zbdipAY+EIdPcseVykYwkOWx4Ujn/EtbBvpnp8ZPUHol17HXH6kVbZoA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
react-dom@19.2.6:
|
||||
resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==}
|
||||
peerDependencies:
|
||||
@ -5044,6 +5065,8 @@ snapshots:
|
||||
|
||||
'@chevrotain/utils@12.0.0': {}
|
||||
|
||||
'@date-fns/tz@1.4.1': {}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@ -7023,6 +7046,8 @@ snapshots:
|
||||
d3: 7.9.0
|
||||
lodash-es: 4.18.1
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
|
||||
dayjs@1.11.20: {}
|
||||
|
||||
debug@4.4.3:
|
||||
@ -8270,6 +8295,12 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
react-day-picker@10.0.0(react@19.2.6):
|
||||
dependencies:
|
||||
'@date-fns/tz': 1.4.1
|
||||
date-fns: 4.1.0
|
||||
react: 19.2.6
|
||||
|
||||
react-dom@19.2.6(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
Loading…
Reference in New Issue
Block a user