forked from baron/baron-sso
클라이언트 대시보드 통계 실지표 연동 및 백엔드 API 구현
This commit is contained in:
@@ -625,6 +625,7 @@ func main() {
|
|||||||
|
|
||||||
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
||||||
dev := api.Group("/dev")
|
dev := api.Group("/dev")
|
||||||
|
dev.Get("/stats", devHandler.GetStats)
|
||||||
dev.Get("/clients", devHandler.ListClients)
|
dev.Get("/clients", devHandler.ListClients)
|
||||||
dev.Post("/clients", devHandler.CreateClient)
|
dev.Post("/clients", devHandler.CreateClient)
|
||||||
dev.Get("/clients/:id", devHandler.GetClient)
|
dev.Get("/clients/:id", devHandler.GetClient)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type AuditRepository interface {
|
|||||||
Create(log *AuditLog) error
|
Create(log *AuditLog) error
|
||||||
FindPage(ctx context.Context, limit int, cursor *AuditCursor) ([]AuditLog, error)
|
FindPage(ctx context.Context, limit int, cursor *AuditCursor) ([]AuditLog, error)
|
||||||
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
|
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
|
||||||
|
CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
||||||
|
CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -70,6 +72,12 @@ type devAuditListResponse struct {
|
|||||||
NextCursor string `json:"next_cursor,omitempty"`
|
NextCursor string `json:"next_cursor,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type devStatsResponse struct {
|
||||||
|
TotalClients int64 `json:"total_clients"`
|
||||||
|
ActiveSessions int64 `json:"active_sessions"`
|
||||||
|
AuthFailures int64 `json:"auth_failures_24h"`
|
||||||
|
}
|
||||||
|
|
||||||
type clientSummary struct {
|
type clientSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -153,27 +161,75 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check with Keto: System:AppManager#member
|
subject := strings.TrimSpace(profile.ID)
|
||||||
allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member")
|
if subject == "" && strings.TrimSpace(profile.Email) != "" && h.KratosAdmin != nil {
|
||||||
if err != nil {
|
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), strings.TrimSpace(profile.Email))
|
||||||
return false, err
|
if err == nil && strings.TrimSpace(resolved) != "" {
|
||||||
|
subject = strings.TrimSpace(resolved)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
slog.Info("Dev private permission evaluated by Keto", "user_id", profile.ID, "allowed", allowed)
|
if subject == "" {
|
||||||
|
slog.Warn("Dev private permission denied: missing subject in profile", "email", profile.Email)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if h.Keto == nil {
|
||||||
|
slog.Warn("Dev private permission denied: keto service unavailable")
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check with Keto: System:AppManager#member
|
||||||
|
allowed, err := h.Keto.CheckPermission(c.Context(), subject, "System", "AppManager", "member")
|
||||||
|
if err != nil {
|
||||||
|
// Fail closed for dev private endpoints: deny on permission backend error.
|
||||||
|
slog.Warn("Dev private permission check failed; denying access", "subject", subject, "error", err)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
slog.Info("Dev private permission evaluated by Keto", "subject", subject, "allowed", allowed)
|
||||||
|
|
||||||
return allowed, nil
|
return allowed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenSubject, tokenEmail := extractAuthClaimsFromBearer(c.Get("Authorization"))
|
authHeader := c.Get("Authorization")
|
||||||
|
bearerToken := extractBearerToken(authHeader)
|
||||||
|
tokenSubject, tokenEmail := extractAuthClaimsFromBearer(authHeader)
|
||||||
|
tokenRole := ""
|
||||||
|
|
||||||
|
// Fallback for OIDC access tokens that do not include full claims locally.
|
||||||
|
if bearerToken != "" && (tokenSubject == "" || tokenEmail == "") {
|
||||||
|
if info, err := h.fetchOIDCUserInfo(c.Context(), bearerToken); err == nil && info != nil {
|
||||||
|
if tokenSubject == "" {
|
||||||
|
tokenSubject = strings.TrimSpace(info.Sub)
|
||||||
|
}
|
||||||
|
if tokenEmail == "" {
|
||||||
|
tokenEmail = strings.TrimSpace(info.Email)
|
||||||
|
}
|
||||||
|
tokenRole = strings.TrimSpace(info.Role)
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Warn("Dev private permission userinfo fallback failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenRole == domain.RoleSuperAdmin {
|
||||||
|
slog.Info("Dev private permission granted by token role", "role", tokenRole)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
if isAdminEmail(tokenEmail) {
|
if isAdminEmail(tokenEmail) {
|
||||||
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
|
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
if tokenSubject == "" {
|
|
||||||
if isTrustedLocalDevfrontRequest(c) {
|
// If subject is missing, resolve it from Kratos by identifier(email) so Keto checks can still run.
|
||||||
// Local devfront fallback: allow localhost developer flow even if auth context is missing.
|
if tokenSubject == "" && tokenEmail != "" && h.KratosAdmin != nil {
|
||||||
slog.Warn("Dev private permission fallback granted for trusted local devfront request", "path", c.Path(), "origin", c.Get("Origin"))
|
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), tokenEmail)
|
||||||
return true, nil
|
if err == nil && strings.TrimSpace(resolved) != "" {
|
||||||
|
tokenSubject = strings.TrimSpace(resolved)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if tokenSubject == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if h.Keto == nil {
|
||||||
|
slog.Warn("Dev private permission denied: keto service unavailable")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +251,9 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
|||||||
// Check with Keto: System:AppManager#member
|
// Check with Keto: System:AppManager#member
|
||||||
allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member")
|
allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
// Fail closed for dev private endpoints: deny on permission backend error.
|
||||||
|
slog.Warn("Dev private permission check failed; denying access", "subject", tokenSubject, "error", err)
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed)
|
slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed)
|
||||||
|
|
||||||
@@ -247,25 +305,71 @@ func isAdminEmail(email string) bool {
|
|||||||
return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail)
|
return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool {
|
func extractBearerToken(authHeader string) string {
|
||||||
if c == nil {
|
authHeader = strings.TrimSpace(authHeader)
|
||||||
return false
|
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(authHeader[len("Bearer "):])
|
||||||
|
}
|
||||||
|
|
||||||
|
type oidcUserInfo struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) fetchOIDCUserInfo(ctx context.Context, accessToken string) (*oidcUserInfo, error) {
|
||||||
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
|
return nil, fmt.Errorf("missing access token")
|
||||||
|
}
|
||||||
|
if h.Hydra == nil || strings.TrimSpace(h.Hydra.PublicURL) == "" {
|
||||||
|
return nil, fmt.Errorf("hydra public url is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
origin := strings.ToLower(strings.TrimSpace(c.Get("Origin")))
|
endpoint := strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo"
|
||||||
referer := strings.ToLower(strings.TrimSpace(c.Get("Referer")))
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
allowedPrefixes := []string{
|
if err != nil {
|
||||||
"http://localhost:5174",
|
return nil, err
|
||||||
"https://localhost:5174",
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("userinfo failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, prefix := range allowedPrefixes {
|
var payload map[string]any
|
||||||
if strings.HasPrefix(origin, prefix) || strings.HasPrefix(referer, prefix) {
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
return true
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pick := func(keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if raw, ok := payload[key]; ok {
|
||||||
|
if value, ok := raw.(string); ok {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return &oidcUserInfo{
|
||||||
|
Sub: pick("sub"),
|
||||||
|
Email: pick("email"),
|
||||||
|
TenantID: pick("tenant_id", "tenantId"),
|
||||||
|
Role: pick("role"),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||||
@@ -279,6 +383,92 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Tenant Isolation] Get current user's tenant ID
|
||||||
|
userTenantID := ""
|
||||||
|
isSuperAdmin := false
|
||||||
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if (!ok || profile == nil) && h.Auth != nil {
|
||||||
|
enriched, _ := h.Auth.GetEnrichedProfile(c)
|
||||||
|
if enriched != nil {
|
||||||
|
profile = enriched
|
||||||
|
ok = true
|
||||||
|
c.Locals("user_profile", enriched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok && profile != nil {
|
||||||
|
if profile.TenantID != nil {
|
||||||
|
userTenantID = *profile.TenantID
|
||||||
|
}
|
||||||
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
||||||
|
} else {
|
||||||
|
// If profile resolution failed, verify bearer token via OIDC userinfo fallback.
|
||||||
|
authHeader := c.Get("Authorization")
|
||||||
|
bearerToken := extractBearerToken(authHeader)
|
||||||
|
if bearerToken == "" {
|
||||||
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, email := extractAuthClaimsFromBearer(authHeader)
|
||||||
|
if sub == "" {
|
||||||
|
info, infoErr := h.fetchOIDCUserInfo(c.Context(), bearerToken)
|
||||||
|
if infoErr != nil {
|
||||||
|
slog.Warn("ListClients userinfo fallback failed", "error", infoErr)
|
||||||
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
|
}
|
||||||
|
sub = strings.TrimSpace(info.Sub)
|
||||||
|
if email == "" {
|
||||||
|
email = strings.TrimSpace(info.Email)
|
||||||
|
}
|
||||||
|
if userTenantID == "" {
|
||||||
|
userTenantID = strings.TrimSpace(info.TenantID)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(info.Role), domain.RoleSuperAdmin) {
|
||||||
|
isSuperAdmin = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sub == "" && email == "" {
|
||||||
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.KratosAdmin != nil && (userTenantID == "" || !isSuperAdmin) {
|
||||||
|
identityID := strings.TrimSpace(sub)
|
||||||
|
if identityID == "" && email != "" {
|
||||||
|
if resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email); err == nil {
|
||||||
|
identityID = strings.TrimSpace(resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if identityID != "" {
|
||||||
|
if identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID); err == nil && identity != nil {
|
||||||
|
if userTenantID == "" {
|
||||||
|
if tid, ok := identity.Traits["tenant_id"].(string); ok {
|
||||||
|
userTenantID = strings.TrimSpace(tid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
role := ""
|
||||||
|
if rawRole, ok := identity.Traits["role"].(string); ok {
|
||||||
|
role = strings.TrimSpace(rawRole)
|
||||||
|
}
|
||||||
|
if role == domain.RoleSuperAdmin {
|
||||||
|
isSuperAdmin = true
|
||||||
|
}
|
||||||
|
profile = &domain.UserProfileResponse{
|
||||||
|
ID: identityID,
|
||||||
|
Email: email,
|
||||||
|
Role: role,
|
||||||
|
TenantID: nil,
|
||||||
|
}
|
||||||
|
if userTenantID != "" {
|
||||||
|
tid := userTenantID
|
||||||
|
profile.TenantID = &tid
|
||||||
|
}
|
||||||
|
c.Locals("user_profile", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isAppManager, err := h.checkAppManagerPermission(c)
|
isAppManager, err := h.checkAppManagerPermission(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to check app manager permission", "error", err)
|
slog.Error("Failed to check app manager permission", "error", err)
|
||||||
@@ -299,10 +489,20 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
items := make([]clientSummary, 0, len(clients))
|
items := make([]clientSummary, 0, len(clients))
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
summary := h.mapClientSummary(client)
|
summary := h.mapClientSummary(client)
|
||||||
// Filter out 'private' clients if user is not an AppManager
|
|
||||||
|
// 1. [Security] Filter out 'private' clients if user is not an AppManager
|
||||||
if summary.Type == "private" && !isAppManager {
|
if summary.Type == "private" && !isAppManager {
|
||||||
continue
|
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 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items = append(items, summary)
|
items = append(items, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +530,22 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
summary := h.mapClientSummary(*client)
|
summary := h.mapClientSummary(*client)
|
||||||
|
|
||||||
|
// [Tenant Isolation] Check if user has access to this client
|
||||||
|
isSuperAdmin := false
|
||||||
|
userTenantID := ""
|
||||||
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||||
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
||||||
|
if profile.TenantID != nil {
|
||||||
|
userTenantID = *profile.TenantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isSuperAdmin {
|
||||||
|
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
||||||
|
if clientTenantID != userTenantID {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check permission for private clients
|
// Check permission for private clients
|
||||||
if summary.Type == "private" {
|
if summary.Type == "private" {
|
||||||
isAppManager, err := h.checkAppManagerPermission(c)
|
isAppManager, err := h.checkAppManagerPermission(c)
|
||||||
@@ -374,20 +590,39 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// [Security] Check permission before patching
|
// [Security] Check permission before patching
|
||||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
summary := h.mapClientSummary(*current)
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
if summary.Type == "private" {
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
}
|
||||||
if !isAppManager {
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
}
|
||||||
}
|
|
||||||
|
summary := h.mapClientSummary(*current)
|
||||||
|
|
||||||
|
// [Tenant Isolation]
|
||||||
|
isSuperAdmin := false
|
||||||
|
userTenantID := ""
|
||||||
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||||
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
||||||
|
if profile.TenantID != nil {
|
||||||
|
userTenantID = *profile.TenantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isSuperAdmin {
|
||||||
|
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
||||||
|
if clientTenantID != userTenantID {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeStatus := ""
|
if summary.Type == "private" {
|
||||||
if current != nil {
|
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||||
beforeStatus = h.mapClientSummary(*current).Status
|
if !isAppManager {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeStatus := summary.Status
|
||||||
h.setAuditDetailsExtra(c, map[string]any{
|
h.setAuditDetailsExtra(c, map[string]any{
|
||||||
"action": "UPDATE_CLIENT_STATUS",
|
"action": "UPDATE_CLIENT_STATUS",
|
||||||
"target_id": clientID,
|
"target_id": clientID,
|
||||||
@@ -402,15 +637,12 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
|
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
|
||||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
|
||||||
}
|
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(*updated)
|
updatedSummary := h.mapClientSummary(*updated)
|
||||||
return c.JSON(clientDetailResponse{
|
return c.JSON(clientDetailResponse{
|
||||||
Client: summary,
|
Client: updatedSummary,
|
||||||
Endpoints: clientEndpoints{
|
Endpoints: clientEndpoints{
|
||||||
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
||||||
Issuer: h.Hydra.PublicURL,
|
Issuer: h.Hydra.PublicURL,
|
||||||
@@ -472,6 +704,15 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
metadata = map[string]interface{}{}
|
metadata = map[string]interface{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Tenant Isolation] Record owner information
|
||||||
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||||
|
metadata["user_id"] = profile.ID
|
||||||
|
if tenantID == "" && profile.TenantID != nil {
|
||||||
|
tenantID = *profile.TenantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if tenantID != "" {
|
if tenantID != "" {
|
||||||
metadata["tenant_id"] = tenantID
|
metadata["tenant_id"] = tenantID
|
||||||
}
|
}
|
||||||
@@ -563,6 +804,24 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentSummary := h.mapClientSummary(*current)
|
||||||
|
|
||||||
|
// [Tenant Isolation]
|
||||||
|
isSuperAdmin := false
|
||||||
|
userTenantID := ""
|
||||||
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||||
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
||||||
|
if profile.TenantID != nil {
|
||||||
|
userTenantID = *profile.TenantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isSuperAdmin {
|
||||||
|
clientTenantID, _ := currentSummary.Metadata["tenant_id"].(string)
|
||||||
|
if clientTenantID != userTenantID {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clientType := ""
|
clientType := ""
|
||||||
if req.Type != nil {
|
if req.Type != nil {
|
||||||
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
||||||
@@ -572,7 +831,6 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// [Security] Check permission for private clients (both current and new type)
|
// [Security] Check permission for private clients (both current and new type)
|
||||||
currentSummary := h.mapClientSummary(*current)
|
|
||||||
if currentSummary.Type == "private" || clientType == "private" {
|
if currentSummary.Type == "private" || clientType == "private" {
|
||||||
isAppManager, err := h.checkAppManagerPermission(c)
|
isAppManager, err := h.checkAppManagerPermission(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -641,9 +899,6 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated)
|
updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
|
||||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
|
||||||
}
|
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,15 +922,37 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Security] Check permission for private clients
|
|
||||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
summary := h.mapClientSummary(*current)
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
if summary.Type == "private" {
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
}
|
||||||
if !isAppManager {
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
}
|
||||||
}
|
|
||||||
|
summary := h.mapClientSummary(*current)
|
||||||
|
|
||||||
|
// [Tenant Isolation]
|
||||||
|
isSuperAdmin := false
|
||||||
|
userTenantID := ""
|
||||||
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||||
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
||||||
|
if profile.TenantID != nil {
|
||||||
|
userTenantID = *profile.TenantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isSuperAdmin {
|
||||||
|
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
||||||
|
if clientTenantID != userTenantID {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Security] Check permission for private clients
|
||||||
|
if summary.Type == "private" {
|
||||||
|
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||||
|
if !isAppManager {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,9 +963,6 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
|
||||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
|
||||||
}
|
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,8 +993,17 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Isolation] Get admin tenant ID from header or locals
|
// [Isolation] Get admin tenant ID from locals or header
|
||||||
adminTenantID := c.Get("X-Tenant-ID") // Assume middleware sets this or trusted in dev
|
adminTenantID := ""
|
||||||
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||||
|
if profile.Role != domain.RoleSuperAdmin && profile.TenantID != nil {
|
||||||
|
adminTenantID = *profile.TenantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if adminTenantID == "" {
|
||||||
|
adminTenantID = c.Get("X-Tenant-ID")
|
||||||
|
}
|
||||||
|
|
||||||
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||||||
|
|
||||||
var consents []domain.ClientConsentWithTenantInfo
|
var consents []domain.ClientConsentWithTenantInfo
|
||||||
@@ -735,12 +1018,6 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
subject = resolved
|
subject = resolved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single user fetch from Hydra (to get latest status) or Local DB
|
|
||||||
// Issue says: "List All", so we prefer Local DB for consistency in listing
|
|
||||||
// But for a single user, we could still use Hydra.
|
|
||||||
// Let's use Local DB to support tenant filtering even for search.
|
|
||||||
// For simplicity, we just filter the list later if search is used.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if adminTenantID != "" {
|
if adminTenantID != "" {
|
||||||
@@ -852,15 +1129,37 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Security] Check permission for private clients
|
|
||||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
summary := h.mapClientSummary(*current)
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
if summary.Type == "private" {
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
}
|
||||||
if !isAppManager {
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
}
|
||||||
}
|
|
||||||
|
summary := h.mapClientSummary(*current)
|
||||||
|
|
||||||
|
// [Tenant Isolation]
|
||||||
|
isSuperAdmin := false
|
||||||
|
userTenantID := ""
|
||||||
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||||
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
||||||
|
if profile.TenantID != nil {
|
||||||
|
userTenantID = *profile.TenantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isSuperAdmin {
|
||||||
|
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
||||||
|
if clientTenantID != userTenantID {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Security] Check permission for private clients
|
||||||
|
if summary.Type == "private" {
|
||||||
|
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||||
|
if !isAppManager {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,22 +1175,14 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to generate secret")
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to generate secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get current client to preserve other fields (already fetched above)
|
// 2. Update Hydra
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
|
||||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
|
||||||
}
|
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Update Hydra
|
|
||||||
current.ClientSecret = newSecret
|
current.ClientSecret = newSecret
|
||||||
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)
|
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Update Persistence (DB & Redis)
|
// 3. Update Persistence (DB & Redis)
|
||||||
if h.SecretRepo != nil {
|
if h.SecretRepo != nil {
|
||||||
if err := h.SecretRepo.Upsert(c.Context(), clientID, newSecret); err != nil {
|
if err := h.SecretRepo.Upsert(c.Context(), clientID, newSecret); err != nil {
|
||||||
// Log error but don't fail the request as Hydra is already updated
|
// Log error but don't fail the request as Hydra is already updated
|
||||||
@@ -904,11 +1195,11 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return the new secret
|
// Return the new secret
|
||||||
summary := h.mapClientSummary(*updated)
|
updatedSummary := h.mapClientSummary(*updated)
|
||||||
summary.ClientSecret = newSecret
|
updatedSummary.ClientSecret = newSecret
|
||||||
|
|
||||||
return c.JSON(clientDetailResponse{
|
return c.JSON(clientDetailResponse{
|
||||||
Client: summary,
|
Client: updatedSummary,
|
||||||
Endpoints: clientEndpoints{
|
Endpoints: clientEndpoints{
|
||||||
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
||||||
Issuer: h.Hydra.PublicURL,
|
Issuer: h.Hydra.PublicURL,
|
||||||
@@ -1002,6 +1293,67 @@ 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")
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
userTenantID := ""
|
||||||
|
isSuperAdmin := false
|
||||||
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||||
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
||||||
|
if profile.TenantID != nil {
|
||||||
|
userTenantID = *profile.TenantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 isSuperAdmin {
|
||||||
|
totalClients++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if client.Metadata != nil {
|
||||||
|
if tid, ok := client.Metadata["tenant_id"].(string); ok && tid == userTenantID {
|
||||||
|
totalClients++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(devStatsResponse{
|
||||||
|
TotalClients: totalClients,
|
||||||
|
ActiveSessions: activeSessions,
|
||||||
|
AuthFailures: authFailures,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -195,3 +195,44 @@ func (r *ClickHouseRepository) Ping(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
return r.conn.Ping(ctx)
|
return r.conn.Ping(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ClickHouseRepository) CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
|
query := `
|
||||||
|
SELECT count()
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE status = 'failure' AND timestamp >= ?
|
||||||
|
`
|
||||||
|
args := []any{since}
|
||||||
|
if tenantID != "" {
|
||||||
|
query += " AND JSONExtractString(details, 'tenant_id') = ?"
|
||||||
|
args = append(args, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := r.conn.QueryRow(ctx, query, args...).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count failures: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClickHouseRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
|
// We use uniqExact(session_id) to count unique sessions that had success events recently.
|
||||||
|
query := `
|
||||||
|
SELECT uniqExact(session_id)
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE status = 'success' AND timestamp >= ? AND session_id != ''
|
||||||
|
`
|
||||||
|
args := []any{since}
|
||||||
|
if tenantID != "" {
|
||||||
|
query += " AND JSONExtractString(details, 'tenant_id') = ?"
|
||||||
|
args = append(args, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := r.conn.QueryRow(ctx, query, args...).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count active sessions: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -34,15 +35,29 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { fetchClients } from "../../lib/devApi";
|
import { fetchClients, fetchDevStats } from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function ClientsPage() {
|
function ClientsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data, isLoading, error } = useQuery({
|
const auth = useAuth();
|
||||||
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading: isLoadingClients,
|
||||||
|
error: clientError,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ["clients"],
|
queryKey: ["clients"],
|
||||||
queryFn: fetchClients,
|
queryFn: fetchClients,
|
||||||
|
enabled: hasAccessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: statsData, isLoading: isLoadingStats } = useQuery({
|
||||||
|
queryKey: ["dev-stats"],
|
||||||
|
queryFn: fetchDevStats,
|
||||||
|
enabled: hasAccessToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
@@ -63,11 +78,10 @@ function ClientsPage() {
|
|||||||
return matchesSearch && matchesType && matchesStatus;
|
return matchesSearch && matchesType && matchesStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalClients = clients.length;
|
const totalClients = statsData?.total_clients ?? clients.length;
|
||||||
const activeClients = clients.filter(
|
const activeSessions = statsData?.active_sessions ?? 0;
|
||||||
(client) => client.status === "active",
|
const authFailures = statsData?.auth_failures_24h ?? 0;
|
||||||
).length;
|
|
||||||
// TODO: Replace with real session/auth-failure metrics when backend endpoints are available.
|
|
||||||
type StatTone = "up" | "down" | "stable";
|
type StatTone = "up" | "down" | "stable";
|
||||||
type StatItem = {
|
type StatItem = {
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -90,7 +104,7 @@ function ClientsPage() {
|
|||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.stats.active_sessions",
|
labelKey: "ui.dev.clients.stats.active_sessions",
|
||||||
labelFallback: "Active Sessions",
|
labelFallback: "Active Sessions",
|
||||||
value: activeClients.toString(),
|
value: activeSessions.toString(),
|
||||||
deltaKey: "ui.dev.clients.stats.realtime",
|
deltaKey: "ui.dev.clients.stats.realtime",
|
||||||
deltaFallback: "Realtime",
|
deltaFallback: "Realtime",
|
||||||
tone: "up" as const,
|
tone: "up" as const,
|
||||||
@@ -98,14 +112,16 @@ function ClientsPage() {
|
|||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.stats.auth_failures",
|
labelKey: "ui.dev.clients.stats.auth_failures",
|
||||||
labelFallback: "Auth Failures (24h)",
|
labelFallback: "Auth Failures (24h)",
|
||||||
value: "0",
|
value: authFailures.toString(),
|
||||||
deltaKey: "ui.dev.clients.stats.stable",
|
deltaKey: authFailures > 0 ? "ui.dev.clients.stats.alert" : "ui.dev.clients.stats.stable",
|
||||||
deltaFallback: "Stable",
|
deltaFallback: authFailures > 0 ? "Check Logs" : "Stable",
|
||||||
tone: "stable" as const,
|
tone: authFailures > 0 ? ("down" as const) : ("stable" as const),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLoading) {
|
const isLoading = isLoadingClients || isLoadingStats;
|
||||||
|
|
||||||
|
if (auth.isLoading || !hasAccessToken || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
{t("msg.dev.clients.loading", "Loading clients...")}
|
{t("msg.dev.clients.loading", "Loading clients...")}
|
||||||
@@ -113,10 +129,10 @@ function ClientsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (clientError) {
|
||||||
const errMsg =
|
const errMsg =
|
||||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
(clientError as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||||
(error as Error).message;
|
(clientError as Error).message;
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-red-500">
|
<div className="p-8 text-center text-red-500">
|
||||||
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
|
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
|
||||||
@@ -268,7 +284,13 @@ function ClientsPage() {
|
|||||||
<div className="mt-1 flex items-baseline gap-2">
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
<span className="text-3xl font-bold">{item.value}</span>
|
<span className="text-3xl font-bold">{item.value}</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={item.tone === "up" ? "success" : "muted"}
|
variant={
|
||||||
|
item.tone === "up"
|
||||||
|
? "success"
|
||||||
|
: item.tone === "down"
|
||||||
|
? "destructive"
|
||||||
|
: "muted"
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2",
|
"px-2",
|
||||||
item.tone === "stable" && "bg-muted/40 text-foreground",
|
item.tone === "stable" && "bg-muted/40 text-foreground",
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export type ClientListResponse = {
|
|||||||
offset: number;
|
offset: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DevStats = {
|
||||||
|
total_clients: number;
|
||||||
|
active_sessions: number;
|
||||||
|
auth_failures_24h: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type DevAuditLog = {
|
export type DevAuditLog = {
|
||||||
event_id: string;
|
event_id: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -121,6 +127,11 @@ export async function fetchClients() {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDevStats() {
|
||||||
|
const { data } = await apiClient.get<DevStats>("/dev/stats");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchClient(clientId: string) {
|
export async function fetchClient(clientId: string) {
|
||||||
const { data } = await apiClient.get<ClientDetailResponse>(
|
const { data } = await apiClient.get<ClientDetailResponse>(
|
||||||
`/dev/clients/${clientId}`,
|
`/dev/clients/${clientId}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user