import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ChevronLeft, ChevronRight, Download, Edit3, Filter, Save, Search, ShieldHalf, X, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { PageHeader } from "../../../../common/core/components/page"; import { commonStickyTableHeaderClass, commonTableShellClass, commonTableViewportClass, } from "../../../../common/ui/table"; import { ForbiddenMessage } from "../../components/common/ForbiddenMessage"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../components/ui/table"; import { Textarea } from "../../components/ui/textarea"; import { fetchClient, fetchConsents, fetchRPUserMetadata, revokeConsent, updateRPUserMetadata, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { cn } from "../../lib/utils"; import { ClientDetailTabs } from "./ClientDetailTabs"; type RPClaimValueType = | "text" | "number" | "float" | "boolean" | "array" | "object" | "date" | "datetime"; type CustomClaimPermission = "admin_only" | "user_and_admin"; type RPClaimSchema = { key: string; value: string; valueType: RPClaimValueType; readPermission: CustomClaimPermission; writePermission: CustomClaimPermission; }; type MetadataDraftRow = { id: string; key: string; value: string; valueType: RPClaimValueType; readPermission: CustomClaimPermission; writePermission: CustomClaimPermission; schemaBacked: boolean; }; function isCustomClaimPermission( value: unknown, ): value is CustomClaimPermission { return value === "admin_only" || value === "user_and_admin"; } function readPermissionMetadata( metadata: Record | undefined, key: string, fallback: CustomClaimPermission, field: "readPermission" | "writePermission", ) { const raw = metadata?.[`${key}_permissions`]; if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return fallback; } const value = (raw as Record)[field]; return isCustomClaimPermission(value) ? value : fallback; } function metadataToDraftRows( metadata: Record | undefined, schemas: RPClaimSchema[], ): MetadataDraftRow[] { if (schemas.length > 0) { return schemas.map((schema) => ({ id: `rp-claim-${schema.key}`, key: schema.key, value: metadataValueToInputString( metadata?.[schema.key], schema.value, schema.valueType, ), valueType: schema.valueType, readPermission: readPermissionMetadata( metadata, schema.key, schema.readPermission, "readPermission", ), writePermission: readPermissionMetadata( metadata, schema.key, schema.writePermission, "writePermission", ), schemaBacked: true, })); } return Object.entries(metadata ?? {}) .filter(([key]) => !key.endsWith("_permissions")) .map(([key, value], index) => ({ id: `${key}-${index}`, key, value: metadataValueToString(value, ""), valueType: "text", readPermission: readPermissionMetadata( metadata, key, "admin_only", "readPermission", ), writePermission: readPermissionMetadata( metadata, key, "admin_only", "writePermission", ), schemaBacked: false, })); } function draftRowsToMetadata(rows: MetadataDraftRow[]) { const metadata: Record = {}; for (const row of rows) { const key = row.key.trim(); if (!key) continue; metadata[key] = draftRowValueToMetadataValue(row); metadata[`${key}_permissions`] = { readPermission: row.readPermission, writePermission: row.writePermission, }; } return metadata; } function draftRowValueToMetadataValue(row: MetadataDraftRow) { const value = row.value.trim(); switch (row.valueType) { case "number": return /^-?\d+$/.test(value) ? Number.parseInt(value, 10) : value; case "float": { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : value; } case "boolean": return value === "true"; case "array": if (value === "") return []; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : value; } catch { return value; } case "object": if (value === "") return {}; try { const parsed = JSON.parse(value); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : value; } catch { return value; } default: return value; } } function isRPClaimValueType(value: string): value is RPClaimValueType { return ( value === "text" || value === "number" || value === "float" || value === "boolean" || value === "array" || value === "object" || value === "date" || value === "datetime" ); } function metadataValueToString(value: unknown, fallback: string) { if (typeof value === "string") return value; if (value == null) return fallback; if (typeof value === "number" || typeof value === "boolean") { return String(value); } return JSON.stringify(value); } function metadataValueToInputString( value: unknown, fallback: string, valueType: RPClaimValueType, ) { const text = metadataValueToString(value, fallback); if (valueType === "date") { return text.slice(0, 10); } if (valueType === "datetime") { return text.slice(0, 16); } return text; } function readRPClaimSchemas( metadata: Record | undefined, ): RPClaimSchema[] { const rawClaims = metadata?.id_token_claims; if (!Array.isArray(rawClaims)) return []; return rawClaims .map((item) => { if (!item || typeof item !== "object") return null; const record = item as Record; if (record.namespace !== "rp_claims") return null; const key = typeof record.key === "string" ? record.key.trim() : ""; if (!key) return null; const rawValueType = typeof record.valueType === "string" ? record.valueType : "text"; const valueType = isRPClaimValueType(rawValueType) ? rawValueType : "text"; return { key, value: metadataValueToString(record.value, ""), valueType, readPermission: isCustomClaimPermission(record.readPermission) ? record.readPermission : "admin_only", writePermission: isCustomClaimPermission(record.writePermission) ? record.writePermission : "admin_only", }; }) .filter((item): item is RPClaimSchema => item !== null); } function rpClaimInputType(valueType: RPClaimValueType) { if (valueType === "date") return "date"; if (valueType === "datetime") return "datetime-local"; return "text"; } function rpClaimInputMode(valueType: RPClaimValueType) { if (valueType === "number") return "numeric"; if (valueType === "float") return "decimal"; return undefined; } function rpClaimInputPattern(valueType: RPClaimValueType) { if (valueType === "number") return "-?[0-9]*"; if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)"; return undefined; } function ClientConsentsPage() { const params = useParams(); const clientId = params.id ?? ""; const [subjectInput, setSubjectInput] = useState(""); const [subject, setSubject] = useState(""); const [statusFilter, setStatusFilter] = useState([]); const [scopeFilter, setScopeFilter] = useState([]); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [editingSubject, setEditingSubject] = useState(""); const [metadataDraftRows, setMetadataDraftRows] = useState< MetadataDraftRow[] >([]); const { data: clientData } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId), enabled: clientId.length > 0, }); const rpClaimSchemas = useMemo( () => readRPClaimSchemas(clientData?.client.metadata), [clientData?.client.metadata], ); const { data: consentsData, isLoading, error, refetch, } = useQuery({ queryKey: ["consents", clientId, subject], queryFn: () => fetchConsents(subject, clientId, "all"), enabled: clientId.length > 0, }); const rows = consentsData?.items ?? []; const revokeMutation = useMutation({ mutationFn: (payload: { subject: string }) => revokeConsent(payload.subject, clientId), onSuccess: () => { refetch(); }, }); const selectedRow = rows.find((row) => row.subject === editingSubject) ?? undefined; const metadataQuery = useQuery({ queryKey: ["rp-user-metadata", clientId, editingSubject], queryFn: () => fetchRPUserMetadata(clientId, editingSubject), enabled: clientId.length > 0 && editingSubject.length > 0, }); const metadataMutation = useMutation({ mutationFn: () => updateRPUserMetadata( clientId, editingSubject, draftRowsToMetadata(metadataDraftRows), ), onSuccess: () => { setEditingSubject(""); setMetadataDraftRows([]); refetch(); }, }); useEffect(() => { if (metadataQuery.data) { setMetadataDraftRows( metadataToDraftRows(metadataQuery.data.metadata, rpClaimSchemas), ); } }, [metadataQuery.data, rpClaimSchemas]); const handleRevoke = (sub: string) => { if ( window.confirm( t( "msg.dev.clients.consents.revoke_confirm", "정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다.", ), ) ) { revokeMutation.mutate({ subject: sub }); } }; const allScopes = Array.from(new Set(rows.flatMap((r) => r.grantedScopes))); const filteredRows = rows.filter((row) => { const matchStatus = statusFilter.length === 0 || statusFilter.includes(row.status); const matchScope = scopeFilter.length === 0 || scopeFilter.some((s) => row.grantedScopes.includes(s)); return matchStatus && matchScope; }); 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); }; const handleStatusFilterChange = (status: string, checked: boolean) => { if (checked) { setStatusFilter((prev) => [...prev, status]); } else { setStatusFilter((prev) => prev.filter((s) => s !== status)); } }; const handleScopeFilterChange = (scope: string, checked: boolean) => { if (checked) { setScopeFilter((prev) => [...prev, scope]); } else { setScopeFilter((prev) => prev.filter((s) => s !== scope)); } }; const handleAllScopesChange = (checked: boolean) => { if (checked) { setScopeFilter(allScopes); } else { setScopeFilter([]); } }; const openMetadataEditor = (row: (typeof rows)[number]) => { setEditingSubject(row.subject); setMetadataDraftRows(metadataToDraftRows(row.rpMetadata, rpClaimSchemas)); }; const updateMetadataDraftRow = ( id: string, patch: Partial, ) => { setMetadataDraftRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)), ); }; if (error) { const axiosError = error as AxiosError<{ error?: string }>; if (axiosError.response?.status === 403) { return (
} title={t( "ui.dev.clients.consents.title", "User Consent Grants", )} />
); } } return (
} title={t("ui.dev.clients.consents.title", "User Consent Grants")} description={t( "msg.dev.clients.consents.subtitle", "OIDC Relying Party 사용자 권한을 검토·관리합니다.", )} />
{clientData?.client?.status === "active" ? t("ui.common.status.active", "Active") : t("ui.common.status.inactive", "Inactive")}
setSubjectInput(e.target.value)} />
{isAdvancedFilterOpen && (
{t("ui.dev.clients.consents.status_label", "Status:")}
{t("ui.dev.clients.consents.scope_label", "Scope:")}
{allScopes.length > 0 && ( )} {allScopes.map((scope) => ( ))}
)}
{error && ( {t( "msg.dev.clients.consents.load_error", "Error loading consents: {{error}}", { error: (error as AxiosError<{ error?: string }>).response?.data ?.error ?? (error as Error).message, }, )} )} {isLoading && ( {t("msg.dev.clients.consents.loading", "Loading consents...")} )}
{t("ui.dev.clients.consents.table.user", "User")} {t("ui.dev.clients.consents.table.tenant", "Tenant")} {t("ui.dev.clients.consents.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", )} {t("ui.dev.clients.consents.table.rp_claims", "RP Claims")} {t("ui.dev.clients.consents.table.action", "Action")} {filteredRows.length === 0 && !isLoading && !error ? (

{t( "msg.dev.clients.consents.empty", "No consents found.", )}

) : ( filteredRows.map((row) => (
{(row.userName || row.subject) .slice(0, 2) .toUpperCase()}
{row.userName || t("ui.dev.clients.consents.subject", "Subject")} {row.subject}
{row.tenantName || t("ui.common.na", "N/A")} {row.tenantId}
{row.status === "active" ? ( {t("ui.common.status.active", "Active")} ) : ( {t( "ui.dev.clients.consents.status_revoked", "Revoked", )} )}
{row.grantedScopes.map((scope) => ( {scope} ))}
{new Date(row.createdAt).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.rpMetadata && Object.keys(row.rpMetadata).some( (key) => !key.endsWith("_permissions"), ) ? (
{Object.entries(row.rpMetadata) .filter(([key]) => !key.endsWith("_permissions")) .map(([key, value]) => ( {key}:{" "} {typeof value === "string" ? value : JSON.stringify(value)} ))}
) : ( {t("ui.common.na", "N/A")} )}
{row.status === "active" && ( )}
)) )}

{t( "msg.dev.clients.consents.showing", "Showing {{from}} to {{to}} of {{total}} users", { from: filteredRows.length > 0 ? 1 : 0, to: filteredRows.length, total: rows.length, }, )}

{editingSubject && (
{t( "ui.dev.clients.consents.rp_claims.title", "RP Custom Claims", )}

{selectedRow?.userName || editingSubject}

{metadataQuery.isLoading ? (

{t("msg.common.loading", "불러오는 중...")}

) : metadataDraftRows.length === 0 ? (
{t( "msg.dev.clients.consents.rp_claims.empty", "등록된 RP custom claim 값이 없습니다.", )}
) : ( metadataDraftRows.map((row) => (
{row.key}
{row.valueType === "boolean" ? ( ) : row.valueType === "array" || row.valueType === "object" ? (