diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index d45024fb..4d195458 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3423,6 +3423,12 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { continue } + // 삭제된 권한일 경우 + status := "inactive" + if dc.DeletedAt.Valid { + status = "revoked" + } + // Hydra에서 클라이언트 정보 조회 (메타데이터용) client, err := h.Hydra.GetClient(c.Context(), dc.ClientID) if err != nil { @@ -3432,7 +3438,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { linkedRpSummary: linkedRpSummary{ ID: dc.ClientID, Name: dc.ClientID, - Status: "inactive", + Status: status, Scopes: dc.GrantedScopes, }, lastAuth: dc.UpdatedAt, @@ -3458,7 +3464,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { Name: name, Logo: extractHydraClientLogo(client.Metadata), URL: clientURL, - Status: "inactive", + Status: status, Scopes: dc.GrantedScopes, }, lastAuth: dc.UpdatedAt, diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index b216001d..bbc31675 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -93,15 +93,17 @@ type clientEndpoints struct { } type consentSummary struct { - Subject string `json:"subject"` - UserName string `json:"userName,omitempty"` - ClientID string `json:"clientId"` - ClientName string `json:"clientName,omitempty"` - GrantedScopes []string `json:"grantedScopes"` - AuthenticatedAt string `json:"authenticatedAt,omitempty"` - CreatedAt time.Time `json:"createdAt"` - TenantID string `json:"tenantId,omitempty"` - TenantName string `json:"tenantName,omitempty"` + Subject string `json:"subject"` + UserName string `json:"userName,omitempty"` + ClientID string `json:"clientId"` + ClientName string `json:"clientName,omitempty"` + GrantedScopes []string `json:"grantedScopes"` + AuthenticatedAt string `json:"authenticatedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + Status string `json:"status"` + TenantID string `json:"tenantId,omitempty"` + TenantName string `json:"tenantName,omitempty"` } type consentListResponse struct { @@ -648,6 +650,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { // [Isolation] Get admin tenant ID from header or locals adminTenantID := c.Get("X-Tenant-ID") // Assume middleware sets this or trusted in dev + statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status"))) var consents []domain.ClientConsentWithTenantInfo var total int64 @@ -686,6 +689,23 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { continue } + var deletedAt *time.Time + status := "active" + if consent.DeletedAt.Valid { + deletedAt = &consent.DeletedAt.Time + status = "revoked" + } + + // Filter by status if requested + if statusFilter != "" && statusFilter != "all" { + if statusFilter == "active" && status != "active" { + continue + } + if statusFilter == "revoked" && status != "revoked" { + continue + } + } + userName := "" identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject) if err == nil && identity != nil { @@ -703,6 +723,8 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { GrantedScopes: consent.GrantedScopes, AuthenticatedAt: consent.UpdatedAt.Format(time.RFC3339), CreatedAt: consent.CreatedAt, + DeletedAt: deletedAt, + Status: status, TenantID: consent.TenantID, TenantName: consent.TenantName, }) diff --git a/backend/internal/repository/client_consent_repository.go b/backend/internal/repository/client_consent_repository.go index ee85daba..9ca6a8c5 100644 --- a/backend/internal/repository/client_consent_repository.go +++ b/backend/internal/repository/client_consent_repository.go @@ -24,11 +24,12 @@ func NewClientConsentRepository(db *gorm.DB) ClientConsentRepository { } func (r *clientConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error { - return r.db.WithContext(ctx). + return r.db.WithContext(ctx).Unscoped(). Where("client_id = ? AND subject = ?", consent.ClientID, consent.Subject). Assign(map[string]interface{}{ "granted_scopes": consent.GrantedScopes, "updated_at": gorm.Expr("NOW()"), + "deleted_at": nil, }). FirstOrCreate(consent).Error } @@ -44,13 +45,13 @@ func (r *clientConsentRepo) List(ctx context.Context, clientID string, limit, of var total int64 // Base query for counting - countQuery := r.db.WithContext(ctx).Model(&domain.ClientConsent{}).Where("client_id = ?", clientID) + countQuery := r.db.WithContext(ctx).Unscoped().Model(&domain.ClientConsent{}).Where("client_id = ?", clientID) if err := countQuery.Count(&total).Error; err != nil { return nil, 0, err } // Query for fetching data - query := r.db.WithContext(ctx). + query := r.db.WithContext(ctx).Unscoped(). Model(&domain.ClientConsent{}). Select("client_consents.*, users.tenant_id, tenants.name as tenant_name"). Joins("LEFT JOIN users ON users.id::text = client_consents.subject"). @@ -66,7 +67,7 @@ func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID var total int64 // Base query for counting - countQuery := r.db.WithContext(ctx). + countQuery := r.db.WithContext(ctx).Unscoped(). Model(&domain.ClientConsent{}). Joins("JOIN users ON users.id::text = client_consents.subject"). Where("client_consents.client_id = ? AND users.tenant_id = ?", clientID, tenantID) @@ -76,7 +77,7 @@ func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID } // Query for fetching data - query := r.db.WithContext(ctx). + query := r.db.WithContext(ctx).Unscoped(). Model(&domain.ClientConsent{}). Select("client_consents.*, users.tenant_id, tenants.name as tenant_name"). Joins("JOIN users ON users.id::text = client_consents.subject"). @@ -94,7 +95,7 @@ func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID func (r *clientConsentRepo) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) { var consents []domain.ClientConsent - err := r.db.WithContext(ctx). + err := r.db.WithContext(ctx).Unscoped(). Where("subject = ?", subject). Order("updated_at DESC"). Find(&consents).Error diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index e82277ca..1f44f2f6 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -27,12 +27,17 @@ import { } from "../../components/ui/table"; import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi"; import { t } from "../../lib/i18n"; +import { cn } from "../../lib/utils"; function ClientConsentsPage() { const params = useParams(); const clientId = params.id ?? ""; const [subjectInput, setSubjectInput] = useState(""); const [subject, setSubject] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [scopeFilter, setScopeFilter] = useState("all"); + const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); + const { data: clientData } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId), @@ -44,9 +49,9 @@ function ClientConsentsPage() { error, refetch, } = useQuery({ - queryKey: ["consents", clientId, subject], - queryFn: () => fetchConsents(subject, clientId), - enabled: clientId.length > 0, // Removed subject.length > 0 check + queryKey: ["consents", clientId, subject, statusFilter], + queryFn: () => fetchConsents(subject, clientId, statusFilter), + enabled: clientId.length > 0, }); const revokeMutation = useMutation({ mutationFn: (payload: { subject: string }) => @@ -56,7 +61,74 @@ function ClientConsentsPage() { }, }); + const handleRevoke = (sub: string) => { + if ( + window.confirm( + t( + "msg.dev.clients.consents.revoke_confirm", + "정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다.", + ), + ) + ) { + revokeMutation.mutate({ subject: sub }); + } + }; + const rows = consentsData?.items ?? []; + const allScopes = Array.from(new Set(rows.flatMap((r) => r.grantedScopes))); + const filteredRows = rows.filter((row) => { + return scopeFilter === "all" || row.grantedScopes.includes(scopeFilter); + }); + + const handleExportCSV = () => { + if (filteredRows.length === 0) return; + + const headers = [ + t("ui.dev.clients.consents.table.user", "User"), + t("ui.dev.clients.consents.table.tenant", "Tenant"), + t("ui.dev.clients.table.status", "Status"), + t("ui.dev.clients.consents.table.scopes", "Granted Scopes"), + t("ui.dev.clients.consents.table.first_granted", "First Granted"), + t( + "ui.dev.clients.consents.table.last_auth", + "Last Authenticated / Revoked", + ), + ]; + + const csvContent = [ + headers.join(","), + ...filteredRows.map((row) => { + const lastAuthRevoked = + row.status === "revoked" && row.deletedAt + ? `${t("ui.dev.clients.consents.status_revoked", "Revoked")}: ${new Date(row.deletedAt).toLocaleString()}` + : row.authenticatedAt + ? new Date(row.authenticatedAt).toLocaleString() + : "-"; + + return [ + `"${row.subject} (${row.userName || ""})"`, + `"${row.tenantName || row.tenantId || ""}"`, + `"${row.status}"`, + `"${row.grantedScopes.join(", ")}"`, + `"${new Date(row.createdAt).toLocaleString()}"`, + `"${lastAuthRevoked}"`, + ].join(","); + }), + ].join("\n"); + + const blob = new Blob([`\uFEFF${csvContent}`], { + type: "text/csv;charset=utf-8;", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + const date = new Date().toISOString().split("T")[0]; + link.setAttribute("href", url); + link.setAttribute("download", `consents_${clientId}_${date}.csv`); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; return (
@@ -132,55 +204,109 @@ function ClientConsentsPage() { - -
-
- - +
+
+
+ + setSubjectInput(e.target.value)} + /> +
+
+
+
-
- - {t("ui.dev.clients.consents.status_label", "Status:")} - - + onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)} + > + + {t( + "ui.dev.clients.consents.filters.advanced", + "Advanced Filters", + )} + + +
-
- - - -
+ + {isAdvancedFilterOpen && ( +
+
+ + {t("ui.dev.clients.consents.status_label", "Status:")} + + +
+ +
+ + {t("ui.dev.clients.consents.scope_label", "Scope:")} + + +
+ + +
+ )} @@ -226,7 +352,7 @@ function ClientConsentsPage() { {t( "ui.dev.clients.consents.table.last_auth", - "Last Authenticated", + "Last Authenticated / Revoked", )} @@ -235,15 +361,18 @@ function ClientConsentsPage() { - {rows.length === 0 && !isLoading ? ( + {filteredRows.length === 0 && !isLoading ? ( {t("msg.dev.clients.consents.empty", "No consents found.")} ) : ( - rows.map((row) => ( - + filteredRows.map((row) => ( +
@@ -273,9 +402,15 @@ function ClientConsentsPage() {
- - {t("ui.common.status.active", "Active")} - + {row.status === "active" ? ( + + {t("ui.common.status.active", "Active")} + + ) : ( + + {t("ui.dev.clients.consents.status_revoked", "Revoked")} + + )}
@@ -294,20 +429,28 @@ function ClientConsentsPage() { {new Date(row.createdAt).toLocaleString()} - {row.authenticatedAt - ? new Date(row.authenticatedAt).toLocaleString() - : "-"} + {row.status === "revoked" && row.deletedAt ? ( + + {t("ui.dev.clients.consents.revoked_at", "Revoked: ")} + {new Date(row.deletedAt).toLocaleString()} + + ) : row.authenticatedAt ? ( + new Date(row.authenticatedAt).toLocaleString() + ) : ( + "-" + )} - + {row.status === "active" && ( + + )} )) @@ -320,8 +463,8 @@ function ClientConsentsPage() { "msg.dev.clients.consents.showing", "Showing {{from}} to {{to}} of {{total}} users", { - from: rows.length > 0 ? 1 : 0, - to: rows.length, + from: filteredRows.length > 0 ? 1 : 0, + to: filteredRows.length, total: rows.length, }, )} @@ -330,7 +473,7 @@ function ClientConsentsPage() { -
-
-
- - -
-
- - {t("ui.dev.clients.badge.tenant_selected", "테넌트: 선택됨")} - - - {t("ui.dev.clients.badge.admin_session", "관리자 세션")} - +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ +
+ + {t( + "ui.dev.clients.badge.tenant_selected", + "테넌트: 선택됨", + )} + + + {t("ui.dev.clients.badge.admin_session", "관리자 세션")} + +
+
+ + {isAdvancedFilterOpen && ( +
+
+ + {t("ui.dev.clients.filter.type_label", "Type:")} + + +
+
+ + {t("ui.dev.clients.consents.status_label", "Status:")} + + +
+ +
+ )}
@@ -222,7 +321,7 @@ function ClientsPage() { - {clients.map((client) => ( + {filteredClients.map((client) => (
diff --git a/devfront/src/features/clients/routes/ClientFederationPage.tsx b/devfront/src/features/clients/routes/ClientFederationPage.tsx index 4d63eead..c61c20be 100644 --- a/devfront/src/features/clients/routes/ClientFederationPage.tsx +++ b/devfront/src/features/clients/routes/ClientFederationPage.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Plus, Trash2, Edit, Globe, Save } from "lucide-react"; +import { Edit, Globe, Plus, Save, Trash2 } from "lucide-react"; import { useState } from "react"; import { useParams } from "react-router-dom"; import { Button } from "../../../components/ui/button"; @@ -156,7 +156,7 @@ const CreateIdpModal = ({ disabled={ mutation.isPending || formData.display_name.trim() === "" || - formData.issuer_url.trim() === "" + (formData.issuer_url?.trim() ?? "") === "" } > {mutation.isPending ? ( diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index 5aa29992..b20e8cd0 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -57,6 +57,8 @@ export type ConsentSummary = { grantedScopes: string[]; authenticatedAt?: string; createdAt: string; + deletedAt?: string; + status: "active" | "revoked"; tenantId?: string; tenantName?: string; }; @@ -148,11 +150,18 @@ export async function deleteClient(clientId: string) { await apiClient.delete(`/dev/clients/${clientId}`); } -export async function fetchConsents(subject: string, clientId?: string) { +export async function fetchConsents( + subject: string, + clientId?: string, + status?: string, +) { const params: Record = { subject }; if (clientId) { params.client_id = clientId; } + if (status && status !== "all") { + params.status = status; + } const { data } = await apiClient.get("/dev/consents", { params, }); diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 35a3da00..d832d1e4 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -956,36 +956,37 @@ admin_session = "관리자 세션" tenant_selected = "테넌트: 선택됨" [ui.dev.clients.consents] -export_csv = "Export CSV" -revoke = "Revoke" +export_csv = "CSV 내보내기" +revoke = "권한 철회" +revoked_at = "철회일: " search_placeholder = "사용자 ID, 이름, 이메일로 검색" -status_all = "All Statuses" -status_label = "Status:" -status_revoked = "Revoked" -subject = "Subject" -title = "User Consent Grants" +status_all = "모든 상태" +status_label = "상태:" +status_revoked = "철회됨" +subject = "사용자 ID" +title = "사용자 동의 권한 관리" [ui.dev.clients.consents.breadcrumb] -clients = "Clients" -current = "User Consent Grants" -home = "Home" +clients = "애플리케이션" +current = "사용자 동의 권한" +home = "홈" [ui.dev.clients.consents.filters] -advanced = "Advanced Filters" +advanced = "상세 필터" [ui.dev.clients.consents.stats] -active_grants = "Active Grants" -avg_scopes = "Avg. Scopes per User" -total_scopes = "Total Scopes Issued" +active_grants = "활성 권한" +avg_scopes = "사용자당 평균 권한 수" +total_scopes = "전체 부여된 권한 수" [ui.dev.clients.consents.table] -action = "Action" -first_granted = "First Granted" -last_auth = "Last Authenticated" -scopes = "Granted Scopes" -status = "Status" -tenant = "Tenant" -user = "User" +action = "작업" +first_granted = "최초 동의" +last_auth = "최근 인증 / 철회" +scopes = "부여된 권한 (Scopes)" +status = "상태" +tenant = "테넌트" +user = "사용자" [ui.dev.clients.details] diff --git a/locales/en.toml b/locales/en.toml index 2430cdf3..4ac4562c 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -281,6 +281,7 @@ load_error = "Error loading consents: {{error}}" loading = "Loading consents..." showing = "Showing {{from}} to {{to}} of {{total}} users" subtitle = "Subtitle" +revoke_confirm = "Are you sure you want to revoke this user's permissions? After revocation, the user must consent again on next login." [msg.dev.clients.details] copy_client_id = "Client ID copied." @@ -1045,6 +1046,7 @@ previous = "Previous" qr = "QR" read_only = "Read Only" refresh = "Refresh" +reset = "Reset" requesting = "Requesting" resend = "Resend" retry = "Retry" @@ -1104,9 +1106,17 @@ untitled = "Untitled" admin_session = "Admin Session" tenant_selected = "Tenant Selected" +[ui.dev.clients.filter] +status_all = "All Statuses" +type_all = "All Types" +type_label = "Type:" + [ui.dev.clients.consents] export_csv = "Export CSV" revoke = "Revoke" +revoked_at = "Revoked: " +scope_all = "All Scopes" +scope_label = "Scope:" search_placeholder = "Search Placeholder" status_all = "All Statuses" status_label = "Status:" diff --git a/locales/ko.toml b/locales/ko.toml index 3622ae6f..d69953e6 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -281,6 +281,7 @@ load_error = "Error loading consents: {{error}}" loading = "Loading consents..." showing = "Showing {{from}} to {{to}} of {{total}} users" subtitle = "OIDC Relying Party 사용자 권한을 검토·관리합니다." +revoke_confirm = "정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다." [msg.dev.clients.details] copy_client_id = "Client ID가 복사되었습니다." @@ -1045,6 +1046,7 @@ previous = "Previous" qr = "QR" read_only = "읽기 전용" refresh = "새로고침" +reset = "초기화" requesting = "요청 중..." resend = "재발송" retry = "다시 시도" @@ -1104,9 +1106,17 @@ untitled = "Untitled" admin_session = "관리자 세션" tenant_selected = "테넌트: 선택됨" +[ui.dev.clients.filter] +status_all = "모든 상태" +type_all = "모든 유형" +type_label = "유형:" + [ui.dev.clients.consents] export_csv = "Export CSV" revoke = "Revoke" +revoked_at = "철회일: " +scope_all = "모든 권한" +scope_label = "권한:" search_placeholder = "사용자 ID, 이름, 이메일로 검색" status_all = "All Statuses" status_label = "Status:" diff --git a/locales/template.toml b/locales/template.toml index 69dd2124..ecd149df 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -222,6 +222,7 @@ load_error = "" loading = "" showing = "" subtitle = "" +revoke_confirm = "" [msg.dev.clients.details] copy_client_id = "" @@ -907,6 +908,7 @@ page_of = "" prev = "" previous = "" qr = "" +reset = "" read_only = "" refresh = "" resend = "" @@ -966,9 +968,17 @@ untitled = "" admin_session = "" tenant_selected = "" +[ui.dev.clients.filter] +status_all = "" +type_all = "" +type_label = "" + [ui.dev.clients.consents] export_csv = "" revoke = "" +revoked_at = "" +scope_all = "" +scope_label = "" search_placeholder = "" status_all = "" status_label = "" diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 4e906a09..db6738c6 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -322,6 +322,7 @@ previous = "Previous" qr = "QR" read_only = "Read Only" refresh = "Refresh" +reset = "Reset" requesting = "Requesting" resend = "Resend" retry = "Retry" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 1c180d38..ec26fe14 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -322,6 +322,7 @@ previous = "Previous" qr = "QR" read_only = "읽기 전용" refresh = "새로고침" +reset = "초기화" requesting = "요청 중..." resend = "재발송" retry = "다시 시도" diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 3911cf30..0a335640 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -312,6 +312,7 @@ page_of = "" prev = "" previous = "" qr = "" +reset = "" read_only = "" refresh = "" resend = ""