forked from baron/baron-sso
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
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 {
|
|
addClientRelation,
|
|
fetchClient,
|
|
fetchClientRelations,
|
|
removeClientRelation,
|
|
} from "../../lib/devApi";
|
|
import { t } from "../../lib/i18n";
|
|
|
|
const relationOptions = [
|
|
"admins",
|
|
"creator",
|
|
"config_editor",
|
|
"secret_rotator",
|
|
"jwks_viewer",
|
|
"jwks_operator",
|
|
"consent_viewer",
|
|
"consent_revoker",
|
|
"relationship_viewer",
|
|
"status_operator",
|
|
] as const;
|
|
|
|
function ClientRelationsPage() {
|
|
const params = useParams();
|
|
const queryClient = useQueryClient();
|
|
const clientId = params.id ?? "";
|
|
const [relation, setRelation] = useState<(typeof relationOptions)[number]>(
|
|
"config_editor",
|
|
);
|
|
const [userId, setUserId] = useState("");
|
|
|
|
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,
|
|
});
|
|
|
|
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 addMutation = useMutation({
|
|
mutationFn: () =>
|
|
addClientRelation(clientId, {
|
|
relation,
|
|
userId: userId.trim(),
|
|
}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["client-relations", clientId] });
|
|
setUserId("");
|
|
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 (!userId.trim()) {
|
|
toast(
|
|
t(
|
|
"msg.dev.clients.relationships.user_required",
|
|
"추가할 User ID를 입력하세요.",
|
|
),
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
addMutation.mutate();
|
|
};
|
|
|
|
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 (
|
|
<div className="p-8 text-center">
|
|
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" size="icon" asChild>
|
|
<Link to={`/clients/${clientId}`}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
<div>
|
|
<p className="text-3xl font-black leading-tight">
|
|
{t(
|
|
"ui.dev.clients.relationships.title",
|
|
"Client Relationships",
|
|
)}
|
|
</p>
|
|
<p className="text-muted-foreground">
|
|
{t(
|
|
"msg.dev.clients.relationships.subtitle",
|
|
"RP direct operator relation을 조회하고 User 단위로 추가·삭제합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
|
<Link
|
|
to={`/clients/${clientId}`}
|
|
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
|
>
|
|
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
|
</Link>
|
|
<Link
|
|
to={`/clients/${clientId}/consents`}
|
|
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
|
>
|
|
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
|
</Link>
|
|
<Link
|
|
to={`/clients/${clientId}/settings`}
|
|
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
|
>
|
|
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
|
</Link>
|
|
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
|
{t("ui.dev.clients.details.tab.relationships", "Relationships")}
|
|
</span>
|
|
</div>
|
|
</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",
|
|
"현재는 direct User assignment만 지원합니다. subject는 자동으로 User:<id> 형식으로 전송됩니다.",
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4 md:grid-cols-[220px_minmax(0,1fr)_auto] md:items-end">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="relation-select">
|
|
{t("ui.dev.clients.relationships.relation", "Relation")}
|
|
</Label>
|
|
<select
|
|
id="relation-select"
|
|
value={relation}
|
|
onChange={(e) =>
|
|
setRelation(e.target.value as (typeof relationOptions)[number])
|
|
}
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
{relationOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="user-id-input">
|
|
{t("ui.dev.clients.relationships.user_id", "User ID")}
|
|
</Label>
|
|
<Input
|
|
id="user-id-input"
|
|
value={userId}
|
|
onChange={(e) => setUserId(e.target.value)}
|
|
placeholder={t(
|
|
"ui.dev.clients.relationships.user_id_placeholder",
|
|
"kratos user id",
|
|
)}
|
|
/>
|
|
</div>
|
|
<Button
|
|
onClick={handleAdd}
|
|
disabled={addMutation.isPending}
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{addMutation.isPending
|
|
? t("msg.common.loading", "Loading...")
|
|
: t("ui.dev.clients.relationships.add", "Add")}
|
|
</Button>
|
|
</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>
|
|
{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 className="font-mono text-xs">
|
|
{item.relation}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="space-y-1">
|
|
<div className="font-mono text-xs">{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}
|
|
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;
|