import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react"; import { useDeferredValue, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useParams } from "react-router-dom"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../components/ui/table"; import { toast } from "../../components/ui/use-toast"; import { type DevAssignableUser, addClientRelation, fetchClient, fetchClientRelations, fetchDevUsers, removeClientRelation, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { ClientDetailTabs } from "./ClientDetailTabs"; const relationOptions = [ "admins", "config_editor", "secret_viewer", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", ] as const; type RelationOption = (typeof relationOptions)[number]; function relationLabel(relation: RelationOption) { return t(`ui.dev.clients.relationships.option.${relation}.label`, relation); } function relationDescription(relation: RelationOption) { return t( `ui.dev.clients.relationships.option.${relation}.description`, relation, ); } function formatUserLabel(user: DevAssignableUser) { const primary = user.name.trim() || user.email.trim(); return `${primary} (${user.email.trim()})`; } function ClientRelationsPage() { const params = useParams(); const auth = useAuth(); const queryClient = useQueryClient(); const clientId = params.id ?? ""; const [selectedRelations, setSelectedRelations] = useState( [], ); const [userSearch, setUserSearch] = useState(""); const deferredUserSearch = useDeferredValue(userSearch.trim()); const [selectedUser, setSelectedUser] = useState( null, ); const [isSearchOpen, setIsSearchOpen] = useState(false); const systemRole = resolveProfileRole( auth.user?.profile as Record | undefined, ); const { data: clientData } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId), enabled: clientId.length > 0, }); const { data: relationData, isLoading, error, } = useQuery({ queryKey: ["client-relations", clientId], queryFn: () => fetchClientRelations(clientId), enabled: clientId.length > 0, }); // Calculate permissions for UI hints and button states const isSuperAdmin = systemRole === "super_admin"; const myUserId = auth.user?.profile.sub; const isRpAdmin = useMemo(() => { if (isSuperAdmin) return true; if (!relationData?.items || !myUserId) return false; return relationData.items.some( (item) => item.subject === `User:${myUserId}` && item.relation === "admins", ); }, [relationData?.items, myUserId, isSuperAdmin]); const canManageRelations = isRpAdmin || isSuperAdmin; const isRelationshipViewForbidden = (error as AxiosError | null)?.response?.status === 403; const relationshipViewForbiddenMessage = t( "msg.dev.clients.relationships.view_forbidden", "이 RP의 관계를 조회할 권한이 없습니다. 관리자에게 관계 조회 또는 RP 관리자 관계 부여를 요청해 주세요.", ); const { data: userSearchData, isFetching: isUserSearchLoading, error: userSearchError, } = useQuery({ queryKey: ["dev-users", deferredUserSearch], queryFn: () => fetchDevUsers(deferredUserSearch, 10, clientId), enabled: clientId.length > 0 && deferredUserSearch.length > 0 && selectedUser == null && !isRelationshipViewForbidden, }); const sortedItems = useMemo(() => { return [...(relationData?.items ?? [])].sort((a, b) => { const relationCompare = a.relation.localeCompare(b.relation); if (relationCompare !== 0) { return relationCompare; } return a.subject.localeCompare(b.subject); }); }, [relationData?.items]); const selectedUserExistingRelations = useMemo(() => { if (!selectedUser) { return new Set(); } return new Set( sortedItems .filter((item) => item.subjectId === selectedUser.id) .map((item) => item.relation), ); }, [selectedUser, sortedItems]); const addMutation = useMutation({ mutationFn: async () => { if (!selectedUser) { throw new Error( t( "msg.dev.clients.relationships.user_required", "추가할 사용자를 선택하세요.", ), ); } const pendingRelations = selectedRelations.filter( (relation) => !selectedUserExistingRelations.has(relation), ); for (const relation of pendingRelations) { await addClientRelation(clientId, { relation, userId: selectedUser.id, }); } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["client-relations", clientId], }); setSelectedRelations([]); setSelectedUser(null); setUserSearch(""); setIsSearchOpen(false); toast( t( "msg.dev.clients.relationships.added", "Relationship가 추가되었습니다.", ), ); }, onError: (err) => { toast( t( "msg.dev.clients.relationships.add_error", "Relationship 추가 실패: {{error}}", { error: (err as AxiosError<{ error?: string }>).response?.data?.error ?? (err as Error).message, }, ), "error", ); }, }); const removeMutation = useMutation({ mutationFn: (payload: { relation: string; subject: string }) => removeClientRelation(clientId, payload.relation, payload.subject), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["client-relations", clientId], }); toast( t( "msg.dev.clients.relationships.removed", "Relationship가 제거되었습니다.", ), ); }, onError: (err) => { toast( t( "msg.dev.clients.relationships.remove_error", "Relationship 제거 실패: {{error}}", { error: (err as AxiosError<{ error?: string }>).response?.data?.error ?? (err as Error).message, }, ), "error", ); }, }); const handleAdd = () => { if (!canManageRelations) { toast( t( "msg.dev.clients.relationships.add_forbidden_viewer", "'관계 조회' 권한만으로는 새로운 관계를 추가하거나 사용자를 검색할 수 없습니다. 'RP 관리자' 권한이 필요합니다.", ), "error", ); return; } if (!selectedUser) { toast( t( "msg.dev.clients.relationships.user_required", "추가할 사용자를 선택하세요.", ), "error", ); return; } const pendingRelations = selectedRelations.filter( (relation) => !selectedUserExistingRelations.has(relation), ); if (pendingRelations.length === 0) { toast( t( "msg.dev.clients.relationships.relation_required", "추가할 관계를 하나 이상 선택하세요.", ), "error", ); return; } addMutation.mutate(); }; const handleRelationToggle = (relation: RelationOption) => { setSelectedRelations((current) => current.includes(relation) ? current.filter((item) => item !== relation) : [...current, relation], ); }; const handleSelectUser = (user: DevAssignableUser) => { setSelectedUser(user); setUserSearch(formatUserLabel(user)); setIsSearchOpen(false); }; const handleRemove = (targetRelation: string, subject: string) => { if ( window.confirm( t( "msg.dev.clients.relationships.remove_confirm", "이 relationship를 제거하시겠습니까?", ), ) ) { removeMutation.mutate({ relation: targetRelation, subject }); } }; if (!clientId) { return (
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
); } const isUserSearchForbidden = (userSearchError as AxiosError | null)?.response?.status === 403; return (

{t( "ui.dev.clients.relationships.title", "Client Relationships", )}

{t( "msg.dev.clients.relationships.subtitle", "RP direct operator relation을 조회하고 User 단위로 추가·삭제합니다.", )}

{clientData?.client?.status === "active" ? t("ui.common.status.active", "Active") : t("ui.common.status.inactive", "Inactive")}
{t("ui.dev.clients.relationships.add_title", "Add Relationship")} {t( "msg.dev.clients.relationships.add_description", "사용자를 검색해 선택하고, 하나 이상의 운영 관계를 한 번에 부여할 수 있습니다.", )} {isRelationshipViewForbidden ? (
{relationshipViewForbiddenMessage}
) : ( <>
{ if (!selectedUser && userSearch.trim() !== "") { setIsSearchOpen(true); } }} onChange={(event) => { setSelectedUser(null); setUserSearch(event.target.value); setIsSearchOpen(true); }} placeholder={t( "ui.dev.clients.relationships.user_search_placeholder", "이름 또는 이메일 검색...", )} /> {isSearchOpen && selectedUser == null && userSearch.trim() !== "" && (
{isUserSearchLoading ? (
{t( "msg.dev.clients.relationships.search_loading", "사용자를 찾는 중입니다...", )}
) : isUserSearchForbidden ? (

{t( "msg.dev.clients.relationships.search_forbidden_user", "일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다.", )}

{t( "msg.dev.clients.relationships.search_forbidden_user_hint", "'관계 조회' 권한만으로는 사용자 검색이 제한됩니다. 'RP 관리자' 관계가 필요합니다.", )}

) : (userSearchData?.items ?? []).length > 0 ? ( (userSearchData?.items ?? []).map((user) => ( )) ) : (
{t( "msg.dev.clients.relationships.search_empty", "검색 결과가 없습니다.", )}
)}
)}
{selectedUser && (

{t( "msg.dev.clients.relationships.selected_user", "선택된 사용자: {{user}}", { user: formatUserLabel(selectedUser) }, )}

)}
{relationOptions.map((relation) => { const disabled = selectedUserExistingRelations.has(relation); const isSelected = selectedRelations.includes(relation); return ( ); })}
)}
{t( "ui.dev.clients.relationships.list_title", "Assigned Relationships", )} {t( "msg.dev.clients.relationships.list_description", "현재 RP에 직접 부여된 operator relation 목록입니다.", )} {isRelationshipViewForbidden ? (
{relationshipViewForbiddenMessage}
) : error ? (
{t( "msg.dev.clients.relationships.load_error", "Relationship 조회 실패: {{error}}", { error: (error as AxiosError<{ error?: string }>).response?.data ?.error ?? (error as Error).message, }, )}
) : isLoading ? (
{t( "msg.dev.clients.relationships.loading", "Relationship를 불러오는 중입니다...", )}
) : sortedItems.length === 0 ? (
{t( "msg.dev.clients.relationships.empty", "직접 부여된 relationship가 없습니다.", )}
) : ( {t("ui.dev.clients.relationships.relation", "Relation")} {t("ui.dev.clients.relationships.subject", "Subject")} {t("ui.dev.clients.relationships.subject_type", "Type")} {t("ui.dev.clients.table.actions", "액션")} {sortedItems.map((item) => (
{relationLabel(item.relation as RelationOption)}
{relationDescription(item.relation as RelationOption)}
{item.userName || item.userEmail || item.subject}
{(item.userEmail || item.userLoginId) && (
{[item.userEmail, item.userLoginId] .filter(Boolean) .join(" · ")}
)}
{item.subject}
{item.subjectId && (
ID: {item.subjectId}
)}
{item.subjectType || "-"}
))}
)}
); } export default ClientRelationsPage;