1
0
forked from baron/baron-sso
Files
baron-sso/devfront/src/features/clients/ClientRelationsPage.tsx

767 lines
28 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Info, Link2, Plus, ShieldHalf, Trash2, X } from "lucide-react";
import { useDeferredValue, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useParams } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
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 {
addClientRelation,
type DevAssignableUser,
fetchClient,
fetchClientRelations,
fetchDevUsers,
removeClientRelation,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
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 relationPermitsInfo(relation: RelationOption) {
return t(`ui.dev.clients.relationships.option.${relation}.permits_info`, "");
}
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<RelationOption[]>(
[],
);
const [userSearch, setUserSearch] = useState("");
const deferredUserSearch = useDeferredValue(userSearch.trim());
const [selectedUser, setSelectedUser] = useState<DevAssignableUser | null>(
null,
);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [infoRelation, setInfoRelation] = useState<RelationOption | null>(null);
const systemRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const hasAccessToken = Boolean(auth.user?.access_token);
const { data: me } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const resolvedSystemRole = me?.role?.trim() || systemRole;
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 = resolvedSystemRole === "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<string>();
}
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 });
}
};
const handleInfoToggle = (
event: React.MouseEvent,
relation: RelationOption,
) => {
event.preventDefault();
event.stopPropagation();
setInfoRelation((prev) => (prev === relation ? null : relation));
};
if (!clientId) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
</div>
);
}
const isUserSearchForbidden =
(userSearchError as AxiosError | null)?.response?.status === 403;
return (
<div className="space-y-8">
<header className="space-y-4">
<div className="flex flex-wrap justify-between gap-4">
<div className="space-y-2">
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{clientData?.client?.name || clientId}</span>
<span>/</span>
<span className="text-foreground font-semibold">
{t("ui.dev.clients.details.tab.relationships", "Relationships")}
</span>
</nav>
<PageHeader
icon={<ShieldHalf size={20} />}
title={t(
"ui.dev.clients.relationships.title",
"Client Relationships",
)}
description={t(
"msg.dev.clients.relationships.subtitle",
"RP direct operator relation을 조회하고 User 단위로 추가·삭제합니다.",
)}
/>
</div>
<div className="flex items-center gap-3">
<Badge
variant={
clientData?.client?.status === "active" ? "info" : "muted"
}
>
{clientData?.client?.status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
</div>
</div>
<ClientDetailTabs activeTab="relationships" clientId={clientId} />
</header>
<Card className="glass-panel">
<CardHeader>
<CardTitle>
{t("ui.dev.clients.relationships.add_title", "Add Relationship")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.relationships.add_description",
"사용자를 검색해 선택하고, 하나 이상의 운영 관계를 한 번에 부여할 수 있습니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t(
"msg.dev.clients.relationships.loading",
"Loading relationships...",
)}
</div>
) : isRelationshipViewForbidden ? (
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
{relationshipViewForbiddenMessage}
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="user-search-input">
{t("ui.dev.clients.relationships.user_search", "사용자")}
</Label>
<div className="relative">
<Input
id="user-search-input"
value={userSearch}
onFocus={() => {
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() !== "" && (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-md border border-border bg-background shadow-lg">
{isUserSearchLoading ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{t(
"msg.dev.clients.relationships.search_loading",
"사용자를 찾는 중입니다...",
)}
</div>
) : isUserSearchForbidden ? (
<div className="px-4 py-8 text-center text-sm text-destructive font-medium border-b border-border/40 bg-destructive/5 flex flex-col gap-2">
<p>
{t(
"msg.dev.clients.relationships.search_forbidden_user",
"일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다.",
)}
</p>
<p className="text-xs text-muted-foreground/80 font-normal">
{t(
"msg.dev.clients.relationships.search_forbidden_user_hint",
"'관계 조회' 권한만으로는 사용자 검색이 제한됩니다. 'RP 관리자' 관계가 필요합니다.",
)}
</p>
</div>
) : (userSearchData?.items ?? []).length > 0 ? (
(userSearchData?.items ?? []).map((user) => (
<button
key={user.id}
type="button"
className="flex w-full flex-col gap-1 px-3 py-2 text-left hover:bg-muted/40 border-b border-border/40 last:border-b-0"
onMouseDown={(event) => {
event.preventDefault();
handleSelectUser(user);
}}
>
<span className="text-sm font-semibold">
{user.name || user.email}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
{user.loginId ? ` · ${user.loginId}` : ""}
</span>
</button>
))
) : (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
{t(
"msg.dev.clients.relationships.search_empty",
"검색 결과가 없습니다.",
)}
</div>
)}
</div>
)}
</div>
{selectedUser && (
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.relationships.selected_user",
"선택된 사용자: {{user}}",
{ user: formatUserLabel(selectedUser) },
)}
</p>
)}
</div>
<div className="space-y-3">
<Label>
{t("ui.dev.clients.relationships.relation", "Relation")}
</Label>
<div className="grid gap-3 md:grid-cols-2">
{relationOptions.map((relation) => {
const disabled =
selectedUserExistingRelations.has(relation);
const isSelected = selectedRelations.includes(relation);
const isInfoVisible = infoRelation === relation;
return (
<div key={relation} className="relative">
<label
className={`flex gap-3 rounded-xl border p-4 transition-all ${
disabled
? "border-border/60 bg-muted/30 opacity-60"
: isSelected
? "border-primary bg-primary/10 shadow-[0_0_0_1px_rgba(59,130,246,0.35)] ring-1 ring-primary/30"
: "border-border bg-background hover:border-primary/40 hover:bg-muted/20"
}`}
>
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-primary"
checked={isSelected || disabled}
disabled={disabled}
onChange={() => handleRelationToggle(relation)}
/>
<div className="flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<div
className={`text-sm font-semibold ${
isSelected && !disabled ? "text-primary" : ""
}`}
>
{relationLabel(relation)}
</div>
<button
type="button"
className={`rounded-full p-0.5 transition-colors ${
isInfoVisible
? "text-primary"
: "text-muted-foreground/60 hover:text-primary"
}`}
onClick={(e) => handleInfoToggle(e, relation)}
>
{isInfoVisible ? (
<X className="h-3.5 w-3.5" />
) : (
<Info className="h-3.5 w-3.5" />
)}
</button>
</div>
<div className="text-xs text-muted-foreground">
{relationDescription(relation)}
</div>
<div
className={`text-[11px] uppercase tracking-wide ${
isSelected && !disabled
? "text-primary/80"
: "text-muted-foreground/80"
}`}
>
{relation}
</div>
</div>
</label>
{isInfoVisible && (
<div className="mt-2 animate-in fade-in slide-in-from-top-1 rounded-lg border border-primary/20 bg-primary/5 p-3 text-xs leading-relaxed text-foreground shadow-sm">
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
<Info className="h-3 w-3" />
{t("ui.common.info", "상세 권한 안내")}
</div>
{relationPermitsInfo(relation)}
</div>
)}
</div>
);
})}
</div>
</div>
<div className="flex justify-end">
<Button
onClick={handleAdd}
disabled={addMutation.isPending || !canManageRelations}
className="gap-2"
>
<Plus className="h-4 w-4" />
{addMutation.isPending
? t("msg.common.loading", "Loading...")
: t("ui.dev.clients.relationships.add", "Add")}
</Button>
</div>
</>
)}
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Link2 className="h-5 w-5" />
{t(
"ui.dev.clients.relationships.list_title",
"Assigned Relationships",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.relationships.list_description",
"현재 RP에 직접 부여된 operator relation 목록입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent>
{isRelationshipViewForbidden ? (
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
{relationshipViewForbiddenMessage}
</div>
) : error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{t(
"msg.dev.clients.relationships.load_error",
"Relationship 조회 실패: {{error}}",
{
error:
(error as AxiosError<{ error?: string }>).response?.data
?.error ?? (error as Error).message,
},
)}
</div>
) : isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t(
"msg.dev.clients.relationships.loading",
"Relationship를 불러오는 중입니다...",
)}
</div>
) : sortedItems.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t(
"msg.dev.clients.relationships.empty",
"직접 부여된 relationship가 없습니다.",
)}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.dev.clients.relationships.relation", "Relation")}
</TableHead>
<TableHead>
{t("ui.dev.clients.relationships.subject", "Subject")}
</TableHead>
<TableHead>
{t("ui.dev.clients.relationships.subject_type", "Type")}
</TableHead>
<TableHead className="w-[120px] text-right">
{t("ui.dev.clients.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedItems.map((item) => (
<TableRow key={`${item.relation}:${item.subject}`}>
<TableCell>
<div className="space-y-1">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 font-medium">
<span>
{relationLabel(item.relation as RelationOption)}
</span>
<button
type="button"
className={`rounded-full p-0.5 transition-colors ${
infoRelation === item.relation
? "text-primary"
: "text-muted-foreground/60 hover:text-primary"
}`}
onClick={(e) =>
handleInfoToggle(
e,
item.relation as RelationOption,
)
}
>
<Info className="h-3.5 w-3.5" />
</button>
</div>
{infoRelation === item.relation && (
<div className="animate-in fade-in slide-in-from-top-1 rounded border border-primary/20 bg-primary/5 p-2 text-[11px] leading-relaxed text-foreground max-w-[250px]">
{relationPermitsInfo(
item.relation as RelationOption,
)}
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
{relationDescription(item.relation as RelationOption)}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="font-medium">
{item.userName || item.userEmail || item.subject}
</div>
{(item.userEmail || item.userLoginId) && (
<div className="text-xs text-muted-foreground">
{[item.userEmail, item.userLoginId]
.filter(Boolean)
.join(" · ")}
</div>
)}
<div className="font-mono text-xs text-muted-foreground">
{item.subject}
</div>
{item.subjectId && (
<div className="text-xs text-muted-foreground">
ID: {item.subjectId}
</div>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{item.subjectType || "-"}</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="gap-2 text-destructive hover:text-destructive"
disabled={
removeMutation.isPending || !canManageRelations
}
onClick={() =>
handleRemove(item.relation, item.subject)
}
>
<Trash2 className="h-4 w-4" />
{t("ui.common.delete", "Delete")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
export default ClientRelationsPage;