From 3626584046690bf690c73d41faee5b87d7b23fca Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 12 May 2026 11:31:18 +0900 Subject: [PATCH] =?UTF-8?q?RP=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 2 + backend/internal/handler/dev_handler.go | 382 ++++-- backend/internal/handler/dev_handler_test.go | 175 ++- devfront/src/app/routes.tsx | 4 +- devfront/src/components/layout/AppLayout.tsx | 8 + .../src/features/dashboard/DashboardPage.tsx | 1074 +++++++++++++---- devfront/src/lib/devApi.ts | 37 + devfront/src/locales/en.toml | 59 + devfront/src/locales/ko.toml | 59 + devfront/src/locales/template.toml | 61 +- 10 files changed, 1483 insertions(+), 378 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ab8a0142..c7fcd6d2 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -376,6 +376,7 @@ func main() { devHandler.HeadlessJWKS = headlessJWKSCache devHandler.AuditRepo = auditRepo devHandler.RPUserMetadataRepo = rpUserMetadataRepo + devHandler.RPUsageQueries = rpUsageQueryRepo tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService) userGroupHandler := handler.NewUserGroupHandler(userGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) @@ -825,6 +826,7 @@ func main() { dev.Get("/consents", devHandler.ListConsents) dev.Delete("/consents", devHandler.RevokeConsents) dev.Get("/audit-logs", devHandler.ListAuditLogs) + dev.Get("/rp-usage/daily", devHandler.GetRPUsageDaily) // [New] Developer Registration Flow dev.Post("/developer-request", devHandler.RequestDeveloperAccess) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index e64e2e61..a4562ed7 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -39,6 +39,7 @@ type DevHandler struct { TenantSvc service.TenantService DeveloperSvc *service.DeveloperService RPUserMetadataRepo repository.RPUserMetadataRepository + RPUsageQueries domain.RPUsageQueryRepository Auth interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) } @@ -94,6 +95,13 @@ type devStatsResponse struct { AuthFailures int64 `json:"auth_failures_24h"` } +type devRPUsageDailyResponse struct { + Items []domain.RPUsageDailyMetric `json:"items"` + Days int `json:"days"` + Period string `json:"period"` + TenantID string `json:"tenantId,omitempty"` +} + type clientSummary struct { ID string `json:"id"` Name string `json:"name"` @@ -369,16 +377,12 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro if role == domain.RoleSuperAdmin { return true } - - if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) { + if canAccessClientByLegacyScope(profile, summary) { return true } - clientTenantID := resolveClientTenantID(summary) - if role != domain.RoleUser && clientTenantID != "" { - if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed { - return true - } + if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) { + return true } allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view") @@ -512,6 +516,26 @@ func mergeStringSets(dst map[string]struct{}, src map[string]struct{}) map[strin return dst } +func shouldScopeDashboardToExplicitClients(role string) bool { + switch normalizeUserRole(role) { + case domain.RoleRPAdmin, domain.RoleUser: + return true + default: + return false + } +} + +func clientIDSetFromSummaries(items []clientSummary) map[string]struct{} { + ids := make(map[string]struct{}, len(items)) + for _, item := range items { + id := strings.TrimSpace(item.ID) + if id != "" { + ids[id] = struct{}{} + } + } + return ids +} + func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool { if profile == nil { return false @@ -1009,6 +1033,70 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { return allowed, nil } +func (h *DevHandler) listVisibleClientSummaries( + c *fiber.Ctx, + profile *domain.UserProfileResponse, + limit int, + offset int, +) ([]clientSummary, error) { + if profile == nil { + return nil, fiber.NewError(fiber.StatusUnauthorized, "unauthorized: authentication required") + } + role := normalizeUserRole(profile.Role) + if !isDevConsoleRoleAllowed(role) { + return nil, fiber.NewError(fiber.StatusForbidden, "forbidden") + } + + userTenantID := tenantIDFromProfile(profile) + isSuperAdmin := role == domain.RoleSuperAdmin + allowedClientIDs := managedClientIDsFromProfile(profile) + + 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 { + return nil, err + } + + items := make([]clientSummary, 0, len(clients)) + for _, client := range clients { + if isHiddenSystemClient(client) { + continue + } + + summary := h.mapClientSummary(client) + canViewByPermit := h.canViewClientByPermit(c, profile, summary) + + if summary.Type == "private" && !isAppManager && !canViewByPermit { + continue + } + + if !isSuperAdmin { + clientTenantID, _ := summary.Metadata["tenant_id"].(string) + if clientTenantID != userTenantID && !canViewByPermit { + continue + } + } + + if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 { + if _, ok := allowedClientIDs[summary.ID]; !ok && !canViewByPermit { + continue + } + } + + if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit { + continue + } + + items = append(items, h.redactClientSecretUnlessAllowed(c, profile, summary)) + } + + return items, nil +} + func extractAuthClaimsFromBearer(authHeader string) (string, string) { authHeader = strings.TrimSpace(authHeader) if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { @@ -1133,72 +1221,22 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { } profile := h.getCurrentProfile(c) - if profile == nil { - return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") - } - role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { - return errorJSON(c, fiber.StatusForbidden, "forbidden") - } - - userTenantID := tenantIDFromProfile(profile) - isSuperAdmin := role == domain.RoleSuperAdmin - allowedClientIDs := managedClientIDsFromProfile(profile) - - isAppManager, err := h.checkAppManagerPermission(c) + items, err := h.listVisibleClientSummaries(c, profile, limit, offset) 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) { - return errorJSON(c, fiber.StatusNotFound, "clients not found") - } + status := fiber.StatusInternalServerError errMsg := err.Error() - if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") { - return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.") + var fiberErr *fiber.Error + if errors.As(err, &fiberErr) { + status = fiberErr.Code + errMsg = fiberErr.Message + } else if errors.Is(err, service.ErrHydraNotFound) { + status = fiber.StatusNotFound + errMsg = "clients not found" + } else if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") { + status = fiber.StatusServiceUnavailable + errMsg = "Hydra service is unavailable. Please check if Ory Hydra is running." } - return errorJSON(c, fiber.StatusInternalServerError, errMsg) - } - - items := make([]clientSummary, 0, len(clients)) - for _, client := range clients { - if isHiddenSystemClient(client) { - continue - } - - summary := h.mapClientSummary(client) - - // 1. [Security] Filter out 'private' clients if user is not an AppManager - canViewByPermit := h.canViewClientByPermit(c, profile, summary) - - if summary.Type == "private" && !isAppManager && !canViewByPermit { - continue - } - - // 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant - if !isSuperAdmin { - clientTenantID, _ := summary.Metadata["tenant_id"].(string) - if clientTenantID != userTenantID && !canViewByPermit { - continue - } - } - - // 3. [Role Scope] RP Admin can only access managed RP IDs unless explicit Keto permit exists - if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 { - if _, ok := allowedClientIDs[summary.ID]; !ok { - if !canViewByPermit { - continue - } - } - } - - if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit { - continue - } - - items = append(items, h.redactClientSecretUnlessAllowed(c, profile, summary)) + return errorJSON(c, status, errMsg) } return c.JSON(clientListResponse{ @@ -2547,59 +2585,42 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error { func (h *DevHandler) GetStats(c *fiber.Ctx) error { h.injectTenantContextFromHeader(c) - - // [Security] Check permission - allowed, err := h.checkAppManagerPermission(c) - if err != nil { - return errorJSON(c, fiber.StatusInternalServerError, "permission check error") + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } - if !allowed { + role := normalizeUserRole(profile.Role) + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - userTenantID := "" - isSuperAdmin := false - if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { - isSuperAdmin = normalizeUserRole(profile.Role) == domain.RoleSuperAdmin - if profile.TenantID != nil { - userTenantID = *profile.TenantID - } + visibleClients, err := h.listVisibleClientSummaries(c, profile, 500, 0) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve visible clients") } - // 1. Total Clients (Tenant Scoped) - // Hydra doesn't support tenant filtering natively, so we list and filter. - // For stats, we might want to fetch a larger batch or use a cached count. - clients, err := h.Hydra.ListClients(c.Context(), 500, 0) - var totalClients int64 - if err == nil { - for _, client := range clients { - if isHiddenSystemClient(client) { - continue - } - if isSuperAdmin { - totalClients++ - continue - } - if client.Metadata != nil { - if tid, ok := client.Metadata["tenant_id"].(string); ok && tid == userTenantID { - totalClients++ - } - } - } - } + userTenantID := tenantIDFromProfile(profile) + totalClients := int64(len(visibleClients)) + visibleClientIDs := clientIDSetFromSummaries(visibleClients) // 2. Auth Failures (24h) var authFailures int64 - if h.AuditRepo != nil { - since := time.Now().Add(-24 * time.Hour) - authFailures, _ = h.AuditRepo.CountFailuresSince(c.Context(), since, userTenantID) - } - - // 3. Active Sessions (1h) var activeSessions int64 if h.AuditRepo != nil { - since := time.Now().Add(-1 * time.Hour) - activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), since, userTenantID) + failureSince := time.Now().Add(-24 * time.Hour) + sessionSince := time.Now().Add(-1 * time.Hour) + if shouldScopeDashboardToExplicitClients(role) { + authFailures, activeSessions, _ = h.countScopedDashboardAuditMetrics( + c, + userTenantID, + visibleClientIDs, + failureSince, + sessionSince, + ) + } else { + authFailures, _ = h.AuditRepo.CountFailuresSince(c.Context(), failureSince, userTenantID) + activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), sessionSince, userTenantID) + } } return c.JSON(devStatsResponse{ @@ -2609,6 +2630,75 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error { }) } +func (h *DevHandler) GetRPUsageDaily(c *fiber.Ctx) error { + h.injectTenantContextFromHeader(c) + + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") + } + role := normalizeUserRole(profile.Role) + if !isDevConsoleViewerRole(role) { + return errorJSON(c, fiber.StatusForbidden, "forbidden") + } + if h == nil || h.RPUsageQueries == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "rp usage query service unavailable") + } + + days := 14 + if raw := c.Query("days"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + days = parsed + } + } + period := normalizeRPUsagePeriod(c.Query("period")) + + visibleClients, err := h.listVisibleClientSummaries(c, profile, 500, 0) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve visible clients") + } + + allowedClientIDs := clientIDSetFromSummaries(visibleClients) + if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 { + return c.JSON(devRPUsageDailyResponse{ + Items: []domain.RPUsageDailyMetric{}, + Days: days, + Period: period, + }) + } + + tenantID := "" + if role != domain.RoleSuperAdmin { + tenantID = tenantIDFromProfile(profile) + } + + items, err := h.RPUsageQueries.FindRPUsage(c.Context(), domain.RPUsageQuery{ + Days: days, + Period: period, + TenantID: tenantID, + }) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + filtered := make([]domain.RPUsageDailyMetric, 0, len(items)) + for _, item := range items { + if role != domain.RoleSuperAdmin { + if _, ok := allowedClientIDs[strings.TrimSpace(item.ClientID)]; !ok { + continue + } + } + filtered = append(filtered, item) + } + + return c.JSON(devRPUsageDailyResponse{ + Items: filtered, + Days: days, + Period: period, + TenantID: tenantID, + }) +} + func generateRandomSecret(length int) (string, error) { bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { @@ -3262,6 +3352,74 @@ func (h *DevHandler) resolveDevTenantScope(c *fiber.Ctx) string { return "" } +func (h *DevHandler) countScopedDashboardAuditMetrics( + c *fiber.Ctx, + tenantID string, + allowedClientIDs map[string]struct{}, + failureSince, sessionSince time.Time, +) (int64, int64, error) { + if h.AuditRepo == nil || len(allowedClientIDs) == 0 { + return 0, 0, nil + } + + oldestSince := failureSince + if sessionSince.Before(oldestSince) { + oldestSince = sessionSince + } + + var failureCount int64 + activeSessions := make(map[string]struct{}) + var cursor *domain.AuditCursor + + const pageSize = 200 + const maxScan = 5000 + scanned := 0 + + for scanned < maxScan { + page, err := h.AuditRepo.FindPage(c.Context(), pageSize, cursor, tenantID) + if err != nil { + return 0, 0, err + } + if len(page) == 0 { + break + } + + stop := false + for _, logItem := range page { + scanned++ + if logItem.Timestamp.Before(oldestSince) { + stop = true + break + } + + details, _ := utils.ParseAuditDetails(logItem.Details) + clientID := strings.TrimSpace(resolveDevAuditClientID(logItem, details)) + if _, ok := allowedClientIDs[clientID]; !ok { + continue + } + + if strings.EqualFold(logItem.Status, "failure") && !logItem.Timestamp.Before(failureSince) { + failureCount++ + } + if strings.EqualFold(logItem.Status, "success") && !logItem.Timestamp.Before(sessionSince) { + sessionID := strings.TrimSpace(logItem.SessionID) + if sessionID != "" { + activeSessions[sessionID] = struct{}{} + } + } + } + + if stop || len(page) < pageSize { + break + } + + last := page[len(page)-1] + cursor = &domain.AuditCursor{Timestamp: last.Timestamp, EventID: last.EventID} + } + + return failureCount, int64(len(activeSessions)), nil +} + // ListMyTenants returns the list of tenants the current user manages or belongs to. func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error { profile, err := h.Auth.GetEnrichedProfile(c) diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 8fad0258..d9b3875c 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -942,7 +942,6 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) { }) mockKeto := new(devMockKetoService) - mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil) mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil) h := &DevHandler{ @@ -1368,6 +1367,22 @@ func TestGetStats_Success(t *testing.T) { } mockKeto := new(devMockKetoService) + mockKeto.On( + "CheckPermission", + mock.Anything, + "User:u1", + "RelyingParty", + mock.Anything, + "view", + ).Return(false, nil).Maybe() + mockKeto.On( + "ListRelations", + mock.Anything, + "RelyingParty", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return([]service.RelationTuple{}, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ @@ -1400,6 +1415,164 @@ func TestGetStats_Success(t *testing.T) { mockKeto.AssertNotCalled(t, "CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all") } +func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) { + now := time.Now() + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/clients" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + {"client_id": "client-owned", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}}, + {"client_id": "client-other", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}}, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + EventID: "evt-1", + Timestamp: now.Add(-15 * time.Minute), + SessionID: "sess-owned", + Status: "success", + EventType: "GET /api/v1/dev/clients/client-owned", + Details: `{"client_id":"client-owned","tenant_id":"tenant-a"}`, + }, + { + EventID: "evt-2", + Timestamp: now.Add(-20 * time.Minute), + Status: "failure", + EventType: "GET /api/v1/dev/clients/client-owned", + Details: `{"client_id":"client-owned","tenant_id":"tenant-a"}`, + }, + { + EventID: "evt-3", + Timestamp: now.Add(-10 * time.Minute), + SessionID: "sess-other", + Status: "success", + EventType: "GET /api/v1/dev/clients/client-other", + Details: `{"client_id":"client-other","tenant_id":"tenant-a"}`, + }, + { + EventID: "evt-4", + Timestamp: now.Add(-30 * time.Minute), + Status: "failure", + EventType: "GET /api/v1/dev/clients/client-other", + Details: `{"client_id":"client-other","tenant_id":"tenant-a"}`, + }, + }, + } + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil) + mockKeto.On( + "ListRelations", + mock.Anything, + "RelyingParty", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return([]service.RelationTuple{}, nil).Maybe() + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + AuditRepo: auditRepo, + Keto: mockKeto, + } + + app := fiber.New() + tenantID := "tenant-a" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleUser, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Get("/api/v1/dev/stats", h.GetStats) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/stats", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var res devStatsResponse + _ = json.NewDecoder(resp.Body).Decode(&res) + + assert.Equal(t, int64(1), res.TotalClients) + assert.Equal(t, int64(1), res.AuthFailures) + assert.Equal(t, int64(1), res.ActiveSessions) + mockKeto.AssertExpectations(t) +} + +func TestGetRPUsageDaily_UserScopesItemsToVisibleClients(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/clients" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + {"client_id": "client-owned", "client_name": "Owned App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}}, + {"client_id": "client-other", "client_name": "Other App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}}, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil) + mockKeto.On( + "ListRelations", + mock.Anything, + "RelyingParty", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return([]service.RelationTuple{}, nil).Maybe() + + usageRepo := &fakeRPUsageQueryRepo{ + items: []domain.RPUsageDailyMetric{ + {Date: "2026-05-12", TenantID: "tenant-a", ClientID: "client-owned", ClientName: "Owned App", LoginRequests: 3}, + {Date: "2026-05-12", TenantID: "tenant-a", ClientID: "client-other", ClientName: "Other App", LoginRequests: 9}, + }, + } + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + RPUsageQueries: usageRepo, + } + + app := fiber.New() + tenantID := "tenant-a" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleUser, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Get("/api/v1/dev/rp-usage/daily", h.GetRPUsageDaily) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/rp-usage/daily?days=14&period=day", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var res devRPUsageDailyResponse + _ = json.NewDecoder(resp.Body).Decode(&res) + + if assert.Len(t, res.Items, 1) { + assert.Equal(t, "client-owned", res.Items[0].ClientID) + } + assert.Equal(t, "tenant-a", usageRepo.query.TenantID) + mockKeto.AssertExpectations(t) +} + func TestDevHandler_NoAuditNoAction(t *testing.T) { h := &DevHandler{ Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"}, diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx index 77a8c0c7..df300892 100644 --- a/devfront/src/app/routes.tsx +++ b/devfront/src/app/routes.tsx @@ -1,5 +1,4 @@ import { - Navigate, type RouteObject, createBrowserRouter, } from "react-router-dom"; @@ -13,6 +12,7 @@ import ClientDetailsPage from "../features/clients/ClientDetailsPage"; import ClientGeneralPage from "../features/clients/ClientGeneralPage"; import ClientRelationsPage from "../features/clients/ClientRelationsPage"; import ClientsPage from "../features/clients/ClientsPage"; +import DashboardPage from "../features/dashboard/DashboardPage"; import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage"; import ProfilePage from "../features/profile/ProfilePage"; import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig"; @@ -33,7 +33,7 @@ export const devFrontRoutes: RouteObject[] = [ { element: , children: [ - { index: true, element: }, + { index: true, element: }, { path: "clients", element: }, { path: "clients/new", element: }, { path: "clients/:id", element: }, diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 8e58ddd6..da52bd17 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -3,6 +3,7 @@ import { BadgeCheck, ChevronDown, ClipboardCheck, + LayoutDashboard, LogOut, Moon, NotebookTabs, @@ -33,6 +34,12 @@ import LanguageSelector from "../common/LanguageSelector"; import { Toaster } from "../ui/toaster"; const navItems = [ + { + labelKey: "ui.dev.nav.overview", + labelFallback: "Overview", + to: "/", + icon: LayoutDashboard, + }, { labelKey: "ui.dev.nav.clients", labelFallback: "Clients", @@ -325,6 +332,7 @@ function AppLayout() { [ shellLayoutClasses.navItemBase, diff --git a/devfront/src/features/dashboard/DashboardPage.tsx b/devfront/src/features/dashboard/DashboardPage.tsx index 077c8f5d..c94ed268 100644 --- a/devfront/src/features/dashboard/DashboardPage.tsx +++ b/devfront/src/features/dashboard/DashboardPage.tsx @@ -1,292 +1,842 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; import { Activity, - ArrowRight, + AlertTriangle, BarChart3, CheckCircle2, - Database, - KeyRound, + Layers3, ShieldCheck, - Sparkles, } from "lucide-react"; +import { type ReactNode, useMemo, useState } from "react"; +import { + type ClientSummary, + fetchClients, + fetchDevRPUsageDaily, + fetchDevStats, + type RPUsageDailyMetric, + type RPUsagePeriod, +} from "../../lib/devApi"; import { t } from "../../lib/i18n"; -const guardHighlights = [ - { - titleKey: "ui.dev.dashboard.guard.policy.title", - titleFallback: "RP 정책 통제", - bodyKey: "msg.dev.dashboard.guard.policy.body", - bodyFallback: - "Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.", - metricKey: "ui.dev.dashboard.guard.policy.metric", - metricFallback: "Policy", - }, - { - titleKey: "ui.dev.dashboard.guard.consent.title", - titleFallback: "Consent 흐름", - bodyKey: "msg.dev.dashboard.guard.consent.body", - bodyFallback: - "사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.", - metricKey: "ui.dev.dashboard.guard.consent.metric", - metricFallback: "Consent", - }, - { - titleKey: "ui.dev.dashboard.guard.hydra.title", - titleFallback: "Hydra Admin", - bodyKey: "msg.dev.dashboard.guard.hydra.body", - bodyFallback: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.", - metricKey: "ui.dev.dashboard.guard.hydra.metric", - metricFallback: "Hydra", - }, +type ClientDistribution = { + activeClients: number; + headlessClients: number; + pkceClients: number; + privateClients: number; +}; + +type DailyPoint = { + date: string; + loginRequests: number; + otherRequests: number; +}; + +type SeriesSummary = { + key: string; + clientLabel: string; + loginRequests: number; + otherRequests: number; + uniqueSubjects: number; +}; + +type MultiLineSeries = { + key: string; + clientLabel: string; + color: UsageChartPalette; + points: DailyPoint[]; +}; + +type ClientFilterOption = { + id: string; + label: string; +}; + +type UsageChartPalette = { + bar: string; + line: string; + point: string; +}; + +const usageChartPalettes: UsageChartPalette[] = [ + { bar: "#7dd3fc", line: "#10b981", point: "#059669" }, + { bar: "#f9a8d4", line: "#f97316", point: "#ea580c" }, + { bar: "#c4b5fd", line: "#6366f1", point: "#4f46e5" }, + { bar: "#86efac", line: "#14b8a6", point: "#0f766e" }, + { bar: "#fdba74", line: "#ef4444", point: "#dc2626" }, + { bar: "#93c5fd", line: "#8b5cf6", point: "#7c3aed" }, ]; -const stackReadiness = [ - { - key: "msg.dev.dashboard.stack.react", - fallback: "React 19 + Vite 7, strict TS, Router v6 data router.", - }, - { - key: "msg.dev.dashboard.stack.query", - fallback: "TanStack Query 5로 RP/Consent 데이터를 캐시합니다.", - }, - { - key: "msg.dev.dashboard.stack.axios", - fallback: "Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.", - }, - { - key: "msg.dev.dashboard.stack.tailwind", - fallback: "Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.", - }, - { - key: "msg.dev.dashboard.stack.proxy", - fallback: "Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.", - }, -]; +function buildClientDistribution(clients: ClientSummary[]): ClientDistribution { + return clients.reduce( + (summary, client) => { + if (client.status === "active") { + summary.activeClients += 1; + } -const nextSteps = [ - { - key: "msg.dev.dashboard.next.rp_workflow", - fallback: "RP 등록/수정/삭제 워크플로우 추가", - }, - { - key: "msg.dev.dashboard.next.consent_filters", - fallback: "Consent 검색 필터 고도화 및 CSV 내보내기", - }, - { - key: "msg.dev.dashboard.next.audit_guard", - fallback: "권한 가드 및 감사 로그 연동", - }, -]; + if (client.metadata?.headless_login_enabled === true) { + summary.headlessClients += 1; + } + + if (client.type === "pkce") { + summary.pkceClients += 1; + } else { + summary.privateClients += 1; + } + + return summary; + }, + { + activeClients: 0, + headlessClients: 0, + pkceClients: 0, + privateClients: 0, + }, + ); +} + +function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] { + const byDate = new Map(); + for (const row of rows) { + const current = + byDate.get(row.date) ?? + ({ + date: row.date, + loginRequests: 0, + otherRequests: 0, + } satisfies DailyPoint); + current.loginRequests += row.loginRequests; + current.otherRequests += row.otherRequests; + byDate.set(row.date, current); + } + return Array.from(byDate.values()).sort((left, right) => + left.date.localeCompare(right.date), + ); +} + +function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] { + const bySeries = new Map(); + for (const row of rows) { + const key = row.clientId; + const current = + bySeries.get(key) ?? + ({ + key, + clientLabel: row.clientName || row.clientId, + loginRequests: 0, + otherRequests: 0, + uniqueSubjects: 0, + } satisfies SeriesSummary); + current.loginRequests += row.loginRequests; + current.otherRequests += row.otherRequests; + current.uniqueSubjects = Math.max(current.uniqueSubjects, row.uniqueSubjects); + bySeries.set(key, current); + } + return Array.from(bySeries.values()) + .sort( + (left, right) => + right.loginRequests + + right.otherRequests - + (left.loginRequests + left.otherRequests), + ) + .slice(0, 5); +} + +function buildMultiLineSeries(rows: RPUsageDailyMetric[]): MultiLineSeries[] { + const dates = summarizeDaily(rows).map((point) => point.date); + const byClient = new Map< + string, + { + clientLabel: string; + byDate: Map; + } + >(); + + for (const row of rows) { + const current = + byClient.get(row.clientId) ?? + { + clientLabel: row.clientName || row.clientId, + byDate: new Map(), + }; + const point = + current.byDate.get(row.date) ?? + ({ + date: row.date, + loginRequests: 0, + otherRequests: 0, + } satisfies DailyPoint); + point.loginRequests += row.loginRequests; + point.otherRequests += row.otherRequests; + current.byDate.set(row.date, point); + byClient.set(row.clientId, current); + } + + return Array.from(byClient.entries()) + .sort((left, right) => left[1].clientLabel.localeCompare(right[1].clientLabel)) + .map(([clientId, entry], index) => ({ + key: clientId, + clientLabel: entry.clientLabel, + color: usageChartPalettes[index % usageChartPalettes.length], + points: dates.map( + (date) => + entry.byDate.get(date) ?? + ({ + date, + loginRequests: 0, + otherRequests: 0, + } satisfies DailyPoint), + ), + })); +} + +function parseDateParts(date: string) { + const parts = date.split("-"); + if (parts.length === 3) { + return { + year: Number(parts[0]), + month: Number(parts[1]), + day: Number(parts[2]), + monthText: parts[1], + dayText: parts[2], + }; + } + return null; +} + +function getISOWeekNumber(year: number, month: number, day: number) { + const date = new Date(Date.UTC(year, month - 1, day)); + const dayOfWeek = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +} + +function getISOWeekThursday(year: number, month: number, day: number) { + const date = new Date(Date.UTC(year, month - 1, day)); + const dayOfWeek = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek); + return date; +} + +function formatPeriodLabel(date: string, period: RPUsagePeriod) { + const parts = parseDateParts(date); + if (!parts) { + return date; + } + if (period === "month") { + return `${parts.monthText}월`; + } + if (period === "week") { + const weekNumber = String( + getISOWeekNumber(parts.year, parts.month, parts.day), + ).padStart(2, "0"); + const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day); + const weekMonth = weekThursday.getUTCMonth() + 1; + const weekDay = weekThursday.getUTCDate(); + const weekMonthText = String(weekMonth).padStart(2, "0"); + const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7))); + return `${weekNumber}(${weekMonthText}월${weekOfMonth}주)`; + } + return `${parts.monthText}.${parts.dayText}`; +} + +function formatMetric(value: number | undefined) { + return value === undefined ? "-" : value.toLocaleString(); +} + +function OverviewMetric({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value: string; +}) { + return ( + + {icon} + {label} + {value} + + ); +} + +function RPUsageMixedChart({ + period, + rows, + palette, + multiLineSeries, +}: { + period: RPUsagePeriod; + rows: RPUsageDailyMetric[]; + palette?: UsageChartPalette; + multiLineSeries?: MultiLineSeries[]; +}) { + const colors = palette ?? usageChartPalettes[0]; + const daily = summarizeDaily(rows); + const series = summarizeSeries(rows); + const chartWidth = 720; + const chartHeight = 230; + const padX = 48; + const padTop = 32; + const padBottom = 34; + const innerWidth = chartWidth - padX * 2; + const innerHeight = chartHeight - padTop - padBottom; + const maxValue = Math.max( + 1, + ...daily.map((point) => point.loginRequests + point.otherRequests), + ...daily.map((point) => point.loginRequests), + ); + const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth; + const barWidth = Math.min(28, Math.max(10, slot * 0.42)); + const y = (value: number) => + padTop + innerHeight - (value / maxValue) * innerHeight; + const x = (index: number) => padX + slot * index + slot / 2; + const linePoints = daily + .map((point, index) => `${x(index)},${y(point.loginRequests)}`) + .join(" "); + const multiLinePoints = multiLineSeries?.map((seriesItem) => ({ + ...seriesItem, + pointsAttr: seriesItem.points + .map((point, index) => `${x(index)},${y(point.loginRequests)}`) + .join(" "), + })); + + if (daily.length === 0) { + return ( +
+ {t( + "msg.dev.dashboard.chart.empty", + "표시할 RP 이용 집계가 없습니다.", + )} +
+ ); + } + + return ( +
+
+ + {t("ui.dev.dashboard.chart.aria", "RP 요청 현황")} + + + + {t("ui.dev.dashboard.chart.other_requests", "기타 요청")} + + {!multiLinePoints || multiLinePoints.length === 0 ? ( + <> + + + {t("ui.dev.dashboard.chart.login_requests", "로그인 요청")} + + + ) : null} + + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const gridY = padTop + innerHeight * ratio; + const label = Math.round(maxValue * (1 - ratio)); + return ( + + + + {label} + + + ); + })} + {daily.map((point, index) => { + const center = x(index); + const otherHeight = (point.otherRequests / maxValue) * innerHeight; + return ( + + + + {formatPeriodLabel(point.date, period)} + + + ); + })} + {!multiLinePoints || multiLinePoints.length === 0 ? ( + <> + + {daily.map((point, index) => ( + + ))} + + ) : ( + multiLinePoints.map((seriesItem) => ( + + + {seriesItem.points.map((point, index) => ( + + ))} + + )) + )} + +
+ + {multiLinePoints && multiLinePoints.length > 0 ? ( +
+ {multiLinePoints.map((item) => ( +
+ + {item.clientLabel} +
+ ))} +
+ ) : series.length > 0 ? ( +
+ {series.map((item) => ( +
+ {item.clientLabel} + + {t("ui.dev.dashboard.chart.series", "로그인 {{login}} / 기타 {{other}} / 사용자 {{subjects}}", { + login: item.loginRequests.toLocaleString(), + other: item.otherRequests.toLocaleString(), + subjects: item.uniqueSubjects.toLocaleString(), + })} + +
+ ))} +
+ ) : null} +
+ ); +} function DashboardPage() { + const [period, setPeriod] = useState("day"); + const [selectedClientIds, setSelectedClientIds] = useState([]); + const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; + const statsQuery = useQuery({ + queryKey: ["dev-dashboard-stats"], + queryFn: fetchDevStats, + retry: false, + }); + const clientsQuery = useQuery({ + queryKey: ["dev-dashboard-clients"], + queryFn: fetchClients, + retry: false, + }); + const usageQuery = useQuery({ + queryKey: ["dev-dashboard-rp-usage", usageDays, period], + queryFn: () => + fetchDevRPUsageDaily({ + days: usageDays, + period, + }), + retry: false, + }); + + const clients = clientsQuery.data?.items ?? []; + const distribution = useMemo( + () => buildClientDistribution(clients), + [clients], + ); + const visibleClients = useMemo( + () => + [...clients] + .sort((left, right) => { + const statusCompare = (left.status || "").localeCompare( + right.status || "", + ); + if (statusCompare !== 0) { + return statusCompare; + } + return (left.name || left.id).localeCompare(right.name || right.id); + }) + .slice(0, 6), + [clients], + ); + const clientFilterOptions = useMemo( + () => + [...clients] + .map((client) => ({ + id: client.id, + label: client.name || client.id, + })) + .sort((left, right) => left.label.localeCompare(right.label)), + [clients], + ); + const stats = statsQuery.data; + const usageRows = usageQuery.data?.items ?? []; + const filteredUsageRows = useMemo(() => { + if (selectedClientIds.length === 0) { + return usageRows; + } + const selectedSet = new Set(selectedClientIds); + return usageRows.filter((row) => selectedSet.has(row.clientId)); + }, [selectedClientIds, usageRows]); + const selectedMultiLineSeries = useMemo( + () => buildMultiLineSeries(filteredUsageRows), + [filteredUsageRows], + ); + const usageError = usageQuery.error as AxiosError<{ error?: string }> | null; + const usageStatus = usageError?.response?.status; + const usageErrorMessage = + usageError?.response?.data?.error ?? usageError?.message ?? ""; + const usageErrorText = + usageStatus === 403 + ? t( + "msg.dev.dashboard.chart.forbidden", + "현재 계정에는 RP 이용 통계를 볼 권한이 없습니다.", + ) + : usageStatus === 503 + ? t( + "msg.dev.dashboard.chart.service_unavailable", + "RP 이용 통계 집계 서비스가 아직 준비되지 않았습니다.", + ) + : usageStatus === 500 + ? t( + "msg.dev.dashboard.chart.server_error", + "RP 이용 통계 조회 중 서버 오류가 발생했습니다.", + ) + : t( + "msg.dev.dashboard.chart.unavailable_with_reason", + "RP 이용 통계 API 응답을 확인할 수 없습니다. {{reason}}", + { + reason: usageErrorMessage || t("err.common.unknown", "알 수 없는 오류"), + }, + ); + const isAllClientsSelected = selectedClientIds.length === 0; + + const toggleClientSelection = (clientId: string) => { + setSelectedClientIds((current) => { + if (current.includes(clientId)) { + const next = current.filter((item) => item !== clientId); + return next; + } + return [...current, clientId]; + }); + }; + + const selectAllClients = () => { + setSelectedClientIds([]); + }; + return ( -
-
-
-
-
-
- - {t("ui.dev.dashboard.ready_badge", "devfront ready")} -
-

- {t( - "msg.dev.dashboard.hero.title_prefix", - "RP 등록 현황과 Consent 상태를", - )} - - {t("msg.dev.dashboard.hero.title_emphasis", " 하나의 화면")} - - {t("msg.dev.dashboard.hero.title_suffix", "에서 관리합니다.")} -

-

- {t( - "msg.dev.dashboard.hero.body", - "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.", - )} -

-
- - {t("ui.dev.dashboard.badge.rp_synced", "RP registry synced")} - - - {t( - "ui.dev.dashboard.badge.consent_guard", - "Consent guard ready", - )} - - - {t( - "ui.dev.dashboard.badge.policy_toggle", - "Policy toggle enabled", - )} - -
-
-
-
- - {t( - "msg.dev.dashboard.notice.dev_scope", - "RP 정책은 dev scope에서만 적용", - )} -
-
- - {t( - "msg.dev.dashboard.notice.consent_audit", - "Consent 회수는 감사 로그와 연계", - )} -
-
- - {t( - "msg.dev.dashboard.notice.hydra_health", - "Hydra Admin 상태 체크 준비", - )} -
-
-
-
- -
- {guardHighlights.map((item) => ( -
-
-
-
- {t(item.metricKey, item.metricFallback)} -
- - {t("ui.common.status.active", "active")} - -
-
-

- {t(item.titleKey, item.titleFallback)} -

-

- {t(item.bodyKey, item.bodyFallback)} -

-
-
- ))} -
- -
-
-
-
-

- {t("ui.dev.dashboard.stack.title", "Stack readiness")} -

-

- {t("ui.dev.dashboard.stack.subtitle", "Devfront baseline")} -

-
- -
-
- {stackReadiness.map((item) => ( -
- -

{t(item.key, item.fallback)}

-
- ))} -
-
- -
-

- {t("ui.dev.dashboard.next.title", "Next actions")} +

+
+
+

+ {t("ui.dev.dashboard.title", "Dashboard")} +

+

+ {t( + "msg.dev.dashboard.description", + "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다.", + )}

-

- {t("ui.dev.dashboard.next.subtitle", "Ship the RP controls")} -

-
- {nextSteps.map((item, idx) => ( -
+
+ +
+ } + label={t("ui.dev.dashboard.summary.total_clients", "총 RP 수")} + value={formatMetric(stats?.total_clients ?? clients.length)} + /> + } + label={t("ui.dev.dashboard.summary.active_clients", "활성 RP 수")} + value={formatMetric(distribution.activeClients)} + /> + } + label={t("ui.dev.dashboard.summary.active_sessions", "활성 세션 수")} + value={formatMetric(stats?.active_sessions)} + /> + } + label={t( + "ui.dev.dashboard.summary.auth_failures_24h", + "24시간 인증 실패 수", + )} + value={formatMetric(stats?.auth_failures_24h)} + /> +
+ +
+
+
+ +
+

+ {t( + "ui.dev.dashboard.chart.title", + "애플리케이션별 로그인요청/기타 요청 현황", + )} +

+

+ {t( + "msg.dev.dashboard.chart.filter_description", + "전체 또는 선택한 애플리케이션만 기준으로 그래프를 확인합니다.", + )} +

+
+
+
+ {[ + ["day", t("ui.dev.dashboard.chart.period_day", "일")], + ["week", t("ui.dev.dashboard.chart.period_week", "주")], + ["month", t("ui.dev.dashboard.chart.period_month", "월")], + ].map(([value, label]) => ( +
+ {label} + ))}
+ +
+ + {clientFilterOptions.map((client) => ( + + ))} +
+ + {usageQuery.isError ? ( +
+ {usageErrorText} +
+ ) : isAllClientsSelected ? ( + + ) : ( + + )}
-
-
-
-

- {t("ui.dev.dashboard.ops.title", "Ops board")} -

-

- {t("ui.dev.dashboard.ops.subtitle", "현재 관측")} +
+
+
+ +

+ {t( + "ui.dev.dashboard.distribution.title", + "애플리케이션 구성 요약", + )}

-
- - {t("ui.dev.dashboard.ops.tag.consent", "Consent grants")} - - - {t("ui.dev.dashboard.ops.tag.rp_status", "RP status")} - -
-
-
-
-
- - {t("ui.dev.dashboard.ops.card.rp_requests", "RP 요청 추이")} +

+ {t( + "msg.dev.dashboard.distribution.description", + "애플리케이션 유형과 headless login 사용 현황을 빠르게 확인합니다.", + )} +

+
+
+

+ {t("ui.dev.dashboard.distribution.private", "Server side App")} +

+

+ {distribution.privateClients.toLocaleString()} +

-

- {t("ui.common.status.pending", "준비 중")} -

-
-
-
- - {t( - "ui.dev.dashboard.ops.card.consent_revoked", - "Consent 회수 건수", - )} +
+

+ {t("ui.dev.dashboard.distribution.pkce", "PKCE")} +

+

+ {distribution.pkceClients.toLocaleString()} +

-

- {t("ui.common.status.pending", "준비 중")} -

-
-
-
- - {t("ui.dev.dashboard.ops.card.hydra_status", "Hydra 상태")} +
+

+ {t( + "ui.dev.dashboard.distribution.headless", + "Headless Login", + )} +

+

+ {distribution.headlessClients.toLocaleString()} +

-

- {t("ui.common.status.ok", "정상")} -

-
-

+ + +
+
+ +

+ {t("ui.dev.dashboard.recent.title", "내 애플리케이션")} +

+
+

+ {t( + "msg.dev.dashboard.recent.empty", + "현재 계정이 접근할 수 있는 RP를 확인합니다.", + )} +

+
+ {visibleClients.length === 0 ? ( +

+ {t( + "msg.dev.dashboard.recent.none", + "표시할 연동 앱이 없습니다.", + )} +

+ ) : ( + visibleClients.map((client) => ( +
+
+

+ {client.name || t("ui.dev.clients.untitled", "Untitled")} +

+

+ {client.id} +

+
+
+

+ {client.metadata?.headless_login_enabled === true + ? t( + "ui.dev.clients.type.private_headless", + "Server side App (Headless Login)", + ) + : client.type === "private" + ? t( + "ui.dev.clients.type.private", + "Server side App", + ) + : t("ui.dev.clients.type.pkce", "PKCE")} +

+

+ {client.status === "active" + ? t("ui.dev.clients.status.active", "활성") + : client.status === "inactive" + ? t("ui.dev.clients.status.inactive", "비활성") + : client.status || "-"} +

+
+
+ )) + )} +
+
+
); } diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index f1422f59..502f8f82 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -53,6 +53,27 @@ export type DevStats = { auth_failures_24h: number; }; +export type RPUsageDailyMetric = { + date: string; + tenantId: string; + tenantType: string; + tenantName?: string; + clientId: string; + clientName: string; + loginRequests: number; + otherRequests: number; + uniqueSubjects: number; +}; + +export type RPUsagePeriod = "day" | "week" | "month"; + +export type RPUsageDailyResponse = { + items: RPUsageDailyMetric[]; + days: number; + period: RPUsagePeriod; + tenantId?: string; +}; + export type DevAuditLog = { event_id: string; timestamp: string; @@ -214,6 +235,22 @@ export async function fetchDevStats() { return data; } +export async function fetchDevRPUsageDaily({ + days = 14, + period = "day", +}: { + days?: number; + period?: RPUsagePeriod; +} = {}) { + const { data } = await apiClient.get( + "/dev/rp-usage/daily", + { + params: { days, period }, + }, + ); + return data; +} + export async function fetchTenants( limit = 1000, offset = 0, diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index f81818bc..33cbe458 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -500,6 +500,7 @@ openid = "Openid" profile = "Profile" [msg.dev.dashboard] +description = "View connected application composition and authentication operations metrics in one place." [msg.dev.dashboard.hero] body = "Body" @@ -507,6 +508,29 @@ title_emphasis = "Title Emphasis" title_prefix = "Title Prefix" title_suffix = "Title Suffix" +[msg.dev.dashboard.distribution] +description = "Quickly review application types and headless login usage." + +[msg.dev.dashboard.chart] +empty = "No RP usage aggregates to display." +filter_description = "View the chart for all applications or only the ones you select." +forbidden = "Your current account does not have permission to view RP usage statistics." +server_error = "A server error occurred while loading RP usage statistics." +service_unavailable = "The RP usage aggregation service is not ready yet." +unavailable = "RP usage statistics API is unavailable. The chart will appear here once aggregate data is ready." +unavailable_with_reason = "RP usage statistics API is unavailable. {{reason}}" + +[msg.dev.dashboard.quick_links] +audit = "Review RP configuration changes and operational history." +clients = "Browse registered RPs and manage their status and type." +description = "Jump directly to key operational screens." +developer_request = "Review developer access requests or submit a new one." +new_client = "Configure redirect URIs, grant types, and authentication methods." + +[msg.dev.dashboard.recent] +empty = "Review the relying parties this account can access." +none = "No connected applications to display." + [msg.dev.dashboard.notice] consent_audit = "Consent Audit" dev_scope = "Dev Scope" @@ -1252,6 +1276,7 @@ audit_logs = "Audit Logs" clients = "Connected Application" developer_request = "Developer Access Request" logout = "Logout" +overview = "Overview" [ui.dev.audit] load_more = "Load more" @@ -1629,12 +1654,32 @@ private_headless = "Server side App (Headless Login)" [ui.dev.dashboard] ready_badge = "devfront ready" +title = "Dashboard" [ui.dev.dashboard.badge] consent_guard = "Consent guard ready" +oidc = "OIDC operations" policy_toggle = "Policy toggle enabled" +registry = "RP registry" rp_synced = "RP registry synced" +[ui.dev.dashboard.distribution] +headless = "Headless Login" +pkce = "PKCE" +private = "Server side App" +title = "Application Distribution" + +[ui.dev.dashboard.chart] +aria = "RP request overview" +filter_all = "All" +login_requests = "Login requests" +other_requests = "Other requests" +period_day = "Day" +period_month = "Month" +period_week = "Week" +series = "Login {{login}} / Other {{other}} / Users {{subjects}}" +title = "Login and other requests by application" + [ui.dev.dashboard.next] subtitle = "Ship the RP controls" title = "Next actions" @@ -1652,11 +1697,25 @@ rp_requests = "Rp Requests" consent = "Consent grants" rp_status = "RP status" +[ui.dev.dashboard.quick_links] +create_button = "Create RP" +new_client = "New RP" +title = "Quick links" + +[ui.dev.dashboard.recent] +title = "My Applications" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" title = "Stack readiness" +[ui.dev.dashboard.summary] +active_clients = "Active RPs" +active_sessions = "Active sessions" +auth_failures_24h = "24h auth failures" +total_clients = "Total RPs" + [ui.dev.header] plane = "Dev Plane" subtitle = "Manage your applications" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index c00aca4d..ba69c665 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -500,6 +500,7 @@ openid = "OIDC 인증 필수 스코프" profile = "기본 프로필 정보 접근" [msg.dev.dashboard] +description = "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다." [msg.dev.dashboard.hero] body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다." @@ -507,6 +508,29 @@ title_emphasis = " 하나의 화면" title_prefix = "RP 등록 현황과 Consent 상태를" title_suffix = "에서 관리합니다." +[msg.dev.dashboard.distribution] +description = "애플리케이션 유형과 headless login 사용 현황을 빠르게 확인합니다." + +[msg.dev.dashboard.chart] +empty = "표시할 RP 이용 집계가 없습니다." +filter_description = "전체 또는 선택한 애플리케이션만 기준으로 그래프를 확인합니다." +forbidden = "현재 계정에는 RP 이용 통계를 볼 권한이 없습니다." +server_error = "RP 이용 통계 조회 중 서버 오류가 발생했습니다." +service_unavailable = "RP 이용 통계 집계 서비스가 아직 준비되지 않았습니다." +unavailable = "RP 이용 통계 API 응답을 확인할 수 없습니다. 집계 데이터가 준비되면 이 영역에 그래프가 표시됩니다." +unavailable_with_reason = "RP 이용 통계 API 응답을 확인할 수 없습니다. {{reason}}" + +[msg.dev.dashboard.quick_links] +audit = "RP 설정 변경과 운영 이력을 확인합니다." +clients = "등록된 RP를 조회하고 상태와 유형을 관리합니다." +description = "주요 운영 화면으로 바로 이동합니다." +developer_request = "개발자 권한 신청 내역을 확인하거나 새 요청을 등록합니다." +new_client = "redirect URI, grant type, 인증 방식을 설정합니다." + +[msg.dev.dashboard.recent] +empty = "현재 계정이 접근할 수 있는 RP를 확인합니다." +none = "표시할 연동 앱이 없습니다." + [msg.dev.dashboard.notice] consent_audit = "Consent 회수는 감사 로그와 연계" dev_scope = "RP 정책은 dev scope에서만 적용" @@ -1252,6 +1276,7 @@ audit_logs = "감사 로그" clients = "연동 앱" developer_request = "개발자 권한 신청" logout = "로그아웃" +overview = "개요" [ui.dev.audit] load_more = "더 보기" @@ -1628,12 +1653,32 @@ private_headless = "Server side App (Headless Login)" [ui.dev.dashboard] ready_badge = "devfront ready" +title = "대시보드" [ui.dev.dashboard.badge] consent_guard = "Consent guard ready" +oidc = "OIDC 운영" policy_toggle = "Policy toggle enabled" +registry = "RP registry" rp_synced = "RP registry synced" +[ui.dev.dashboard.distribution] +headless = "Headless Login" +pkce = "PKCE" +private = "Server side App" +title = "애플리케이션 구성 요약" + +[ui.dev.dashboard.chart] +aria = "RP 요청 현황" +filter_all = "전체" +login_requests = "로그인 요청" +other_requests = "기타 요청" +period_day = "일" +period_month = "월" +period_week = "주" +series = "로그인 {{login}} / 기타 {{other}} / 사용자 {{subjects}}" +title = "애플리케이션별 로그인요청/기타 요청 현황" + [ui.dev.dashboard.next] subtitle = "Ship the RP controls" title = "Next actions" @@ -1651,11 +1696,25 @@ rp_requests = "RP 요청 추이" consent = "Consent grants" rp_status = "RP status" +[ui.dev.dashboard.quick_links] +create_button = "새 RP 만들기" +new_client = "새 RP 생성" +title = "빠른 이동" + +[ui.dev.dashboard.recent] +title = "내 애플리케이션" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" title = "Stack readiness" +[ui.dev.dashboard.summary] +active_clients = "활성 RP 수" +active_sessions = "활성 세션 수" +auth_failures_24h = "24시간 인증 실패 수" +total_clients = "총 RP 수" + [ui.dev.header] plane = "Dev Plane" subtitle = "Manage your applications" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 7cacce98..01e5506e 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -538,6 +538,7 @@ openid = "" profile = "" [msg.dev.dashboard] +description = "" [msg.dev.dashboard.hero] body = "" @@ -545,6 +546,29 @@ title_emphasis = "" title_prefix = "" title_suffix = "" +[msg.dev.dashboard.distribution] +description = "" + +[msg.dev.dashboard.chart] +empty = "" +filter_description = "" +forbidden = "" +server_error = "" +service_unavailable = "" +unavailable = "" +unavailable_with_reason = "" + +[msg.dev.dashboard.quick_links] +audit = "" +clients = "" +description = "" +developer_request = "" +new_client = "" + +[msg.dev.dashboard.recent] +empty = "" +none = "" + [msg.dev.dashboard.notice] consent_audit = "" dev_scope = "" @@ -1303,8 +1327,9 @@ scope_badge = "" [ui.dev.nav] audit_logs = "" clients = "" -logout = "" developer_request = "" +logout = "" +overview = "" [ui.dev.welcome] btn_request = "" @@ -1685,12 +1710,32 @@ private_headless = "" [ui.dev.dashboard] ready_badge = "" +title = "" [ui.dev.dashboard.badge] consent_guard = "" +oidc = "" policy_toggle = "" +registry = "" rp_synced = "" +[ui.dev.dashboard.distribution] +headless = "" +pkce = "" +private = "" +title = "" + +[ui.dev.dashboard.chart] +aria = "" +filter_all = "" +login_requests = "" +other_requests = "" +period_day = "" +period_month = "" +period_week = "" +series = "" +title = "" + [ui.dev.dashboard.next] subtitle = "" title = "" @@ -1708,11 +1753,25 @@ rp_requests = "" consent = "" rp_status = "" +[ui.dev.dashboard.quick_links] +create_button = "" +new_client = "" +title = "" + +[ui.dev.dashboard.recent] +title = "" + [ui.dev.dashboard.stack] notes = "" subtitle = "" title = "" +[ui.dev.dashboard.summary] +active_clients = "" +active_sessions = "" +auth_failures_24h = "" +total_clients = "" + [ui.dev.header] plane = "" subtitle = ""