forked from baron/baron-sso
RP 대시보드 기능 추가
This commit is contained in:
@@ -376,6 +376,7 @@ func main() {
|
|||||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||||
devHandler.AuditRepo = auditRepo
|
devHandler.AuditRepo = auditRepo
|
||||||
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||||
|
devHandler.RPUsageQueries = rpUsageQueryRepo
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
|
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
|
||||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
@@ -825,6 +826,7 @@ func main() {
|
|||||||
dev.Get("/consents", devHandler.ListConsents)
|
dev.Get("/consents", devHandler.ListConsents)
|
||||||
dev.Delete("/consents", devHandler.RevokeConsents)
|
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||||
dev.Get("/audit-logs", devHandler.ListAuditLogs)
|
dev.Get("/audit-logs", devHandler.ListAuditLogs)
|
||||||
|
dev.Get("/rp-usage/daily", devHandler.GetRPUsageDaily)
|
||||||
|
|
||||||
// [New] Developer Registration Flow
|
// [New] Developer Registration Flow
|
||||||
dev.Post("/developer-request", devHandler.RequestDeveloperAccess)
|
dev.Post("/developer-request", devHandler.RequestDeveloperAccess)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type DevHandler struct {
|
|||||||
TenantSvc service.TenantService
|
TenantSvc service.TenantService
|
||||||
DeveloperSvc *service.DeveloperService
|
DeveloperSvc *service.DeveloperService
|
||||||
RPUserMetadataRepo repository.RPUserMetadataRepository
|
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||||
|
RPUsageQueries domain.RPUsageQueryRepository
|
||||||
Auth interface {
|
Auth interface {
|
||||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||||
}
|
}
|
||||||
@@ -94,6 +95,13 @@ type devStatsResponse struct {
|
|||||||
AuthFailures int64 `json:"auth_failures_24h"`
|
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 {
|
type clientSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -369,16 +377,12 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
|
|||||||
if role == domain.RoleSuperAdmin {
|
if role == domain.RoleSuperAdmin {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if canAccessClientByLegacyScope(profile, summary) {
|
||||||
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
clientTenantID := resolveClientTenantID(summary)
|
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
|
||||||
if role != domain.RoleUser && clientTenantID != "" {
|
return true
|
||||||
if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view")
|
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
|
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 {
|
func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool {
|
||||||
if profile == nil {
|
if profile == nil {
|
||||||
return false
|
return false
|
||||||
@@ -1009,6 +1033,70 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
|||||||
return allowed, nil
|
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) {
|
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
|
||||||
authHeader = strings.TrimSpace(authHeader)
|
authHeader = strings.TrimSpace(authHeader)
|
||||||
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||||
@@ -1133,72 +1221,22 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
profile := h.getCurrentProfile(c)
|
profile := h.getCurrentProfile(c)
|
||||||
if profile == nil {
|
items, err := h.listVisibleClientSummaries(c, profile, limit, offset)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to check app manager permission", "error", err)
|
status := fiber.StatusInternalServerError
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
errMsg := err.Error()
|
errMsg := err.Error()
|
||||||
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
|
var fiberErr *fiber.Error
|
||||||
return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.")
|
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)
|
return errorJSON(c, status, 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 c.JSON(clientListResponse{
|
return c.JSON(clientListResponse{
|
||||||
@@ -2547,59 +2585,42 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
func (h *DevHandler) GetStats(c *fiber.Ctx) error {
|
func (h *DevHandler) GetStats(c *fiber.Ctx) error {
|
||||||
h.injectTenantContextFromHeader(c)
|
h.injectTenantContextFromHeader(c)
|
||||||
|
profile := h.getCurrentProfile(c)
|
||||||
// [Security] Check permission
|
if profile == nil {
|
||||||
allowed, err := h.checkAppManagerPermission(c)
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
if err != nil {
|
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
|
||||||
}
|
}
|
||||||
if !allowed {
|
role := normalizeUserRole(profile.Role)
|
||||||
|
if !isDevConsoleViewerRole(role) {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||||
}
|
}
|
||||||
|
|
||||||
userTenantID := ""
|
visibleClients, err := h.listVisibleClientSummaries(c, profile, 500, 0)
|
||||||
isSuperAdmin := false
|
if err != nil {
|
||||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve visible clients")
|
||||||
isSuperAdmin = normalizeUserRole(profile.Role) == domain.RoleSuperAdmin
|
|
||||||
if profile.TenantID != nil {
|
|
||||||
userTenantID = *profile.TenantID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Total Clients (Tenant Scoped)
|
userTenantID := tenantIDFromProfile(profile)
|
||||||
// Hydra doesn't support tenant filtering natively, so we list and filter.
|
totalClients := int64(len(visibleClients))
|
||||||
// For stats, we might want to fetch a larger batch or use a cached count.
|
visibleClientIDs := clientIDSetFromSummaries(visibleClients)
|
||||||
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++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Auth Failures (24h)
|
// 2. Auth Failures (24h)
|
||||||
var authFailures int64
|
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
|
var activeSessions int64
|
||||||
if h.AuditRepo != nil {
|
if h.AuditRepo != nil {
|
||||||
since := time.Now().Add(-1 * time.Hour)
|
failureSince := time.Now().Add(-24 * time.Hour)
|
||||||
activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), since, userTenantID)
|
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{
|
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) {
|
func generateRandomSecret(length int) (string, error) {
|
||||||
bytes := make([]byte, length)
|
bytes := make([]byte, length)
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
@@ -3262,6 +3352,74 @@ func (h *DevHandler) resolveDevTenantScope(c *fiber.Ctx) string {
|
|||||||
return ""
|
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.
|
// ListMyTenants returns the list of tenants the current user manages or belongs to.
|
||||||
func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
||||||
profile, err := h.Auth.GetEnrichedProfile(c)
|
profile, err := h.Auth.GetEnrichedProfile(c)
|
||||||
|
|||||||
@@ -942,7 +942,6 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mockKeto := new(devMockKetoService)
|
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)
|
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
||||||
|
|
||||||
h := &DevHandler{
|
h := &DevHandler{
|
||||||
@@ -1368,6 +1367,22 @@ func TestGetStats_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mockKeto := new(devMockKetoService)
|
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{
|
h := &DevHandler{
|
||||||
Hydra: &service.HydraAdminService{
|
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")
|
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) {
|
func TestDevHandler_NoAuditNoAction(t *testing.T) {
|
||||||
h := &DevHandler{
|
h := &DevHandler{
|
||||||
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Navigate,
|
|
||||||
type RouteObject,
|
type RouteObject,
|
||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
@@ -13,6 +12,7 @@ import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
|||||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||||
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
|
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
|
||||||
import ClientsPage from "../features/clients/ClientsPage";
|
import ClientsPage from "../features/clients/ClientsPage";
|
||||||
|
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||||
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
|
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
|
||||||
import ProfilePage from "../features/profile/ProfilePage";
|
import ProfilePage from "../features/profile/ProfilePage";
|
||||||
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
||||||
@@ -33,7 +33,7 @@ export const devFrontRoutes: RouteObject[] = [
|
|||||||
{
|
{
|
||||||
element: <AppLayout />,
|
element: <AppLayout />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="/clients" replace /> },
|
{ index: true, element: <DashboardPage /> },
|
||||||
{ path: "clients", element: <ClientsPage /> },
|
{ path: "clients", element: <ClientsPage /> },
|
||||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
{ path: "clients/new", element: <ClientGeneralPage /> },
|
||||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
@@ -33,6 +34,12 @@ import LanguageSelector from "../common/LanguageSelector";
|
|||||||
import { Toaster } from "../ui/toaster";
|
import { Toaster } from "../ui/toaster";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.nav.overview",
|
||||||
|
labelFallback: "Overview",
|
||||||
|
to: "/",
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.nav.clients",
|
labelKey: "ui.dev.nav.clients",
|
||||||
labelFallback: "Clients",
|
labelFallback: "Clients",
|
||||||
@@ -325,6 +332,7 @@ function AppLayout() {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
|
end={to === "/"}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
[
|
[
|
||||||
shellLayoutClasses.navItemBase,
|
shellLayoutClasses.navItemBase,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,27 @@ export type DevStats = {
|
|||||||
auth_failures_24h: number;
|
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 = {
|
export type DevAuditLog = {
|
||||||
event_id: string;
|
event_id: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -214,6 +235,22 @@ export async function fetchDevStats() {
|
|||||||
return data;
|
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(
|
export async function fetchTenants(
|
||||||
limit = 1000,
|
limit = 1000,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ openid = "Openid"
|
|||||||
profile = "Profile"
|
profile = "Profile"
|
||||||
|
|
||||||
[msg.dev.dashboard]
|
[msg.dev.dashboard]
|
||||||
|
description = "View connected application composition and authentication operations metrics in one place."
|
||||||
|
|
||||||
[msg.dev.dashboard.hero]
|
[msg.dev.dashboard.hero]
|
||||||
body = "Body"
|
body = "Body"
|
||||||
@@ -507,6 +508,29 @@ title_emphasis = "Title Emphasis"
|
|||||||
title_prefix = "Title Prefix"
|
title_prefix = "Title Prefix"
|
||||||
title_suffix = "Title Suffix"
|
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]
|
[msg.dev.dashboard.notice]
|
||||||
consent_audit = "Consent Audit"
|
consent_audit = "Consent Audit"
|
||||||
dev_scope = "Dev Scope"
|
dev_scope = "Dev Scope"
|
||||||
@@ -1252,6 +1276,7 @@ audit_logs = "Audit Logs"
|
|||||||
clients = "Connected Application"
|
clients = "Connected Application"
|
||||||
developer_request = "Developer Access Request"
|
developer_request = "Developer Access Request"
|
||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
|
overview = "Overview"
|
||||||
|
|
||||||
[ui.dev.audit]
|
[ui.dev.audit]
|
||||||
load_more = "Load more"
|
load_more = "Load more"
|
||||||
@@ -1629,12 +1654,32 @@ private_headless = "Server side App (Headless Login)"
|
|||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
title = "Dashboard"
|
||||||
|
|
||||||
[ui.dev.dashboard.badge]
|
[ui.dev.dashboard.badge]
|
||||||
consent_guard = "Consent guard ready"
|
consent_guard = "Consent guard ready"
|
||||||
|
oidc = "OIDC operations"
|
||||||
policy_toggle = "Policy toggle enabled"
|
policy_toggle = "Policy toggle enabled"
|
||||||
|
registry = "RP registry"
|
||||||
rp_synced = "RP registry synced"
|
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]
|
[ui.dev.dashboard.next]
|
||||||
subtitle = "Ship the RP controls"
|
subtitle = "Ship the RP controls"
|
||||||
title = "Next actions"
|
title = "Next actions"
|
||||||
@@ -1652,11 +1697,25 @@ rp_requests = "Rp Requests"
|
|||||||
consent = "Consent grants"
|
consent = "Consent grants"
|
||||||
rp_status = "RP status"
|
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]
|
[ui.dev.dashboard.stack]
|
||||||
notes = "Setup notes"
|
notes = "Setup notes"
|
||||||
subtitle = "Devfront baseline"
|
subtitle = "Devfront baseline"
|
||||||
title = "Stack readiness"
|
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]
|
[ui.dev.header]
|
||||||
plane = "Dev Plane"
|
plane = "Dev Plane"
|
||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ openid = "OIDC 인증 필수 스코프"
|
|||||||
profile = "기본 프로필 정보 접근"
|
profile = "기본 프로필 정보 접근"
|
||||||
|
|
||||||
[msg.dev.dashboard]
|
[msg.dev.dashboard]
|
||||||
|
description = "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다."
|
||||||
|
|
||||||
[msg.dev.dashboard.hero]
|
[msg.dev.dashboard.hero]
|
||||||
body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다."
|
body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다."
|
||||||
@@ -507,6 +508,29 @@ title_emphasis = " 하나의 화면"
|
|||||||
title_prefix = "RP 등록 현황과 Consent 상태를"
|
title_prefix = "RP 등록 현황과 Consent 상태를"
|
||||||
title_suffix = "에서 관리합니다."
|
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]
|
[msg.dev.dashboard.notice]
|
||||||
consent_audit = "Consent 회수는 감사 로그와 연계"
|
consent_audit = "Consent 회수는 감사 로그와 연계"
|
||||||
dev_scope = "RP 정책은 dev scope에서만 적용"
|
dev_scope = "RP 정책은 dev scope에서만 적용"
|
||||||
@@ -1252,6 +1276,7 @@ audit_logs = "감사 로그"
|
|||||||
clients = "연동 앱"
|
clients = "연동 앱"
|
||||||
developer_request = "개발자 권한 신청"
|
developer_request = "개발자 권한 신청"
|
||||||
logout = "로그아웃"
|
logout = "로그아웃"
|
||||||
|
overview = "개요"
|
||||||
|
|
||||||
[ui.dev.audit]
|
[ui.dev.audit]
|
||||||
load_more = "더 보기"
|
load_more = "더 보기"
|
||||||
@@ -1628,12 +1653,32 @@ private_headless = "Server side App (Headless Login)"
|
|||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
title = "대시보드"
|
||||||
|
|
||||||
[ui.dev.dashboard.badge]
|
[ui.dev.dashboard.badge]
|
||||||
consent_guard = "Consent guard ready"
|
consent_guard = "Consent guard ready"
|
||||||
|
oidc = "OIDC 운영"
|
||||||
policy_toggle = "Policy toggle enabled"
|
policy_toggle = "Policy toggle enabled"
|
||||||
|
registry = "RP registry"
|
||||||
rp_synced = "RP registry synced"
|
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]
|
[ui.dev.dashboard.next]
|
||||||
subtitle = "Ship the RP controls"
|
subtitle = "Ship the RP controls"
|
||||||
title = "Next actions"
|
title = "Next actions"
|
||||||
@@ -1651,11 +1696,25 @@ rp_requests = "RP 요청 추이"
|
|||||||
consent = "Consent grants"
|
consent = "Consent grants"
|
||||||
rp_status = "RP status"
|
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]
|
[ui.dev.dashboard.stack]
|
||||||
notes = "Setup notes"
|
notes = "Setup notes"
|
||||||
subtitle = "Devfront baseline"
|
subtitle = "Devfront baseline"
|
||||||
title = "Stack readiness"
|
title = "Stack readiness"
|
||||||
|
|
||||||
|
[ui.dev.dashboard.summary]
|
||||||
|
active_clients = "활성 RP 수"
|
||||||
|
active_sessions = "활성 세션 수"
|
||||||
|
auth_failures_24h = "24시간 인증 실패 수"
|
||||||
|
total_clients = "총 RP 수"
|
||||||
|
|
||||||
[ui.dev.header]
|
[ui.dev.header]
|
||||||
plane = "Dev Plane"
|
plane = "Dev Plane"
|
||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|||||||
@@ -538,6 +538,7 @@ openid = ""
|
|||||||
profile = ""
|
profile = ""
|
||||||
|
|
||||||
[msg.dev.dashboard]
|
[msg.dev.dashboard]
|
||||||
|
description = ""
|
||||||
|
|
||||||
[msg.dev.dashboard.hero]
|
[msg.dev.dashboard.hero]
|
||||||
body = ""
|
body = ""
|
||||||
@@ -545,6 +546,29 @@ title_emphasis = ""
|
|||||||
title_prefix = ""
|
title_prefix = ""
|
||||||
title_suffix = ""
|
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]
|
[msg.dev.dashboard.notice]
|
||||||
consent_audit = ""
|
consent_audit = ""
|
||||||
dev_scope = ""
|
dev_scope = ""
|
||||||
@@ -1303,8 +1327,9 @@ scope_badge = ""
|
|||||||
[ui.dev.nav]
|
[ui.dev.nav]
|
||||||
audit_logs = ""
|
audit_logs = ""
|
||||||
clients = ""
|
clients = ""
|
||||||
logout = ""
|
|
||||||
developer_request = ""
|
developer_request = ""
|
||||||
|
logout = ""
|
||||||
|
overview = ""
|
||||||
|
|
||||||
[ui.dev.welcome]
|
[ui.dev.welcome]
|
||||||
btn_request = ""
|
btn_request = ""
|
||||||
@@ -1685,12 +1710,32 @@ private_headless = ""
|
|||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = ""
|
ready_badge = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.dev.dashboard.badge]
|
[ui.dev.dashboard.badge]
|
||||||
consent_guard = ""
|
consent_guard = ""
|
||||||
|
oidc = ""
|
||||||
policy_toggle = ""
|
policy_toggle = ""
|
||||||
|
registry = ""
|
||||||
rp_synced = ""
|
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]
|
[ui.dev.dashboard.next]
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
title = ""
|
title = ""
|
||||||
@@ -1708,11 +1753,25 @@ rp_requests = ""
|
|||||||
consent = ""
|
consent = ""
|
||||||
rp_status = ""
|
rp_status = ""
|
||||||
|
|
||||||
|
[ui.dev.dashboard.quick_links]
|
||||||
|
create_button = ""
|
||||||
|
new_client = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.dev.dashboard.recent]
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.dev.dashboard.stack]
|
[ui.dev.dashboard.stack]
|
||||||
notes = ""
|
notes = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.dev.dashboard.summary]
|
||||||
|
active_clients = ""
|
||||||
|
active_sessions = ""
|
||||||
|
auth_failures_24h = ""
|
||||||
|
total_clients = ""
|
||||||
|
|
||||||
[ui.dev.header]
|
[ui.dev.header]
|
||||||
plane = ""
|
plane = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user