forked from baron/baron-sso
RP 대시보드 기능 추가
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
Reference in New Issue
Block a user