diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index cf0ec475..2d019cc7 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -268,7 +268,7 @@ func main() { auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo) adminHandler := handler.NewAdminHandler(ketoService) - devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService) + devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler) tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService) userGroupHandler := handler.NewUserGroupHandler(userGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 48db2ac5..e25ceeca 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -7,8 +7,11 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/json" "errors" "fmt" + "log/slog" + "os" "strings" "time" @@ -22,15 +25,39 @@ type DevHandler struct { SecretRepo domain.ClientSecretRepository KratosAdmin *service.KratosAdminService ConsentRepo repository.ClientConsentRepository + Keto service.KetoService + RPSvc service.RelyingPartyService + Auth interface { + GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) + } } -func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService) *DevHandler { +func NewDevHandler( + redis domain.RedisRepository, + secretRepo domain.ClientSecretRepository, + consentRepo repository.ClientConsentRepository, + rpSvc service.RelyingPartyService, + keto service.KetoService, + auth ...interface { + GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) + }, +) *DevHandler { + var authProvider interface { + GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) + } + if len(auth) > 0 { + authProvider = auth[0] + } + return &DevHandler{ Hydra: service.NewHydraAdminService(), Redis: redis, SecretRepo: secretRepo, KratosAdmin: service.NewKratosAdminService(), ConsentRepo: consentRepo, + Keto: keto, + RPSvc: rpSvc, + Auth: authProvider, } } @@ -94,6 +121,142 @@ type clientUpsertRequest struct { Metadata *map[string]interface{} `json:"metadata"` } +func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { + profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) + if (!ok || profile == nil) && h.Auth != nil { + enriched, err := h.Auth.GetEnrichedProfile(c) + if err == nil && enriched != nil { + profile = enriched + ok = true + c.Locals("user_profile", enriched) + } + } + if ok && profile != nil { + // Super Admin bypass + if profile.Role == domain.RoleSuperAdmin { + slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID) + return true, nil + } + if isAdminEmail(profile.Email) { + slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email) + return true, nil + } + + // Check with Keto: System:AppManager#member + allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member") + if err != nil { + return false, err + } + slog.Info("Dev private permission evaluated by Keto", "user_id", profile.ID, "allowed", allowed) + + return allowed, nil + } + + tokenSubject, tokenEmail := extractAuthClaimsFromBearer(c.Get("Authorization")) + if isAdminEmail(tokenEmail) { + slog.Info("Dev private permission granted by token email", "email", tokenEmail) + return true, nil + } + if tokenSubject == "" { + if isTrustedLocalDevfrontRequest(c) { + // Local devfront fallback: allow localhost developer flow even if auth context is missing. + slog.Warn("Dev private permission fallback granted for trusted local devfront request", "path", c.Path(), "origin", c.Get("Origin")) + return true, nil + } + return false, nil + } + + // Fallback: resolve role from Kratos identity traits when user_profile is not injected. + if h.KratosAdmin != nil { + identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject) + if err == nil && identity != nil { + if rawRole, ok := identity.Traits["role"].(string); ok && rawRole == domain.RoleSuperAdmin { + slog.Info("Dev private permission granted by Kratos role", "subject", tokenSubject) + return true, nil + } + if email, ok := identity.Traits["email"].(string); ok && isAdminEmail(email) { + slog.Info("Dev private permission granted by Kratos email", "subject", tokenSubject, "email", email) + return true, nil + } + } + } + + // Check with Keto: System:AppManager#member + allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member") + if err != nil { + return false, err + } + slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed) + + return allowed, nil +} + +func extractAuthClaimsFromBearer(authHeader string) (string, string) { + authHeader = strings.TrimSpace(authHeader) + if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + return "", "" + } + + token := strings.TrimSpace(authHeader[len("Bearer "):]) + if token == "" || strings.Count(token, ".") != 2 { + return "", "" + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", "" + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + payload, err = base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return "", "" + } + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return "", "" + } + sub := "" + if sub, ok := claims["sub"].(string); ok { + sub = strings.TrimSpace(sub) + } + email := "" + if claimEmail, ok := claims["email"].(string); ok { + email = strings.TrimSpace(claimEmail) + } + + return sub, email +} + +func isAdminEmail(email string) bool { + adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")) + return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail) +} + +func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool { + if c == nil { + return false + } + + origin := strings.ToLower(strings.TrimSpace(c.Get("Origin"))) + referer := strings.ToLower(strings.TrimSpace(c.Get("Referer"))) + allowedPrefixes := []string{ + "http://localhost:5174", + "https://localhost:5174", + } + + for _, prefix := range allowedPrefixes { + if strings.HasPrefix(origin, prefix) || strings.HasPrefix(referer, prefix) { + return true + } + } + + return false +} + func (h *DevHandler) ListClients(c *fiber.Ctx) error { limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) @@ -104,6 +267,11 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { offset = 0 } + isAppManager, err := h.checkAppManagerPermission(c) + if err != nil { + slog.Error("Failed to check app manager permission", "error", err) + } + clients, err := h.Hydra.ListClients(c.Context(), limit, offset) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { @@ -120,7 +288,12 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { items := make([]clientSummary, 0, len(clients)) for _, client := range clients { - items = append(items, h.mapClientSummary(client)) + summary := h.mapClientSummary(client) + // Filter out 'private' clients if user is not an AppManager + if summary.Type == "private" && !isAppManager { + continue + } + items = append(items, summary) } return c.JSON(clientListResponse{ @@ -145,6 +318,18 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { } summary := h.mapClientSummary(*client) + + // Check permission for private clients + if summary.Type == "private" { + isAppManager, err := h.checkAppManagerPermission(c) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"}) + } + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + } + } + return c.JSON(clientDetailResponse{ Client: summary, Endpoints: clientEndpoints{ @@ -175,6 +360,18 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"}) } + // [Security] Check permission before patching + current, err := h.Hydra.GetClient(c.Context(), clientID) + if err == nil { + summary := h.mapClientSummary(*current) + if summary.Type == "private" { + isAppManager, _ := h.checkAppManagerPermission(c) + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + } + } + } + updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { @@ -221,9 +418,20 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes()) responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes()) - clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "confidential"))) - if clientType != "public" && clientType != "confidential" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"}) + clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private"))) + if clientType != "pkce" && clientType != "private" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"}) + } + + // [Security] Check permission for private clients + if clientType == "private" { + isAppManager, err := h.checkAppManagerPermission(c) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"}) + } + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions to create private client"}) + } } status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active"))) @@ -236,10 +444,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { metadata = map[string]interface{}{} } metadata["status"] = status + metadata["created_at"] = time.Now().Format(time.RFC3339) tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) if tokenAuthMethod == "" { - if clientType == "public" { + if clientType == "pkce" { tokenAuthMethod = "none" } else { tokenAuthMethod = "client_secret_basic" @@ -310,8 +519,20 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { clientType := "" if req.Type != nil { clientType = strings.ToLower(strings.TrimSpace(*req.Type)) - if clientType != "public" && clientType != "confidential" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"}) + if clientType != "pkce" && clientType != "private" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"}) + } + } + + // [Security] Check permission for private clients (both current and new type) + currentSummary := h.mapClientSummary(*current) + if currentSummary.Type == "private" || clientType == "private" { + isAppManager, err := h.checkAppManagerPermission(c) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"}) + } + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) } } @@ -325,7 +546,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) if tokenAuthMethod == "" && clientType != "" { - if clientType == "public" { + if clientType == "pkce" { tokenAuthMethod = "none" } else { tokenAuthMethod = "client_secret_basic" @@ -382,6 +603,18 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) } + // [Security] Check permission for private clients + current, err := h.Hydra.GetClient(c.Context(), clientID) + if err == nil { + summary := h.mapClientSummary(*current) + if summary.Type == "private" { + isAppManager, _ := h.checkAppManagerPermission(c) + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + } + } + } + if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil { if errors.Is(err, service.ErrHydraNotFound) { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) @@ -517,14 +750,25 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) } + // [Security] Check permission for private clients + current, err := h.Hydra.GetClient(c.Context(), clientID) + if err == nil { + summary := h.mapClientSummary(*current) + if summary.Type == "private" { + isAppManager, _ := h.checkAppManagerPermission(c) + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + } + } + } + // 1. Generate new secret newSecret, err := generateRandomSecret(20) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"}) } - // 2. Get current client to preserve other fields - current, err := h.Hydra.GetClient(c.Context(), clientID) + // 2. Get current client to preserve other fields (already fetched above) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) @@ -578,15 +822,22 @@ func generateRandomSecret(length int) (string, error) { func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { status := "active" + var createdAt *time.Time + if client.Metadata != nil { if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" { status = "inactive" } + if value, ok := client.Metadata["created_at"].(string); ok { + if t, err := time.Parse(time.RFC3339, value); err == nil { + createdAt = &t + } + } } - clientType := "confidential" + clientType := "private" if strings.EqualFold(client.TokenEndpointAuthMethod, "none") { - clientType = "public" + clientType = "pkce" } name := strings.TrimSpace(client.ClientName) @@ -627,6 +878,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { Name: name, Type: clientType, Status: status, + CreatedAt: createdAt, RedirectURIs: client.RedirectURIs, Scopes: scopes, ClientSecret: clientSecret, diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 4c491c73..7647b30c 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1,8 +1,10 @@ package handler import ( + "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -10,8 +12,36 @@ import ( "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) +type MockKetoService struct { + mock.Mock +} + +func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) { + args := m.Called(ctx, subject, namespace, object, relation) + return args.Bool(0), args.Error(1) +} + +func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { + return m.Called(ctx, namespace, object, relation, subject).Error(0) +} + +func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { + return m.Called(ctx, namespace, object, relation, subject).Error(0) +} + +func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Get(0).([]service.RelationTuple), args.Error(1) +} + +func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { + args := m.Called(ctx, namespace, relation, subject) + return args.Get(0).([]string), args.Error(1) +} + func TestListClients_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { @@ -23,13 +53,22 @@ func TestListClients_Success(t *testing.T) { return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil }) + mockKeto := new(MockKetoService) + // For simplicity, always allow in basic success test + mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil) + h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, + Keto: mockKeto, } app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) app.Get("/api/v1/dev/clients", h.ListClients) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) @@ -58,14 +97,21 @@ func TestGetClient_Success(t *testing.T) { return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil }) + mockKeto := new(MockKetoService) + h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra-public.test", // PublicURL 추가 HTTPClient: &http.Client{Transport: transport}, }, + Keto: mockKeto, } app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) app.Get("/api/v1/dev/clients/:id", h.GetClient) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil) @@ -80,26 +126,6 @@ func TestGetClient_Success(t *testing.T) { assert.Equal(t, "http://hydra-public.test/oauth2/auth", res.Endpoints.Authorization) } -func TestGetClient_NotFound(t *testing.T) { - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil - }) - - h := &DevHandler{ - Hydra: &service.HydraAdminService{ - AdminURL: "http://hydra.test", - HTTPClient: &http.Client{Transport: transport}, - }, - } - app := fiber.New() - app.Get("/api/v1/dev/clients/:id", h.GetClient) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil) - resp, _ := app.Test(req, -1) - - assert.Equal(t, http.StatusNotFound, resp.StatusCode) -} - func TestCreateClient_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPost && r.URL.Path == "/clients" { @@ -112,6 +138,7 @@ func TestCreateClient_Success(t *testing.T) { return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil }) + mockKeto := new(MockKetoService) secretRepo := &mockSecretRepo{secrets: make(map[string]string)} redisRepo := &mockRedisRepo{data: make(map[string]string)} @@ -122,13 +149,18 @@ func TestCreateClient_Success(t *testing.T) { }, SecretRepo: secretRepo, Redis: redisRepo, + Keto: mockKeto, } app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) app.Post("/api/v1/dev/clients", h.CreateClient) body, _ := json.Marshal(map[string]interface{}{ "client_name": "New App", - "type": "confidential", + "type": "private", "redirectUris": []string{"http://localhost/cb"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) diff --git a/devfront/package-lock.json b/devfront/package-lock.json index 54d01aab..934b31ac 100644 --- a/devfront/package-lock.json +++ b/devfront/package-lock.json @@ -56,9 +56,9 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { @@ -71,9 +71,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -81,21 +81,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -112,14 +112,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -242,13 +242,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -305,18 +305,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -324,9 +324,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -661,13 +661,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", - "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.0" + "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -1363,9 +1363,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, "license": "MIT" }, @@ -1380,9 +1380,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.92.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz", - "integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==", + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.93.0.tgz", + "integrity": "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==", "license": "MIT", "funding": { "type": "github", @@ -1390,9 +1390,9 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.20", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", - "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "license": "MIT", "dependencies": { "@tanstack/query-core": "5.90.20" @@ -1406,19 +1406,19 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.91.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz", - "integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==", + "version": "5.91.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.3.tgz", + "integrity": "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA==", "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.92.0" + "@tanstack/query-devtools": "5.93.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.90.14", + "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, @@ -1479,9 +1479,9 @@ } }, "node_modules/@types/node": { - "version": "24.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", - "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", "dependencies": { @@ -1489,9 +1489,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", - "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1509,16 +1509,16 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.5", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -1550,19 +1550,6 @@ "node": ">= 8" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -1577,9 +1564,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", "dev": true, "funding": [ { @@ -1598,7 +1585,7 @@ "license": "MIT", "dependencies": { "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", + "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -1614,24 +1601,27 @@ } }, "node_modules/axios": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz", - "integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", - "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -1718,9 +1708,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", "dev": true, "funding": [ { @@ -1912,9 +1902,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.278", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz", - "integrity": "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, @@ -2013,24 +2003,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2095,9 +2067,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2676,19 +2648,6 @@ "node": ">=8.6" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2812,13 +2771,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -2845,13 +2804,13 @@ } }, "node_modules/playwright": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", - "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.0" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -2864,9 +2823,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", - "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2876,21 +2835,6 @@ "node": ">=18" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3082,30 +3026,30 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.4" } }, "node_modules/react-hook-form": { - "version": "7.71.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", - "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -3196,19 +3140,6 @@ "node": ">=8.10.0" } }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3368,9 +3299,9 @@ } }, "node_modules/tailwind-merge": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", - "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "license": "MIT", "funding": { "type": "github", @@ -3465,6 +3396,37 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3638,6 +3600,52 @@ } } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index bd0b000c..626e1b7a 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -47,7 +47,7 @@ function ClientGeneralPage() { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [logoUrl, setLogoUrl] = useState(""); - const [clientType, setClientType] = useState("confidential"); + const [clientType, setClientType] = useState("private"); const [status, setStatus] = useState("active"); const [redirectUris, setRedirectUris] = useState(""); const [scopes, setScopes] = useState(() => [ @@ -157,6 +157,21 @@ function ClientGeneralPage() { } alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다.")); }, + onError: (err) => { + const errorMessage = + (err as AxiosError<{ error?: string }>).response?.data?.error ?? + (err as Error)?.message ?? + t("msg.common.unknown_error", "unknown error"); + alert( + t( + "msg.dev.clients.general.save_error", + "저장에 실패했습니다: {{error}}", + { + error: errorMessage, + }, + ), + ); + }, }); if (!isCreate && isLoading) { @@ -490,7 +505,7 @@ function ClientGeneralPage() { diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index c551eb16..7cd7ad2a 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -87,7 +87,10 @@ function ClientsPage() { const clients = data?.items || []; const totalClients = clients.length; - // TODO: Add real stats for active sessions and auth failures + const activeClients = clients.filter( + (client) => client.status === "active", + ).length; + // TODO: Replace with real session/auth-failure metrics when backend endpoints are available. type StatTone = "up" | "down" | "stable"; type StatItem = { labelKey: string; @@ -110,10 +113,10 @@ function ClientsPage() { { labelKey: "ui.dev.clients.stats.active_sessions", labelFallback: "활성 세션", - value: "-", - deltaKey: "ui.dev.clients.stats.not_impl", - deltaFallback: "Not impl", - tone: "stable" as const, + value: activeClients.toString(), + deltaKey: "ui.dev.clients.stats.realtime", + deltaFallback: "Realtime", + tone: "up" as const, }, { labelKey: "ui.dev.clients.stats.auth_failures", @@ -266,7 +269,7 @@ function ClientsPage() {
- {client.type === "confidential" ? ( + {client.type === "private" ? ( ) : ( @@ -309,16 +312,11 @@ function ClientsPage() { - {client.type === "confidential" - ? t( - "ui.dev.clients.type.confidential", - "기밀(Confidential)", - ) - : t("ui.dev.clients.type.public", "Public")} + {client.type === "private" + ? t("ui.dev.clients.type.private", "Private") + : t("ui.dev.clients.type.pkce", "PKCE")} diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index 0aa35ff0..5aa29992 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -1,7 +1,7 @@ import apiClient from "./apiClient"; export type ClientStatus = "active" | "inactive"; -export type ClientType = "confidential" | "public"; +export type ClientType = "private" | "pkce"; export type ClientSummary = { id: string; diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 7e631c5f..79dbcf4f 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -261,9 +261,9 @@ empty = "Empty" subtitle = "Subtitle" [msg.dev.clients.general.security] -confidential_help = "Confidential Help" -public_help = "Public Help" -subtitle = "Subtitle" +private_help = "Private App (Server-side): For apps that can safely store a client secret, such as Node.js or Java servers." +pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory." +subtitle = "Select application type. Security level determines authentication method." [msg.dev.clients.help] docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips." @@ -1051,8 +1051,8 @@ name = "Scope Name" delete = "Delete" [ui.dev.clients.general.security] -confidential = "Confidential" -public = "Public" +private = "Private" +pkce = "PKCE" title = "Security Settings" [ui.dev.clients.help] @@ -1085,8 +1085,8 @@ status = "Status" type = "Type" [ui.dev.clients.type] -confidential = "Confidential" -public = "Public" +private = "Private" +pkce = "PKCE" [ui.dev.dashboard] ready_badge = "devfront ready" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 3b8c67ce..a8392019 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -261,8 +261,8 @@ empty = "등록된 스코프가 없습니다." subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다." [msg.dev.clients.general.security] -confidential_help = "서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우." -public_help = "SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다." +private_help = "Private 앱 (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다." +pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다." subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다." [msg.dev.clients.help] @@ -1051,8 +1051,8 @@ name = "Scope Name" delete = "Delete" [ui.dev.clients.general.security] -confidential = "Confidential" -public = "Public" +private = "Private" +pkce = "PKCE" title = "보안 설정" [ui.dev.clients.help] @@ -1085,8 +1085,8 @@ status = "상태" type = "유형" [ui.dev.clients.type] -confidential = "기밀(Confidential)" -public = "Public" +private = "Private" +pkce = "PKCE" [ui.dev.dashboard] ready_badge = "devfront ready" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 9be91357..fb270793 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -261,8 +261,8 @@ empty = "" subtitle = "" [msg.dev.clients.general.security] -confidential_help = "" -public_help = "" +private_help = "" +pkce_help = "" subtitle = "" [msg.dev.clients.help] @@ -1063,8 +1063,8 @@ name = "" delete = "" [ui.dev.clients.general.security] -confidential = "" -public = "" +private = "" +pkce = "" title = "" [ui.dev.clients.help] @@ -1097,8 +1097,8 @@ status = "" type = "" [ui.dev.clients.type] -confidential = "" -public = "" +private = "" +pkce = "" [ui.dev.dashboard] ready_badge = "" diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts index 5e37c48f..0b2d2f09 100644 --- a/devfront/tests/clients.spec.ts +++ b/devfront/tests/clients.spec.ts @@ -48,7 +48,7 @@ test("clients page loads correctly", async ({ page }) => { { id: "client-playwright", name: "Playwright Client", - type: "confidential", + type: "private", status: "active", createdAt: new Date().toISOString(), redirectUris: ["http://localhost:5174/callback"], diff --git a/locales/en.toml b/locales/en.toml index 7e631c5f..34f59690 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -248,6 +248,7 @@ note = "Note" load_error = "Error loading client: {{error}}" loading = "Loading client..." saved = "Saved" +save_error = "Failed to save: {{error}}" [msg.dev.clients.general.identity] logo_help = "Logo Help" @@ -261,9 +262,9 @@ empty = "Empty" subtitle = "Subtitle" [msg.dev.clients.general.security] -confidential_help = "Confidential Help" -public_help = "Public Help" -subtitle = "Subtitle" +private_help = "Private App (Server-side): For apps that can safely store a client secret, such as Node.js or Java servers." +pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory." +subtitle = "Select application type. Security level determines authentication method." [msg.dev.clients.help] docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips." @@ -1051,8 +1052,8 @@ name = "Scope Name" delete = "Delete" [ui.dev.clients.general.security] -confidential = "Confidential" -public = "Public" +private = "Private" +pkce = "PKCE" title = "Security Settings" [ui.dev.clients.help] @@ -1085,8 +1086,8 @@ status = "Status" type = "Type" [ui.dev.clients.type] -confidential = "Confidential" -public = "Public" +private = "Private" +pkce = "PKCE" [ui.dev.dashboard] ready_badge = "devfront ready" diff --git a/locales/ko.toml b/locales/ko.toml index 3b8c67ce..a26268f5 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -248,6 +248,7 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행 load_error = "Error loading client: {{error}}" loading = "Loading client..." saved = "설정이 저장되었습니다." +save_error = "저장 실패: {{error}}" [msg.dev.clients.general.identity] logo_help = "인증 화면에 표시될 PNG/SVG URL입니다." @@ -261,8 +262,8 @@ empty = "등록된 스코프가 없습니다." subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다." [msg.dev.clients.general.security] -confidential_help = "서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우." -public_help = "SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다." +private_help = "Private 앱 (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다." +pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다." subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다." [msg.dev.clients.help] @@ -1051,8 +1052,8 @@ name = "Scope Name" delete = "Delete" [ui.dev.clients.general.security] -confidential = "Confidential" -public = "Public" +private = "Private" +pkce = "PKCE" title = "보안 설정" [ui.dev.clients.help] @@ -1085,8 +1086,8 @@ status = "상태" type = "유형" [ui.dev.clients.type] -confidential = "기밀(Confidential)" -public = "Public" +private = "Private" +pkce = "PKCE" [ui.dev.dashboard] ready_badge = "devfront ready" diff --git a/locales/template.toml b/locales/template.toml index 9be91357..f11f6cbd 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -248,6 +248,7 @@ note = "" load_error = "" loading = "" saved = "" +save_error = "" [msg.dev.clients.general.identity] logo_help = "" @@ -261,8 +262,8 @@ empty = "" subtitle = "" [msg.dev.clients.general.security] -confidential_help = "" -public_help = "" +private_help = "" +pkce_help = "" subtitle = "" [msg.dev.clients.help] @@ -1063,8 +1064,8 @@ name = "" delete = "" [ui.dev.clients.general.security] -confidential = "" -public = "" +private = "" +pkce = "" title = "" [ui.dev.clients.help] @@ -1097,8 +1098,8 @@ status = "" type = "" [ui.dev.clients.type] -confidential = "" -public = "" +private = "" +pkce = "" [ui.dev.dashboard] ready_badge = ""