From 099a8c768c2d5c321fc9dc4a672c5b241ff97535 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 26 Feb 2026 12:39:56 +0900 Subject: [PATCH 1/6] =?UTF-8?q?consent=20=EC=B2=A0=ED=9A=8C=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8B=AC=20=EB=B0=8F=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=20UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientConsentsPage.tsx | 88 +++++++++++++------ .../clients/routes/ClientFederationPage.tsx | 2 +- devfront/src/lib/devApi.ts | 11 ++- devfront/src/locales/ko.toml | 43 ++++----- 4 files changed, 96 insertions(+), 48 deletions(-) diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index e82277ca..113855d7 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -33,6 +33,8 @@ function ClientConsentsPage() { const clientId = params.id ?? ""; const [subjectInput, setSubjectInput] = useState(""); const [subject, setSubject] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const { data: clientData } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId), @@ -44,9 +46,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,6 +58,19 @@ function ClientConsentsPage() { }, }); + const handleRevoke = (sub: string) => { + if ( + window.confirm( + t( + "msg.dev.clients.consents.revoke_confirm", + "정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다.", + ), + ) + ) { + revokeMutation.mutate({ subject: sub }); + } + }; + const rows = consentsData?.items ?? []; return ( @@ -150,14 +165,18 @@ function ClientConsentsPage() { {t("ui.dev.clients.consents.status_label", "Status:")} - setStatusFilter(e.target.value)} + > + - - @@ -226,7 +245,7 @@ function ClientConsentsPage() { {t( "ui.dev.clients.consents.table.last_auth", - "Last Authenticated", + "Last Authenticated / Revoked", )} @@ -243,7 +262,10 @@ function ClientConsentsPage() { ) : ( rows.map((row) => ( - +
@@ -273,9 +295,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 +322,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" && ( + + )} )) @@ -349,7 +385,9 @@ function ClientConsentsPage() { "Active Grants", )}

- {rows.length} + + {rows.filter((r) => r.status === "active").length} + diff --git a/devfront/src/features/clients/routes/ClientFederationPage.tsx b/devfront/src/features/clients/routes/ClientFederationPage.tsx index 4d63eead..ac6c14a8 100644 --- a/devfront/src/features/clients/routes/ClientFederationPage.tsx +++ b/devfront/src/features/clients/routes/ClientFederationPage.tsx @@ -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] From c8f39c15e0cca3f703f0c176659a0d7b298a8fe1 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 26 Feb 2026 12:40:43 +0900 Subject: [PATCH 2/6] =?UTF-8?q?conent=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20soft=20delete=20=EB=B0=8F=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 10 ++++- backend/internal/handler/dev_handler.go | 40 ++++++++++++++----- .../repository/client_consent_repository.go | 13 +++--- 3 files changed, 46 insertions(+), 17 deletions(-) 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 From d60bc1d5d5f54005b2cb8a4464c72526bb530b2b Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 26 Feb 2026 13:24:50 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=95=B1=20=EB=B0=8F?= =?UTF-8?q?=20Consent=20=EB=AA=A9=EB=A1=9D=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientConsentsPage.tsx | 163 ++++++++++++------ devfront/src/features/clients/ClientsPage.tsx | 136 ++++++++++++--- .../clients/routes/ClientFederationPage.tsx | 2 +- 3 files changed, 224 insertions(+), 77 deletions(-) diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 113855d7..e7e17685 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -27,6 +27,7 @@ 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(); @@ -34,6 +35,8 @@ function ClientConsentsPage() { 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], @@ -72,6 +75,10 @@ function ClientConsentsPage() { }; 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); + }); return (
@@ -147,59 +154,105 @@ function ClientConsentsPage() { - -
-
- - +
+
+
+ + setSubjectInput(e.target.value)} + /> +
+
+
+
-
- - {t("ui.dev.clients.consents.status_label", "Status:")} - - + + {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:")} + + +
+ + +
+ )} @@ -254,14 +307,14 @@ 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) => ( 0 ? 1 : 0, - to: rows.length, + from: filteredRows.length > 0 ? 1 : 0, + to: filteredRows.length, total: rows.length, }, )} @@ -366,7 +419,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 +318,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 ac6c14a8..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"; From 5a7231eba62adc891344c47ef7f12d8ee63235e8 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 26 Feb 2026 13:25:08 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=ED=95=84=ED=84=B0=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A0=A8=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20?= =?UTF-8?q?=ED=82=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en.toml | 6 ++++++ locales/ko.toml | 6 ++++++ locales/template.toml | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/locales/en.toml b/locales/en.toml index 2430cdf3..35990817 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." @@ -1104,9 +1105,14 @@ untitled = "Untitled" admin_session = "Admin Session" tenant_selected = "Tenant Selected" +[ui.dev.clients.filter] +status_all = "All Statuses" +type_all = "All Types" + [ui.dev.clients.consents] export_csv = "Export CSV" revoke = "Revoke" +revoked_at = "Revoked: " search_placeholder = "Search Placeholder" status_all = "All Statuses" status_label = "Status:" diff --git a/locales/ko.toml b/locales/ko.toml index 3622ae6f..ee27d7db 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가 복사되었습니다." @@ -1104,9 +1105,14 @@ untitled = "Untitled" admin_session = "관리자 세션" tenant_selected = "테넌트: 선택됨" +[ui.dev.clients.filter] +status_all = "모든 상태" +type_all = "모든 유형" + [ui.dev.clients.consents] export_csv = "Export CSV" revoke = "Revoke" +revoked_at = "철회일: " search_placeholder = "사용자 ID, 이름, 이메일로 검색" status_all = "All Statuses" status_label = "Status:" diff --git a/locales/template.toml b/locales/template.toml index 69dd2124..c9190ae6 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 = "" @@ -966,9 +967,14 @@ untitled = "" admin_session = "" tenant_selected = "" +[ui.dev.clients.filter] +status_all = "" +type_all = "" + [ui.dev.clients.consents] export_csv = "" revoke = "" +revoked_at = "" search_placeholder = "" status_all = "" status_label = "" From 39062e1773136cbe8e13dbc9e6304e6e121ea5c7 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 26 Feb 2026 13:58:20 +0900 Subject: [PATCH 5/6] =?UTF-8?q?Consent=20=EB=AA=A9=EB=A1=9D=20CSV=20?= =?UTF-8?q?=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientConsentsPage.tsx | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index e7e17685..75bafe57 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -80,6 +80,56 @@ function ClientConsentsPage() { 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 (
@@ -191,7 +241,11 @@ function ClientConsentsPage() { > {t("ui.common.search", "검색")} -
@@ -455,6 +509,8 @@ function ClientConsentsPage() { {rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)} + +

{t( From 9a409689ee40f69932242215c3ac571d6941ac1f Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 26 Feb 2026 14:04:28 +0900 Subject: [PATCH 6/6] =?UTF-8?q?i18n=20=EB=88=84=EB=9D=BD=20=ED=82=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20flutter=20=EB=A6=B0=ED=8A=B8?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientConsentsPage.tsx | 2 +- devfront/src/features/clients/ClientsPage.tsx | 5 ++++- locales/en.toml | 4 ++++ locales/ko.toml | 4 ++++ locales/template.toml | 4 ++++ userfront/assets/translations/en.toml | 1 + userfront/assets/translations/ko.toml | 1 + userfront/assets/translations/template.toml | 1 + 8 files changed, 20 insertions(+), 2 deletions(-) diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 75bafe57..1f44f2f6 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -116,7 +116,7 @@ function ClientConsentsPage() { }), ].join("\n"); - const blob = new Blob(["\uFEFF" + csvContent], { + const blob = new Blob([`\uFEFF${csvContent}`], { type: "text/csv;charset=utf-8;", }); const url = URL.createObjectURL(blob); diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 2f7fe72d..4a8472cb 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -188,7 +188,10 @@ function ClientsPage() {

- {t("ui.dev.clients.badge.tenant_selected", "테넌트: 선택됨")} + {t( + "ui.dev.clients.badge.tenant_selected", + "테넌트: 선택됨", + )} {t("ui.dev.clients.badge.admin_session", "관리자 세션")} diff --git a/locales/en.toml b/locales/en.toml index 35990817..4ac4562c 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -1046,6 +1046,7 @@ previous = "Previous" qr = "QR" read_only = "Read Only" refresh = "Refresh" +reset = "Reset" requesting = "Requesting" resend = "Resend" retry = "Retry" @@ -1108,11 +1109,14 @@ 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 ee27d7db..d69953e6 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -1046,6 +1046,7 @@ previous = "Previous" qr = "QR" read_only = "읽기 전용" refresh = "새로고침" +reset = "초기화" requesting = "요청 중..." resend = "재발송" retry = "다시 시도" @@ -1108,11 +1109,14 @@ 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 c9190ae6..ecd149df 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -908,6 +908,7 @@ page_of = "" prev = "" previous = "" qr = "" +reset = "" read_only = "" refresh = "" resend = "" @@ -970,11 +971,14 @@ 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 = ""