1
0
forked from baron/baron-sso

RP 대시보드 기능 추가

This commit is contained in:
2026-05-12 11:31:18 +09:00
parent a2a6938246
commit 3626584046
10 changed files with 1483 additions and 378 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"},

View File

@@ -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: <AppLayout />,
children: [
{ index: true, element: <Navigate to="/clients" replace /> },
{ index: true, element: <DashboardPage /> },
{ path: "clients", element: <ClientsPage /> },
{ path: "clients/new", element: <ClientGeneralPage /> },
{ path: "clients/:id", element: <ClientDetailsPage /> },

View File

@@ -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() {
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,

File diff suppressed because it is too large Load Diff

View File

@@ -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<RPUsageDailyResponse>(
"/dev/rp-usage/daily",
{
params: { days, period },
},
);
return data;
}
export async function fetchTenants(
limit = 1000,
offset = 0,

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 = ""