1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/users/GlobalCustomClaimsPage.tsx

337 lines
12 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Key, Plus, Save, Trash2, Users } from "lucide-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { toast } from "../../components/ui/use-toast";
import {
fetchGlobalCustomClaimDefinitions,
type GlobalCustomClaimDefinition,
type GlobalCustomClaimPermission,
updateGlobalCustomClaimDefinitions,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
type ClaimDraft = GlobalCustomClaimDefinition & { id: string };
const valueTypes: GlobalCustomClaimDefinition["valueType"][] = [
"text",
"number",
"boolean",
"array",
"object",
"date",
"datetime",
];
const permissions: GlobalCustomClaimPermission[] = [
"admin_only",
"user_and_admin",
];
function toDrafts(items: GlobalCustomClaimDefinition[]): ClaimDraft[] {
return items.map((item, index) => ({
id: `${item.key || "claim"}-${index}`,
key: item.key,
label: item.label,
valueType: item.valueType || "text",
readPermission: item.readPermission || "admin_only",
writePermission: item.writePermission || "admin_only",
description: item.description || "",
}));
}
function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
return drafts
.map((draft) => normalizeClaimDraftPermissions(draft))
.map((draft) => ({
key: draft.key.trim(),
label: draft.label.trim(),
valueType: draft.valueType,
readPermission: draft.readPermission,
writePermission: draft.writePermission,
description: draft.description?.trim(),
}))
.filter((draft) => draft.key.length > 0);
}
function normalizeClaimDraftPermissions(draft: ClaimDraft): ClaimDraft {
if (draft.writePermission !== "user_and_admin") {
return draft;
}
return {
...draft,
readPermission: "user_and_admin",
};
}
function permissionLabel(permission: GlobalCustomClaimPermission) {
return permission === "user_and_admin"
? t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)
: t("ui.common.custom_claim_permission.admin_only", "관리자만 가능");
}
export default function GlobalCustomClaimsPage() {
const queryClient = useQueryClient();
const [drafts, setDrafts] = React.useState<ClaimDraft[]>([]);
const query = useQuery({
queryKey: ["global-custom-claim-definitions"],
queryFn: fetchGlobalCustomClaimDefinitions,
});
React.useEffect(() => {
if (query.data) {
setDrafts(toDrafts(query.data.items));
}
}, [query.data]);
const mutation = useMutation({
mutationFn: updateGlobalCustomClaimDefinitions,
onSuccess: (data) => {
queryClient.setQueryData(["global-custom-claim-definitions"], data);
toast.success(t("msg.info.saved_success", "저장되었습니다."));
},
onError: () => {
toast.error(t("err.common.unknown", "오류가 발생했습니다."));
},
});
const addClaim = () => {
setDrafts((current) => [
...current,
{
id: `global-claim-${Date.now()}`,
key: "",
label: "",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
description: "",
},
]);
};
const updateClaim = (id: string, patch: Partial<ClaimDraft>) => {
setDrafts((current) =>
current.map((draft) =>
draft.id === id
? normalizeClaimDraftPermissions({ ...draft, ...patch })
: draft,
),
);
};
const removeClaim = (id: string) => {
setDrafts((current) => current.filter((draft) => draft.id !== id));
};
const saveClaims = () => {
mutation.mutate({ items: toDefinitions(drafts) });
};
return (
<div className="space-y-6">
<PageHeader
titleAs="h2"
icon={<Key size={20} />}
title={t(
"ui.admin.users.global_custom_claims.title",
"전역 Claim 설정",
)}
description={t(
"msg.admin.users.global_custom_claims.description",
"모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다.",
)}
actions={
<>
<Button asChild variant="outline" size="sm" className="h-9">
<Link to="/users">
<Users size={16} />
{t("ui.admin.users.list.title", "사용자 관리")}
</Link>
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-2"
onClick={addClaim}
>
<Plus size={16} />
{t("ui.common.add", "추가")}
</Button>
<Button
type="button"
size="sm"
className="h-9 gap-2"
disabled={mutation.isPending}
onClick={saveClaims}
>
<Save size={16} />
{t("ui.common.save", "저장")}
</Button>
</>
}
/>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-lg">
{t(
"ui.admin.users.global_custom_claims.registry",
"Global Claim Registry",
)}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.global_custom_claims.registry",
"정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{query.isLoading ? (
<div className="py-12 text-center text-sm text-muted-foreground">
{t("ui.common.loading", "로딩 중...")}
</div>
) : drafts.length === 0 ? (
<div className="rounded-lg border border-dashed py-12 text-center text-sm text-muted-foreground">
{t(
"msg.admin.users.global_custom_claims.empty",
"정의된 전역 claim이 없습니다.",
)}
</div>
) : (
drafts.map((claim) => (
<div
key={claim.id}
className="grid gap-3 rounded-md border bg-background p-3 lg:grid-cols-[minmax(160px,0.8fr)_minmax(160px,0.8fr)_130px_160px_160px_minmax(220px,1fr)_40px]"
>
<Input
value={claim.key}
name={`global-claim-definition-key-${claim.id}`}
className="font-mono text-xs"
placeholder="claim_key"
data-testid={`global-claim-definition-key-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, { key: event.target.value })
}
/>
<Input
value={claim.label}
name={`global-claim-definition-label-${claim.id}`}
placeholder={t(
"ui.admin.users.global_custom_claims.label_placeholder",
"표시 이름",
)}
data-testid={`global-claim-definition-label-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, { label: event.target.value })
}
/>
<select
aria-label={t(
"ui.admin.users.global_custom_claims.value_type",
"Claim 타입",
)}
value={claim.valueType}
name={`global-claim-definition-value-type-${claim.id}`}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
onChange={(event) =>
updateClaim(claim.id, {
valueType: event.target
.value as GlobalCustomClaimDefinition["valueType"],
})
}
>
{valueTypes.map((valueType) => (
<option key={valueType} value={valueType}>
{valueType}
</option>
))}
</select>
<select
aria-label={t(
"ui.admin.users.global_custom_claims.read_permission",
"읽기 권한",
)}
value={claim.readPermission}
name={`global-claim-definition-read-permission-${claim.id}`}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
data-testid={`global-claim-definition-read-permission-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, {
readPermission: event.target
.value as GlobalCustomClaimPermission,
})
}
>
{permissions.map((permission) => (
<option key={permission} value={permission}>
{permissionLabel(permission)}
</option>
))}
</select>
<select
aria-label={t(
"ui.admin.users.global_custom_claims.write_permission",
"쓰기 권한",
)}
value={claim.writePermission}
name={`global-claim-definition-write-permission-${claim.id}`}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
data-testid={`global-claim-definition-write-permission-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, {
writePermission: event.target
.value as GlobalCustomClaimPermission,
})
}
>
{permissions.map((permission) => (
<option key={permission} value={permission}>
{permissionLabel(permission)}
</option>
))}
</select>
<Input
value={claim.description || ""}
name={`global-claim-definition-description-${claim.id}`}
placeholder={t(
"ui.admin.users.global_custom_claims.description_placeholder",
"설명",
)}
onChange={(event) =>
updateClaim(claim.id, { description: event.target.value })
}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeClaim(claim.id)}
>
<Trash2 size={16} />
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}