forked from baron/baron-sso
Merge pull request 'feature/af-issue380' (#406) from feature/af-issue380 into dev
Reviewed-on: baron/baron-sso#406
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:unit": "vitest run",
|
||||
"test:ui": "playwright test --ui"
|
||||
"test:ui": "playwright test --ui",
|
||||
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.4",
|
||||
|
||||
@@ -5,7 +5,7 @@ const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<div className="relative w-full">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
@@ -69,7 +69,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
|
||||
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-foreground align-middle sticky top-0 bg-inherit",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -63,8 +63,8 @@ function ApiKeyListPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>
|
||||
@@ -103,8 +103,8 @@ function ApiKeyListPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.api_keys.list.registry.title", "API Key Registry")}
|
||||
@@ -119,95 +119,102 @@ function ApiKeyListPage() {
|
||||
</div>
|
||||
<Badge variant="muted">{t("ui.common.badge.system", "System")}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
{t(
|
||||
"msg.admin.api_keys.list.empty",
|
||||
"등록된 API 키가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-semibold">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key size={14} className="text-[var(--color-muted)]" />
|
||||
{key.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code>{key.client_id}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.scopes.map((scope) => (
|
||||
<Badge
|
||||
key={scope}
|
||||
variant="muted"
|
||||
className="text-[10px]"
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
{t(
|
||||
"msg.admin.api_keys.list.empty",
|
||||
"등록된 API 키가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-semibold">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key
|
||||
size={14}
|
||||
className="text-[var(--color-muted)]"
|
||||
/>
|
||||
{key.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code>{key.client_id}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.scopes.map((scope) => (
|
||||
<Badge
|
||||
key={scope}
|
||||
variant="muted"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsedAt
|
||||
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
|
||||
: t("ui.common.never", "Never")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsedAt
|
||||
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
|
||||
: t("ui.common.never", "Never")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -158,8 +158,8 @@ function AuditLogsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>{t("ui.admin.audit.breadcrumb.section", "Audit")}</span>
|
||||
@@ -194,409 +194,421 @@ function AuditLogsPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.audit.registry.title", "Audit registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.audit.registry.count",
|
||||
"로드된 로그 {{count}}건",
|
||||
{ count: logs.length },
|
||||
<Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.audit.registry.title", "Audit registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("msg.admin.audit.registry.count", "로드된 로그 {{count}}건", {
|
||||
count: logs.length,
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">
|
||||
{t("ui.common.badge.command_only", "Command only")}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={filterDraft}
|
||||
onChange={(event) => setFilterDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
handleAddFilter();
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
"ui.admin.audit.filters.placeholder",
|
||||
"필터 추가 (예: status:failure)",
|
||||
)}
|
||||
</CardDescription>
|
||||
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddFilter}>
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</div>
|
||||
<Badge variant="muted">
|
||||
{t("ui.common.badge.command_only", "Command only")}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={filterDraft}
|
||||
onChange={(event) => setFilterDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
handleAddFilter();
|
||||
{filters.length === 0 ? (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.filters.empty", "필터 없음")}
|
||||
</span>
|
||||
) : (
|
||||
filters.map((filter) => (
|
||||
<span
|
||||
key={filter}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
{filter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters((prev) =>
|
||||
prev.filter((item) => item !== filter),
|
||||
)
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
"ui.admin.audit.filters.placeholder",
|
||||
"필터 추가 (예: status:failure)",
|
||||
)}
|
||||
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddFilter}>
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</div>
|
||||
{filters.length === 0 ? (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.filters.empty", "필터 없음")}
|
||||
</span>
|
||||
) : (
|
||||
filters.map((filter) => (
|
||||
<span
|
||||
key={filter}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.filters.remove",
|
||||
"{{filter}} 필터 제거",
|
||||
{ filter },
|
||||
)}
|
||||
>
|
||||
<Terminal size={12} />
|
||||
{filter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters((prev) =>
|
||||
prev.filter((item) => item !== filter),
|
||||
)
|
||||
}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.filters.remove",
|
||||
"{{filter}} 필터 제거",
|
||||
{ filter },
|
||||
)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[140px]">
|
||||
{t("ui.admin.audit.table.time", "TIME")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px]">
|
||||
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.audit.table.request", "REQUEST")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.audit.table.path", "PATH")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{t("ui.admin.audit.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.audit.table.action_target", "Action / Target")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && logs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<TableHead className="w-[140px]">
|
||||
{t("ui.admin.audit.table.time", "TIME")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px]">
|
||||
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.audit.table.request", "REQUEST")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.audit.table.path", "PATH")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{t("ui.admin.audit.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"msg.admin.audit.empty",
|
||||
"아직 수집된 감사 로그가 없습니다.",
|
||||
"ui.admin.audit.table.action_target",
|
||||
"Action / Target",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
)}
|
||||
{logs.map((row, index) => {
|
||||
const details = parseDetails(row.details);
|
||||
const actionLabel =
|
||||
details.action ||
|
||||
(details.method && details.path
|
||||
? `${details.method} ${details.path}`
|
||||
: row.event_type);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const isExpanded = Boolean(expandedRows[rowKey]);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow className="bg-card/40">
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
{(() => {
|
||||
const { date, time } = formatIsoDateTime(
|
||||
row.timestamp,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{row.user_id || details.actor_id || "-"}
|
||||
</code>
|
||||
{(row.user_id || details.actor_id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.actor_id",
|
||||
"Copy actor id",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
row.user_id || details.actor_id || "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="break-all">
|
||||
{formatCellValue(details.request_id)}
|
||||
</span>
|
||||
{details.request_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.request_id",
|
||||
"Copy request id",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(details.request_id || "")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{formatCellValue(details.method)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{formatCellValue(details.path)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status === "success" || row.status === "ok"
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{actionLabel}
|
||||
</div>
|
||||
{details.target && (
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && logs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t(
|
||||
"msg.admin.audit.empty",
|
||||
"아직 수집된 감사 로그가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{logs.map((row, index) => {
|
||||
const details = parseDetails(row.details);
|
||||
const actionLabel =
|
||||
details.action ||
|
||||
(details.method && details.path
|
||||
? `${details.method} ${details.path}`
|
||||
: row.event_type);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const isExpanded = Boolean(expandedRows[rowKey]);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow className="bg-card/40">
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
{(() => {
|
||||
const { date, time } = formatIsoDateTime(
|
||||
row.timestamp,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.target",
|
||||
"Target · {{target}}",
|
||||
{
|
||||
target: details.target,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.target",
|
||||
"Copy target",
|
||||
)}
|
||||
onClick={() => handleCopy(details.target || "")}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !isExpanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell colSpan={7} className="text-xs">
|
||||
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t(
|
||||
"ui.admin.audit.details.request",
|
||||
"Request",
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{row.user_id || details.actor_id || "-"}
|
||||
</code>
|
||||
{(row.user_id || details.actor_id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.actor_id",
|
||||
"Copy actor id",
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{
|
||||
value: formatCellValue(
|
||||
details.request_id,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.event_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.ip",
|
||||
"IP · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.ip_address),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.admin.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.actor_id",
|
||||
"Actor ID · {{value}}",
|
||||
{
|
||||
value:
|
||||
row.user_id || details.actor_id || "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.tenant",
|
||||
"Tenant · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.tenant_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.device",
|
||||
"Device · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.device_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.admin.audit.details.result", "Result")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.error",
|
||||
"Error · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.error),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.before",
|
||||
"Before · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.before),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.after",
|
||||
"After · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.after),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
row.user_id || details.actor_id || "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="break-all">
|
||||
{formatCellValue(details.request_id)}
|
||||
</span>
|
||||
{details.request_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.request_id",
|
||||
"Copy request id",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(details.request_id || "")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{formatCellValue(details.method)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{formatCellValue(details.path)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status === "success" || row.status === "ok"
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{actionLabel}
|
||||
</div>
|
||||
{details.target && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.target",
|
||||
"Target · {{target}}",
|
||||
{
|
||||
target: details.target,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.target",
|
||||
"Copy target",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(details.target || "")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !isExpanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="pt-4 text-center">
|
||||
{hasNextPage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.admin.audit.load_more", "Load more")}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell colSpan={7} className="text-xs">
|
||||
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t(
|
||||
"ui.admin.audit.details.request",
|
||||
"Request",
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{
|
||||
value: formatCellValue(
|
||||
details.request_id,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.event_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.ip",
|
||||
"IP · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.ip_address),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.admin.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.actor_id",
|
||||
"Actor ID · {{value}}",
|
||||
{
|
||||
value:
|
||||
row.user_id ||
|
||||
details.actor_id ||
|
||||
"-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.tenant",
|
||||
"Tenant · {{value}}",
|
||||
{
|
||||
value: formatCellValue(
|
||||
details.tenant_id,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.device",
|
||||
"Device · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.device_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t(
|
||||
"ui.admin.audit.details.result",
|
||||
"Result",
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.error",
|
||||
"Error · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.error),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.before",
|
||||
"Before · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.before),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.after",
|
||||
"After · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.after),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 text-center flex-shrink-0">
|
||||
{hasNextPage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.admin.audit.load_more", "Load more")}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
@@ -52,6 +53,8 @@ import { t } from "../../../lib/i18n";
|
||||
type DialogMode = "owner" | "admin";
|
||||
|
||||
export function TenantAdminsAndOwnersTab() {
|
||||
const auth = useAuth();
|
||||
const currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -204,218 +207,266 @@ export function TenantAdminsAndOwnersTab() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 mt-6">
|
||||
{/* Owners Card */}
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Crown className="h-6 w-6 text-yellow-500" />
|
||||
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.owners.subtitle",
|
||||
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("owner")}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.owners.table_name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.owners.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.owners.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ownersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentOwners.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 opacity-20" />
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.tenants.owners.empty",
|
||||
"등록된 소유자가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentOwners.map((owner) => (
|
||||
<TableRow
|
||||
key={owner.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
||||
{owner.name.charAt(0)}
|
||||
</div>
|
||||
<span>{owner.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{owner.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
onClick={() =>
|
||||
handleRemoveOwner(owner.id, owner.name)
|
||||
}
|
||||
disabled={removeOwnerMutation.isPending}
|
||||
title={t(
|
||||
"ui.admin.tenants.owners.remove_title",
|
||||
"소유자 권한 회수",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
|
||||
{/* Owners Card */}
|
||||
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Crown className="h-6 w-6 text-yellow-500" />
|
||||
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.owners.subtitle",
|
||||
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("owner")}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.owners.table_name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.owners.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.owners.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ownersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentOwners.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 opacity-20" />
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.tenants.owners.empty",
|
||||
"등록된 소유자가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentOwners.map((owner) => (
|
||||
<TableRow
|
||||
key={owner.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
||||
{owner.name.charAt(0)}
|
||||
</div>
|
||||
<span>{owner.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{owner.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveOwner(owner.id, owner.name)
|
||||
}
|
||||
disabled={
|
||||
removeOwnerMutation.isPending ||
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
}
|
||||
title={
|
||||
owner.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentOwners.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_last",
|
||||
"마지막 소유자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.owners.remove_title",
|
||||
"소유자 권한 회수",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Admins Card */}
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.admins.subtitle",
|
||||
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("admin")}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.admins.table_name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.admins.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentAdmins.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 opacity-20" />
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.tenants.admins.empty",
|
||||
"등록된 관리자가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentAdmins.map((admin) => (
|
||||
<TableRow
|
||||
key={admin.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
||||
{admin.name.charAt(0)}
|
||||
</div>
|
||||
<span>{admin.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{admin.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
onClick={() =>
|
||||
handleRemoveAdmin(admin.id, admin.name)
|
||||
}
|
||||
disabled={removeAdminMutation.isPending}
|
||||
title={t(
|
||||
"ui.admin.tenants.admins.remove_title",
|
||||
"관리자 권한 회수",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
{/* Admins Card */}
|
||||
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.admins.subtitle",
|
||||
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("admin")}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.admins.table_name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.admins.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentAdmins.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 opacity-20" />
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.tenants.admins.empty",
|
||||
"등록된 관리자가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentAdmins.map((admin) => (
|
||||
<TableRow
|
||||
key={admin.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
||||
{admin.name.charAt(0)}
|
||||
</div>
|
||||
<span>{admin.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{admin.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveAdmin(admin.id, admin.name)
|
||||
}
|
||||
disabled={
|
||||
removeAdminMutation.isPending ||
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
}
|
||||
title={
|
||||
admin.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentAdmins.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_last",
|
||||
"마지막 관리자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.admins.remove_title",
|
||||
"관리자 권한 회수",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Common Dialog for adding users */}
|
||||
<Dialog
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { fetchTenant } from "../../../lib/adminApi";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantDetailPage() {
|
||||
@@ -16,6 +16,14 @@ function TenantDetailPage() {
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const canAccessSchema =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
|
||||
const isFederationTab = location.pathname.includes("/federation");
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
const isOrganizationTab = location.pathname.includes("/organization");
|
||||
@@ -98,16 +106,18 @@ function TenantDetailPage() {
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/schema`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
location.pathname.includes("/schema")
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||
</Link>
|
||||
{canAccessSchema && (
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/schema`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
location.pathname.includes("/schema")
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outlet for nested routes */}
|
||||
|
||||
@@ -343,11 +343,11 @@ function TenantGroupsPage() {
|
||||
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mt-6">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
|
||||
{/* 그룹 생성 폼 */}
|
||||
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
||||
<CardHeader>
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Plus size={16} />{" "}
|
||||
{t("ui.admin.groups.create.title", "새 그룹 생성")}
|
||||
@@ -359,7 +359,7 @@ function TenantGroupsPage() {
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-4 flex-1 overflow-auto">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">
|
||||
{t("ui.admin.groups.form.name_label", "그룹 이름")}
|
||||
@@ -431,8 +431,8 @@ function TenantGroupsPage() {
|
||||
</Card>
|
||||
|
||||
{/* 그룹 목록 (트리 뷰) */}
|
||||
<Card className="bg-[var(--color-panel)] md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.groups.list.title", "User Groups")}
|
||||
@@ -458,76 +458,80 @@ function TenantGroupsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupsQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
{t("msg.admin.groups.list.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!groupsQuery.isLoading && groupTree.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.list.empty",
|
||||
"아직 등록된 그룹이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{groupTree.map((node) => (
|
||||
<UserGroupTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
onSelect={setSelectedGroupId}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onDelete={(id) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.groups.list.delete_confirm",
|
||||
"그룹을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
}}
|
||||
onAddSubGroup={handleAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupsQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
{t("msg.admin.groups.list.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!groupsQuery.isLoading && groupTree.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.list.empty",
|
||||
"아직 등록된 그룹이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{groupTree.map((node) => (
|
||||
<UserGroupTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
onSelect={setSelectedGroupId}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onDelete={(id) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.groups.list.delete_confirm",
|
||||
"그룹을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
}}
|
||||
onAddSubGroup={handleAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
|
||||
{currentGroup && (
|
||||
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
|
||||
<CardHeader>
|
||||
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield size={18} className="text-primary" />
|
||||
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
|
||||
@@ -541,8 +545,8 @@ function TenantGroupsPage() {
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-end mb-4">
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex justify-end mb-4 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAddMember(currentGroup.id)}
|
||||
@@ -552,56 +556,65 @@ function TenantGroupsPage() {
|
||||
{t("ui.common.add", "멤버 추가")}
|
||||
</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.members.table.remove", "제거")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentGroup.members?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-4 text-muted-foreground"
|
||||
>
|
||||
{t("msg.admin.groups.members.empty", "멤버가 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{currentGroup.members?.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
removeMemberMutation.mutate({
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
})
|
||||
}
|
||||
disabled={removeMemberMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.members.table.remove", "제거")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentGroup.members?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-4 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.members.empty",
|
||||
"멤버가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{currentGroup.members?.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
removeMemberMutation.mutate({
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
})
|
||||
}
|
||||
disabled={removeMemberMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -116,8 +116,8 @@ function TenantListPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>{t("ui.admin.tenants.breadcrumb.section", "Tenants")}</span>
|
||||
@@ -156,8 +156,8 @@ function TenantListPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||
@@ -172,120 +172,132 @@ function TenantListPage() {
|
||||
{t("ui.common.badge.admin_only", "Admin only")}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && tenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.empty",
|
||||
"아직 등록된 테넌트가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">{tenant.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{t(
|
||||
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
|
||||
tenant.type,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{tenant.memberCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && tenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{t("ui.common.edit", "편집")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tenant.id, tenant.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{t(
|
||||
"msg.admin.tenants.empty",
|
||||
"아직 등록된 테넌트가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{t(
|
||||
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
|
||||
tenant.type,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
`ui.common.status.${tenant.status}`,
|
||||
tenant.status,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{tenant.memberCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{t("ui.common.edit", "편집")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tenant.id, tenant.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -14,10 +14,16 @@ import {
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type SchemaFieldType = "text" | "number" | "boolean" | "date";
|
||||
type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
type SchemaField = {
|
||||
id: string;
|
||||
@@ -27,6 +33,7 @@ type SchemaField = {
|
||||
required: boolean;
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
@@ -40,6 +47,38 @@ export function TenantSchemaPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const canAccess =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
|
||||
if (isProfileLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center animate-pulse text-muted-foreground">
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAccess) {
|
||||
return (
|
||||
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||
<h3 className="text-xl font-bold text-destructive">
|
||||
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.schema.forbidden_desc",
|
||||
"사용자 스키마 설정은 관리자만 접근할 수 있습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
@@ -66,13 +105,16 @@ export function TenantSchemaPage() {
|
||||
type:
|
||||
field?.type === "number" ||
|
||||
field?.type === "boolean" ||
|
||||
field?.type === "date"
|
||||
field?.type === "date" ||
|
||||
field?.type === "float" ||
|
||||
field?.type === "datetime"
|
||||
? field.type
|
||||
: "text",
|
||||
required: Boolean(field?.required),
|
||||
adminOnly: Boolean(field?.adminOnly),
|
||||
validation:
|
||||
typeof field?.validation === "string" ? field.validation : "",
|
||||
unsigned: Boolean(field?.unsigned),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -114,6 +156,7 @@ export function TenantSchemaPage() {
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -210,9 +253,13 @@ export function TenantSchemaPage() {
|
||||
nextType === "text" ||
|
||||
nextType === "number" ||
|
||||
nextType === "boolean" ||
|
||||
nextType === "date"
|
||||
nextType === "date" ||
|
||||
nextType === "float" ||
|
||||
nextType === "datetime"
|
||||
) {
|
||||
updateField(index, { type: nextType });
|
||||
updateField(index, {
|
||||
type: nextType as SchemaFieldType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -225,7 +272,13 @@ export function TenantSchemaPage() {
|
||||
<option value="number">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_number",
|
||||
"숫자 (Number)",
|
||||
"숫자 (Integer)",
|
||||
)}
|
||||
</option>
|
||||
<option value="float">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_float",
|
||||
"실수 (Float)",
|
||||
)}
|
||||
</option>
|
||||
<option value="boolean">
|
||||
@@ -240,12 +293,18 @@ export function TenantSchemaPage() {
|
||||
"날짜 (Date)",
|
||||
)}
|
||||
</option>
|
||||
<option value="datetime">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_datetime",
|
||||
"일시 (DateTime)",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -275,6 +334,24 @@ export function TenantSchemaPage() {
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{(field.type === "number" || field.type === "float") && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.unsigned}
|
||||
onChange={(e) =>
|
||||
updateField(index, { unsigned: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.unsigned",
|
||||
"음수 불가",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
|
||||
@@ -34,8 +34,8 @@ function TenantSubTenantsPage() {
|
||||
const subTenants = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card className="mt-6 bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 size={18} className="text-primary" />
|
||||
@@ -57,64 +57,73 @@ function TenantSubTenantsPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.sub.table.action", "ACTION")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subTenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t("msg.admin.tenants.sub.empty", "하위 테넌트가 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{subTenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">{tenant.name}</TableCell>
|
||||
<TableCell className="text-xs font-mono">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
|
||||
<ArrowRight size={12} className="ml-1" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.sub.table.action", "ACTION")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subTenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.sub.empty",
|
||||
"하위 테넌트가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{subTenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
|
||||
<ArrowRight size={12} className="ml-1" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -42,8 +42,8 @@ function TenantUsersPage() {
|
||||
const users = usersQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card className="mt-6 bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User size={18} className="text-primary" />
|
||||
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
|
||||
@@ -51,66 +51,70 @@ function TenantUsersPage() {
|
||||
})}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.email", "EMAIL")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.role", "ROLE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.members.empty",
|
||||
"소속된 사용자가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(
|
||||
`ui.common.role.${user.role}`,
|
||||
user.role.replace("_", " "),
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={user.status === "active" ? "default" : "muted"}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.email", "EMAIL")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.role", "ROLE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.members.empty",
|
||||
"소속된 사용자가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(
|
||||
`ui.common.role.${user.role}`,
|
||||
user.role.replace("_", " "),
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={user.status === "active" ? "default" : "muted"}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -35,8 +35,8 @@ export default function GlobalUserGroupListPage() {
|
||||
return <div className="p-8">Loading tenants and groups...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex items-start justify-between">
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex items-start justify-between flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -46,7 +46,7 @@ export default function GlobalUserGroupListPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-6 flex-1 overflow-auto p-1">
|
||||
{tenantList?.items.map((tenant) => (
|
||||
<TenantGroupCard key={tenant.id} tenant={tenant} />
|
||||
))}
|
||||
@@ -62,8 +62,8 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Building2 size={20} className="text-muted-foreground" />
|
||||
@@ -83,62 +83,66 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">그룹명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="w-[100px]">멤버 수</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : groups?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center text-muted-foreground py-4"
|
||||
>
|
||||
등록된 유저 그룹이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groups?.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={14} className="text-primary" />
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{group.name}
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{group.description || "-"}</TableCell>
|
||||
<TableCell>{group.members?.length || 0} 명</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||
>
|
||||
상세보기
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">그룹명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="w-[100px]">멤버 수</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : groups?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center text-muted-foreground py-4"
|
||||
>
|
||||
등록된 유저 그룹이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groups?.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={14} className="text-primary" />
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{group.name}
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{group.description || "-"}</TableCell>
|
||||
<TableCell>{group.members?.length || 0} 명</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||
>
|
||||
상세보기
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -228,7 +228,7 @@ const MemberTable: React.FC<{
|
||||
}> = ({ members, isLoading, onRefresh, showTenant }) => (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="h-9">
|
||||
{t("ui.admin.users.table.name", "NAME")}
|
||||
@@ -929,9 +929,9 @@ function TenantUserGroupsTab() {
|
||||
const BaseIcon = getTenantIcon(currentBase.type);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mt-6">
|
||||
<Card className="bg-[var(--color-panel)] border-none shadow-sm overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b bg-muted/5 py-4">
|
||||
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] border-none shadow-sm overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b bg-muted/5 py-4 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
<BaseIcon size={20} className="text-primary" />
|
||||
@@ -1078,7 +1078,7 @@ function TenantUserGroupsTab() {
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="px-6 py-3 bg-muted/5 border-b flex items-center gap-4">
|
||||
<div className="px-6 py-3 bg-muted/5 border-b flex items-center gap-4 flex-shrink-0">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -1102,39 +1102,43 @@ function TenantUserGroupsTab() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/5">
|
||||
<TableRow>
|
||||
<TableHead className="pl-6 w-[40%]">
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right pr-6">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TenantTreeRow
|
||||
node={currentBase}
|
||||
level={0}
|
||||
isRoot={true}
|
||||
onRemove={handleRemove}
|
||||
onMove={handleMove}
|
||||
isUpdating={updateParentMutation.isPending}
|
||||
searchTerm={treeSearchTerm}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 p-0">
|
||||
<div className="flex-1 rounded-md border-0 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="pl-6 w-[40%]">
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right pr-6">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TenantTreeRow
|
||||
node={currentBase}
|
||||
level={0}
|
||||
isRoot={true}
|
||||
onRemove={handleRemove}
|
||||
onMove={handleMove}
|
||||
isUpdating={updateParentMutation.isPending}
|
||||
searchTerm={treeSearchTerm}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -211,8 +211,8 @@ export function UserGroupDetailPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-8 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link
|
||||
@@ -260,10 +260,10 @@ export function UserGroupDetailPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
|
||||
{/* Members Management */}
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.groups.detail.members_title", "구성원 관리")}
|
||||
@@ -347,88 +347,90 @@ export function UserGroupDetailPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.list.table.name_email", "사용자")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold">
|
||||
{t("ui.admin.groups.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!currentGroup.members ||
|
||||
currentGroup.members.length === 0 ? (
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.members.empty",
|
||||
"구성원이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.list.table.name_email", "사용자")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold">
|
||||
{t("ui.admin.groups.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentGroup.members.map((member) => (
|
||||
<TableRow
|
||||
key={member.id}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{member.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t(
|
||||
"msg.admin.groups.members.remove_confirm",
|
||||
"제거하시겠습니까?",
|
||||
{ name: member.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeMemberMutation.mutate(member.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!currentGroup.members ||
|
||||
currentGroup.members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.members.empty",
|
||||
"구성원이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
currentGroup.members.map((member) => (
|
||||
<TableRow
|
||||
key={member.id}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{member.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t(
|
||||
"msg.admin.groups.members.remove_confirm",
|
||||
"제거하시겠습니까?",
|
||||
{ name: member.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeMemberMutation.mutate(member.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Roles/Permissions Management (Keto Based) */}
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.groups.detail.permissions_title", "권한 관리")}
|
||||
@@ -530,86 +532,88 @@ export function UserGroupDetailPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.role", "역할")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold">
|
||||
{t("ui.admin.groups.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isRolesLoading ? (
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.role", "역할")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold">
|
||||
{t("ui.admin.groups.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
) : !groupRoles || groupRoles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.roles.empty",
|
||||
"할당된 역할이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupRoles.map((role, idx) => (
|
||||
<TableRow
|
||||
key={`${role.tenantId}-${role.relation}-${idx}`}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-sm">
|
||||
{role.tenantName || role.tenantId}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize font-normal"
|
||||
>
|
||||
{role.relation}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t("msg.admin.groups.roles.remove_confirm"),
|
||||
)
|
||||
) {
|
||||
removeRoleMutation.mutate({
|
||||
targetTenantId: role.tenantId,
|
||||
relation: role.relation,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isRolesLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : !groupRoles || groupRoles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.roles.empty",
|
||||
"할당된 역할이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupRoles.map((role, idx) => (
|
||||
<TableRow
|
||||
key={`${role.tenantId}-${role.relation}-${idx}`}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-sm">
|
||||
{role.tenantName || role.tenantId}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize font-normal"
|
||||
>
|
||||
{role.relation}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t("msg.admin.groups.roles.remove_confirm"),
|
||||
)
|
||||
) {
|
||||
removeRoleMutation.mutate({
|
||||
targetTenantId: role.tenantId,
|
||||
relation: role.relation,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
ArrowLeft,
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
Copy,
|
||||
Dices,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
Save,
|
||||
Users,
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
useForm,
|
||||
} from "react-hook-form";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -36,6 +41,19 @@ import {
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
// Utility for secure password generation
|
||||
function generateSecurePassword(length = 16) {
|
||||
const charset =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+~`|}{[]:;?><,./-=";
|
||||
let retVal = "";
|
||||
const values = new Uint32Array(length);
|
||||
crypto.getRandomValues(values);
|
||||
for (let i = 0; i < length; i++) {
|
||||
retVal += charset.charAt(values[i] % charset.length);
|
||||
}
|
||||
return retVal;
|
||||
}
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label?: string;
|
||||
@@ -148,6 +166,7 @@ function UserDetailPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
@@ -175,6 +194,7 @@ function UserDetailPage() {
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<UserFormValues>({
|
||||
defaultValues: {
|
||||
@@ -194,6 +214,28 @@ function UserDetailPage() {
|
||||
const isAdmin =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
|
||||
const handleGeneratePassword = () => {
|
||||
const newPass = generateSecurePassword();
|
||||
setValue("password", newPass);
|
||||
setShowPassword(true);
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.detail.password_generated",
|
||||
"안전한 비밀번호가 생성되었습니다.",
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyPassword = () => {
|
||||
const pass = watch("password");
|
||||
if (pass) {
|
||||
navigator.clipboard.writeText(pass);
|
||||
toast.success(
|
||||
t("msg.common.copied_to_clipboard", "클립보드에 복사되었습니다."),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user) {
|
||||
reset({
|
||||
@@ -556,15 +598,49 @@ function UserDetailPage() {
|
||||
"비밀번호 변경",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={t(
|
||||
"ui.admin.users.detail.security.password_placeholder",
|
||||
"변경할 경우에만 입력",
|
||||
)}
|
||||
{...register("password")}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"ui.admin.users.detail.security.password_placeholder",
|
||||
"변경할 경우에만 입력",
|
||||
)}
|
||||
className="font-mono"
|
||||
{...register("password")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleGeneratePassword}
|
||||
title={t(
|
||||
"ui.admin.users.detail.generate_password",
|
||||
"자동 생성",
|
||||
)}
|
||||
>
|
||||
<Dices size={16} className="mr-2" />
|
||||
{t("ui.common.generate", "생성")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyPassword}
|
||||
disabled={!watch("password")}
|
||||
title={t("ui.common.copy", "복사")}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.users.detail.security.password_hint",
|
||||
|
||||
@@ -254,8 +254,8 @@ function UserListPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>{t("ui.admin.users.list.breadcrumb.section", "Users")}</span>
|
||||
@@ -353,8 +353,8 @@ function UserListPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.users.list.registry.title", "User Registry")}
|
||||
@@ -368,8 +368,8 @@ function UserListPage() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4">
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4 flex-shrink-0">
|
||||
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -412,167 +412,175 @@ function UserListPage() {
|
||||
</div>
|
||||
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={
|
||||
items.length > 0 &&
|
||||
selectedUserIds.length === items.length
|
||||
}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t("ui.admin.users.list.table.name_email", "NAME / EMAIL")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.admin.users.list.table.tenant_dept",
|
||||
"TENANT / DEPT",
|
||||
)}
|
||||
</TableHead>
|
||||
{/* Dynamic Columns from Schema */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<TableHead key={field.key} className="uppercase">
|
||||
{field.label}
|
||||
</TableHead>
|
||||
),
|
||||
)}
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.created", "CREATED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.users.list.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("msg.admin.users.list.empty", "검색 결과가 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={
|
||||
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<TableHead className="w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => toggleSelectUser(user.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{user.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{t(`ui.admin.role.${user.role}`, user.role)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.status === "active" ? "default" : "secondary"
|
||||
checked={
|
||||
items.length > 0 &&
|
||||
selectedUserIds.length === items.length
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="font-medium text-blue-600">
|
||||
{user.tenant?.name || user.companyCode || "-"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t(
|
||||
"ui.admin.users.list.table.name_email",
|
||||
"NAME / EMAIL",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.admin.users.list.table.tenant_dept",
|
||||
"TENANT / DEPT",
|
||||
)}
|
||||
</TableHead>
|
||||
{/* Dynamic Columns from Schema */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<TableCell key={field.key} className="text-sm">
|
||||
{String(user.metadata?.[field.key] ?? "-")}
|
||||
</TableCell>
|
||||
<TableHead key={field.key} className="uppercase">
|
||||
{field.label}
|
||||
</TableHead>
|
||||
),
|
||||
)}
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/users/${user.id}`)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(user.id, user.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.created", "CREATED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.users.list.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.users.list.empty",
|
||||
"검색 결과가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={
|
||||
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => toggleSelectUser(user.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{user.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{t(`ui.admin.role.${user.role}`, user.role)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="font-medium text-blue-600">
|
||||
{user.tenant?.name || user.companyCode || "-"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<TableCell key={field.key} className="text-sm">
|
||||
{String(user.metadata?.[field.key] ?? "-")}
|
||||
</TableCell>
|
||||
),
|
||||
)}
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/users/${user.id}`)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(user.id, user.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
@@ -607,6 +615,9 @@ function UserListPage() {
|
||||
</Button>
|
||||
<UserBulkMoveGroupModal
|
||||
userIds={selectedUserIds}
|
||||
selectedUsers={items.filter((u) =>
|
||||
selectedUserIds.includes(u.id),
|
||||
)}
|
||||
onSuccess={() => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
@@ -639,7 +650,7 @@ function UserListPage() {
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { FolderTree, Loader2, Search } from "lucide-react";
|
||||
import { AlertTriangle, FolderTree, Loader2, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
@@ -18,19 +18,28 @@ import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import {
|
||||
type GroupSummary,
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
bulkUpdateUsers,
|
||||
fetchGroups,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
interface UserBulkMoveGroupModalProps {
|
||||
userIds: string[];
|
||||
selectedUsers?: UserSummary[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function UserBulkMoveGroupModal({
|
||||
userIds,
|
||||
selectedUsers = [],
|
||||
onSuccess,
|
||||
}: UserBulkMoveGroupModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -38,6 +47,7 @@ export function UserBulkMoveGroupModal({
|
||||
React.useState<string>("");
|
||||
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -48,10 +58,11 @@ export function UserBulkMoveGroupModal({
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
const selectedTenantId = React.useMemo(
|
||||
() => tenants.find((t) => t.slug === selectedTenantSlug)?.id ?? "",
|
||||
const selectedTenant = React.useMemo(
|
||||
() => tenants.find((t) => t.slug === selectedTenantSlug),
|
||||
[tenants, selectedTenantSlug],
|
||||
);
|
||||
const selectedTenantId = selectedTenant?.id ?? "";
|
||||
|
||||
const { data: groups, isLoading: isGroupsLoading } = useQuery({
|
||||
queryKey: ["tenant-groups", selectedTenantId],
|
||||
@@ -59,6 +70,51 @@ export function UserBulkMoveGroupModal({
|
||||
enabled: open && !!selectedTenantId,
|
||||
});
|
||||
|
||||
const schemaWarnings = React.useMemo(() => {
|
||||
if (!selectedTenant || selectedUsers.length === 0) return null;
|
||||
|
||||
const targetSchema =
|
||||
(selectedTenant.config?.userSchema as UserSchemaField[]) || [];
|
||||
const targetSchemaKeys = new Set(targetSchema.map((f) => f.key));
|
||||
const requiredKeys = targetSchema
|
||||
.filter((f) => f.required)
|
||||
.map((f) => f.key);
|
||||
|
||||
const missingRequiredFields = new Set<string>();
|
||||
const incompatibleFields = new Set<string>();
|
||||
|
||||
for (const user of selectedUsers) {
|
||||
const userMeta = user.metadata || {};
|
||||
|
||||
// 1. Check for missing required fields
|
||||
for (const key of requiredKeys) {
|
||||
if (
|
||||
userMeta[key] === undefined ||
|
||||
userMeta[key] === null ||
|
||||
userMeta[key] === ""
|
||||
) {
|
||||
missingRequiredFields.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for fields that exist in user metadata but not in the target schema (data loss)
|
||||
for (const key of Object.keys(userMeta)) {
|
||||
if (!targetSchemaKeys.has(key)) {
|
||||
incompatibleFields.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingRequiredFields.size === 0 && incompatibleFields.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
missing: Array.from(missingRequiredFields),
|
||||
incompatible: Array.from(incompatibleFields),
|
||||
};
|
||||
}, [selectedTenant, selectedUsers]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: bulkUpdateUsers,
|
||||
onSuccess: () => {
|
||||
@@ -96,7 +152,18 @@ export function UserBulkMoveGroupModal({
|
||||
}, [groups, searchTerm]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) {
|
||||
setSelectedTenantSlug("");
|
||||
setSelectedGroupName("");
|
||||
setAcknowledgeWarning(false);
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -131,6 +198,7 @@ export function UserBulkMoveGroupModal({
|
||||
onChange={(e) => {
|
||||
setSelectedTenantSlug(e.target.value);
|
||||
setSelectedGroupName("");
|
||||
setAcknowledgeWarning(false);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("ui.common.select", "선택하세요...")}</option>
|
||||
@@ -195,6 +263,49 @@ export function UserBulkMoveGroupModal({
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schemaWarnings && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 space-y-2 mt-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-destructive font-semibold">
|
||||
<AlertTriangle size={16} />
|
||||
{t("ui.admin.users.bulk.schema_warning", "스키마 호환성 경고")}
|
||||
</div>
|
||||
<div className="text-destructive/80 text-xs">
|
||||
{schemaWarnings.missing.length > 0 && (
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.users.bulk.schema_missing",
|
||||
"대상 테넌트의 필수 필드가 누락되어 있습니다:",
|
||||
)}{" "}
|
||||
<strong>{schemaWarnings.missing.join(", ")}</strong>
|
||||
</p>
|
||||
)}
|
||||
{schemaWarnings.incompatible.length > 0 && (
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.users.bulk.schema_incompatible",
|
||||
"대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:",
|
||||
)}{" "}
|
||||
<strong>{schemaWarnings.incompatible.join(", ")}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer mt-2 pt-2 border-t border-destructive/10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledgeWarning}
|
||||
onChange={(e) => setAcknowledgeWarning(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-destructive focus:ring-destructive"
|
||||
/>
|
||||
<span className="font-medium text-destructive/90">
|
||||
{t(
|
||||
"ui.admin.users.bulk.acknowledge_warning",
|
||||
"경고를 확인했으며 계속 진행합니다.",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -203,7 +314,11 @@ export function UserBulkMoveGroupModal({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMove}
|
||||
disabled={!selectedTenantSlug || mutation.isPending}
|
||||
disabled={
|
||||
!selectedTenantSlug ||
|
||||
mutation.isPending ||
|
||||
(!!schemaWarnings && !acknowledgeWarning)
|
||||
}
|
||||
>
|
||||
{mutation.isPending && (
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
|
||||
@@ -539,6 +539,30 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
|
||||
}
|
||||
|
||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
|
||||
if profile.ID == userID {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from admin role")
|
||||
}
|
||||
}
|
||||
|
||||
if h.Keto != nil {
|
||||
if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", ""); err == nil {
|
||||
adminCount := 0
|
||||
isTargetAdmin := false
|
||||
for _, rel := range relations {
|
||||
if strings.HasPrefix(rel.SubjectID, "User:") {
|
||||
adminCount++
|
||||
if rel.SubjectID == "User:"+userID {
|
||||
isTargetAdmin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if isTargetAdmin && adminCount <= 1 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last admin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if h.KetoOutbox != nil {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
@@ -646,6 +670,30 @@ func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
|
||||
}
|
||||
|
||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
|
||||
if profile.ID == userID {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from owner role")
|
||||
}
|
||||
}
|
||||
|
||||
if h.Keto != nil {
|
||||
if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", ""); err == nil {
|
||||
ownerCount := 0
|
||||
isTargetOwner := false
|
||||
for _, rel := range relations {
|
||||
if strings.HasPrefix(rel.SubjectID, "User:") {
|
||||
ownerCount++
|
||||
if rel.SubjectID == "User:"+userID {
|
||||
isTargetOwner = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if isTargetOwner && ownerCount <= 1 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last owner")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if h.KetoOutbox != nil {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -1463,6 +1464,87 @@ func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema [
|
||||
return errors.New("field " + key + " is admin only")
|
||||
}
|
||||
|
||||
// Type validation
|
||||
if expectedType, ok := config["type"].(string); ok && expectedType != "" && val != nil && val != "" {
|
||||
switch expectedType {
|
||||
case "number":
|
||||
var numVal float64
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
numVal = v
|
||||
case int:
|
||||
numVal = float64(v)
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return errors.New("field " + key + " must be a number")
|
||||
}
|
||||
numVal = parsed
|
||||
default:
|
||||
return errors.New("field " + key + " must be a number")
|
||||
}
|
||||
if float64(int(numVal)) != numVal {
|
||||
return errors.New("field " + key + " must be an integer")
|
||||
}
|
||||
if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 {
|
||||
return errors.New("field " + key + " must be an unsigned integer")
|
||||
}
|
||||
case "float":
|
||||
var numVal float64
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
numVal = v
|
||||
case int:
|
||||
numVal = float64(v)
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return errors.New("field " + key + " must be a float")
|
||||
}
|
||||
numVal = parsed
|
||||
default:
|
||||
return errors.New("field " + key + " must be a float")
|
||||
}
|
||||
if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 {
|
||||
return errors.New("field " + key + " must be an unsigned float")
|
||||
}
|
||||
case "boolean":
|
||||
switch v := val.(type) {
|
||||
case bool:
|
||||
// ok
|
||||
case string:
|
||||
if v != "true" && v != "false" {
|
||||
return errors.New("field " + key + " must be a boolean")
|
||||
}
|
||||
default:
|
||||
return errors.New("field " + key + " must be a boolean")
|
||||
}
|
||||
case "date":
|
||||
if strVal, ok := val.(string); ok {
|
||||
if _, err := time.Parse("2006-01-02", strVal); err != nil {
|
||||
return errors.New("field " + key + " must be a valid date (YYYY-MM-DD)")
|
||||
}
|
||||
} else {
|
||||
return errors.New("field " + key + " must be a date string")
|
||||
}
|
||||
case "datetime":
|
||||
if strVal, ok := val.(string); ok {
|
||||
_, err1 := time.Parse(time.RFC3339, strVal)
|
||||
_, err2 := time.Parse("2006-01-02T15:04", strVal)
|
||||
_, err3 := time.Parse("2006-01-02T15:04:05", strVal)
|
||||
if err1 != nil && err2 != nil && err3 != nil {
|
||||
return errors.New("field " + key + " must be a valid datetime")
|
||||
}
|
||||
} else {
|
||||
return errors.New("field " + key + " must be a datetime string")
|
||||
}
|
||||
case "text":
|
||||
if _, ok := val.(string); !ok {
|
||||
return errors.New("field " + key + " must be a string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regex validation
|
||||
if regexStr, ok := config["validation"].(string); ok && regexStr != "" {
|
||||
strVal := ""
|
||||
|
||||
43
fix_toml.py
Normal file
43
fix_toml.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
def fix_toml(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
sections = {}
|
||||
current_section = None
|
||||
|
||||
for line in lines:
|
||||
sline = line.strip()
|
||||
|
||||
if sline.startswith('[') and sline.endswith(']'):
|
||||
current_section = sline
|
||||
if current_section not in sections:
|
||||
sections[current_section] = []
|
||||
else:
|
||||
if current_section is None:
|
||||
if sline: # Ignore empty lines at the very top before any section
|
||||
current_section = "_TOP_"
|
||||
if current_section not in sections:
|
||||
sections[current_section] = []
|
||||
if current_section is not None:
|
||||
sections[current_section].append(line)
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
if "_TOP_" in sections:
|
||||
for line in sections["_TOP_"]:
|
||||
f.write(line)
|
||||
del sections["_TOP_"]
|
||||
|
||||
for sec, slines in sections.items():
|
||||
f.write(sec + '\n')
|
||||
for line in slines:
|
||||
if line.strip(): # keep only non-empty to avoid huge gaps
|
||||
f.write(line)
|
||||
f.write('\n')
|
||||
|
||||
for file in ['locales/template.toml', 'locales/ko.toml', 'locales/en.toml']:
|
||||
fix_toml(file)
|
||||
print("Fixed", file)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
[domain]
|
||||
|
||||
[domain.affiliation]
|
||||
@@ -202,6 +201,8 @@ empty = "Empty"
|
||||
remove_confirm = "Remove Confirm"
|
||||
remove_success = "Remove Success"
|
||||
subtitle = "Subtitle"
|
||||
remove_last = "Cannot remove the last admin."
|
||||
remove_self = "Cannot remove yourself."
|
||||
|
||||
[msg.admin.tenants.owners]
|
||||
add_success = "Owner added successfully."
|
||||
@@ -209,6 +210,8 @@ empty = "No owners registered."
|
||||
remove_confirm = "Are you sure you want to remove this owner?"
|
||||
remove_success = "Owner permission revoked."
|
||||
subtitle = "List of owners with top-level permissions for this tenant."
|
||||
remove_last = "Cannot remove the last owner."
|
||||
remove_self = "Cannot remove yourself."
|
||||
|
||||
[msg.admin.tenants.create]
|
||||
subtitle = "Subtitle"
|
||||
@@ -237,6 +240,7 @@ missing_id = "Tenant ID missing"
|
||||
subtitle = "Define custom attributes for users in this tenant."
|
||||
update_error = "Failed to update schema"
|
||||
update_success = "Schema updated successfully"
|
||||
forbidden_desc = "Only administrators can access user schema settings."
|
||||
|
||||
[msg.admin.tenants.sub]
|
||||
empty = "Empty"
|
||||
@@ -253,6 +257,8 @@ move_error = "Error moving users."
|
||||
move_success = "{{count}} users moved successfully."
|
||||
parsed_count = "Parsed {{count}} rows."
|
||||
update_success = "User info updated successfully."
|
||||
schema_incompatible = "Fields not in target schema may be lost:"
|
||||
schema_missing = "Missing required fields for target tenant:"
|
||||
|
||||
[msg.admin.users.create]
|
||||
error = "Failed to User Create."
|
||||
@@ -280,6 +286,7 @@ edit_subtitle = "Edit Subtitle"
|
||||
not_found = "Not Found"
|
||||
update_error = "Failed to User Edit."
|
||||
update_success = "Update Success"
|
||||
password_generated = "A secure password has been generated."
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "Required."
|
||||
@@ -309,6 +316,8 @@ parsing = "Parsing data..."
|
||||
requesting = "Requesting..."
|
||||
saving = "Saving..."
|
||||
unknown_error = "unknown error"
|
||||
copied_to_clipboard = "Copied to clipboard."
|
||||
forbidden = "Access Denied."
|
||||
|
||||
[msg.dev]
|
||||
logout_confirm = "Are you sure you want to log out?"
|
||||
@@ -990,6 +999,9 @@ type_date = "Date"
|
||||
type_number = "Number"
|
||||
type_text = "Text"
|
||||
validation_placeholder = "Regex Pattern (Optional)"
|
||||
type_datetime = "DateTime"
|
||||
type_float = "Float"
|
||||
unsigned = "Unsigned"
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add = "Add"
|
||||
@@ -1029,6 +1041,8 @@ select_group = "Select Target Tenant"
|
||||
selected_count = "{{count}} users selected"
|
||||
start_upload = "Start Upload"
|
||||
title = "Bulk Actions"
|
||||
acknowledge_warning = "I acknowledge the warning and will proceed."
|
||||
schema_warning = "Schema Compatibility Warning"
|
||||
|
||||
[ui.admin.users.create]
|
||||
back = "Back"
|
||||
@@ -1073,6 +1087,7 @@ title = "Title"
|
||||
back = "Back"
|
||||
edit_title = "Edit Title"
|
||||
title = "User Details"
|
||||
generate_password = "Auto Generate"
|
||||
|
||||
[ui.admin.users.detail.breadcrumb]
|
||||
section = "Users"
|
||||
@@ -1138,7 +1153,6 @@ email = "Email"
|
||||
name = "Name"
|
||||
role = "Role"
|
||||
|
||||
|
||||
[ui.common]
|
||||
add = "Add"
|
||||
all = "All"
|
||||
@@ -1191,6 +1205,7 @@ theme_dark = "Dark"
|
||||
theme_light = "Light"
|
||||
theme_toggle = "Theme Toggle"
|
||||
unknown = "Unknown"
|
||||
generate = "Generate"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
@@ -1688,3 +1703,4 @@ verify = "Verify"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "Action"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
[domain]
|
||||
|
||||
[domain.affiliation]
|
||||
@@ -202,6 +201,8 @@ empty = "등록된 관리자가 없습니다."
|
||||
remove_confirm = "관리자를 삭제하시겠습니까?"
|
||||
remove_success = "권한이 회수되었습니다."
|
||||
subtitle = "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다."
|
||||
remove_last = "마지막 관리자는 회수할 수 없습니다."
|
||||
remove_self = "본인의 권한은 회수할 수 없습니다."
|
||||
|
||||
[msg.admin.tenants.owners]
|
||||
add_success = "소유자가 추가되었습니다."
|
||||
@@ -209,6 +210,8 @@ empty = "등록된 소유자가 없습니다."
|
||||
remove_confirm = "소유자를 삭제하시겠습니까?"
|
||||
remove_success = "소유자 권한이 회수되었습니다."
|
||||
subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다."
|
||||
remove_last = "마지막 소유자는 회수할 수 없습니다."
|
||||
remove_self = "본인의 권한은 회수할 수 없습니다."
|
||||
|
||||
[msg.admin.tenants.create]
|
||||
subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다."
|
||||
@@ -237,6 +240,7 @@ missing_id = "테넌트 ID가 없습니다."
|
||||
subtitle = "이 테넌트의 사용자에게 적용할 커스텀 속성을 정의합니다."
|
||||
update_error = "스키마 업데이트에 실패했습니다."
|
||||
update_success = "스키마가 성공적으로 업데이트되었습니다."
|
||||
forbidden_desc = "사용자 스키마 설정은 관리자만 접근할 수 있습니다."
|
||||
|
||||
[msg.admin.tenants.sub]
|
||||
empty = "하위 테넌트가 없습니다."
|
||||
@@ -253,6 +257,8 @@ move_error = "사용자 이동 중 오류가 발생했습니다."
|
||||
move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니다."
|
||||
parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
|
||||
update_success = "사용자 정보가 일괄 업데이트되었습니다."
|
||||
schema_incompatible = "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:"
|
||||
schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:"
|
||||
|
||||
[msg.admin.users.create]
|
||||
error = "사용자 생성에 실패했습니다."
|
||||
@@ -280,6 +286,7 @@ edit_subtitle = "{{email}} 계정의 정보를 수정합니다."
|
||||
not_found = "사용자를 찾을 수 없습니다."
|
||||
update_error = "사용자 수정에 실패했습니다."
|
||||
update_success = "사용자 정보가 수정되었습니다."
|
||||
password_generated = "안전한 비밀번호가 생성되었습니다."
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "필수입니다."
|
||||
@@ -309,6 +316,8 @@ parsing = "데이터 파싱 중..."
|
||||
requesting = "요청 중..."
|
||||
saving = "저장 중..."
|
||||
unknown_error = "알 수 없는 오류"
|
||||
copied_to_clipboard = "클립보드에 복사되었습니다."
|
||||
forbidden = "접근 권한이 없습니다."
|
||||
|
||||
[msg.dev]
|
||||
logout_confirm = "로그아웃 하시겠습니까?"
|
||||
@@ -990,6 +999,9 @@ type_date = "Date"
|
||||
type_number = "Number"
|
||||
type_text = "Text"
|
||||
validation_placeholder = "정규표현식 (선택 사항)"
|
||||
type_datetime = "일시 (DateTime)"
|
||||
type_float = "실수 (Float)"
|
||||
unsigned = "음수 불가"
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add = "하위 테넌트 추가"
|
||||
@@ -1029,6 +1041,8 @@ select_group = "대상 테넌트 선택"
|
||||
selected_count = "{{count}}명 선택됨"
|
||||
start_upload = "업로드 시작"
|
||||
title = "일괄 작업"
|
||||
acknowledge_warning = "경고를 확인했으며 계속 진행합니다."
|
||||
schema_warning = "스키마 호환성 경고"
|
||||
|
||||
[ui.admin.users.create]
|
||||
back = "목록으로 돌아가기"
|
||||
@@ -1073,6 +1087,7 @@ title = "초기 비밀번호 생성 완료"
|
||||
back = "목록으로 돌아가기"
|
||||
edit_title = "정보 수정"
|
||||
title = "사용자 상세"
|
||||
generate_password = "자동 생성"
|
||||
|
||||
[ui.admin.users.detail.breadcrumb]
|
||||
section = "Users"
|
||||
@@ -1138,7 +1153,6 @@ email = "이메일"
|
||||
name = "이름"
|
||||
role = "역할"
|
||||
|
||||
|
||||
[ui.common]
|
||||
add = "추가"
|
||||
all = "전체"
|
||||
@@ -1191,6 +1205,7 @@ theme_dark = "Dark"
|
||||
theme_light = "Light"
|
||||
theme_toggle = "테마 전환"
|
||||
unknown = "Unknown"
|
||||
generate = "생성"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
@@ -1684,3 +1699,4 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "로그인하기"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
[domain]
|
||||
|
||||
[domain.affiliation]
|
||||
@@ -202,6 +201,8 @@ empty = ""
|
||||
remove_confirm = ""
|
||||
remove_success = ""
|
||||
subtitle = ""
|
||||
remove_last = ""
|
||||
remove_self = ""
|
||||
|
||||
[msg.admin.tenants.owners]
|
||||
add_success = ""
|
||||
@@ -209,6 +210,8 @@ empty = ""
|
||||
remove_confirm = ""
|
||||
remove_success = ""
|
||||
subtitle = ""
|
||||
remove_last = ""
|
||||
remove_self = ""
|
||||
|
||||
[msg.admin.tenants.create]
|
||||
subtitle = ""
|
||||
@@ -237,6 +240,7 @@ missing_id = ""
|
||||
subtitle = ""
|
||||
update_error = ""
|
||||
update_success = ""
|
||||
forbidden_desc = ""
|
||||
|
||||
[msg.admin.tenants.sub]
|
||||
empty = ""
|
||||
@@ -253,6 +257,8 @@ move_error = ""
|
||||
move_success = ""
|
||||
parsed_count = ""
|
||||
update_success = ""
|
||||
schema_incompatible = ""
|
||||
schema_missing = ""
|
||||
|
||||
[msg.admin.users.create]
|
||||
error = ""
|
||||
@@ -280,6 +286,7 @@ edit_subtitle = ""
|
||||
not_found = ""
|
||||
update_error = ""
|
||||
update_success = ""
|
||||
password_generated = ""
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = ""
|
||||
@@ -309,6 +316,8 @@ parsing = ""
|
||||
requesting = ""
|
||||
saving = ""
|
||||
unknown_error = ""
|
||||
copied_to_clipboard = ""
|
||||
forbidden = ""
|
||||
|
||||
[msg.dev]
|
||||
logout_confirm = ""
|
||||
@@ -990,6 +999,9 @@ type_date = ""
|
||||
type_number = ""
|
||||
type_text = ""
|
||||
validation_placeholder = ""
|
||||
type_datetime = ""
|
||||
type_float = ""
|
||||
unsigned = ""
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add = ""
|
||||
@@ -1029,6 +1041,8 @@ select_group = ""
|
||||
selected_count = ""
|
||||
start_upload = ""
|
||||
title = ""
|
||||
acknowledge_warning = ""
|
||||
schema_warning = ""
|
||||
|
||||
[ui.admin.users.create]
|
||||
back = ""
|
||||
@@ -1073,6 +1087,7 @@ title = ""
|
||||
back = ""
|
||||
edit_title = ""
|
||||
title = ""
|
||||
generate_password = ""
|
||||
|
||||
[ui.admin.users.detail.breadcrumb]
|
||||
section = ""
|
||||
@@ -1138,7 +1153,6 @@ email = ""
|
||||
name = ""
|
||||
role = ""
|
||||
|
||||
|
||||
[ui.common]
|
||||
add = ""
|
||||
all = ""
|
||||
@@ -1191,6 +1205,7 @@ theme_dark = ""
|
||||
theme_light = ""
|
||||
theme_toggle = ""
|
||||
unknown = ""
|
||||
generate = ""
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = ""
|
||||
@@ -1684,3 +1699,4 @@ verify = ""
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = ""
|
||||
|
||||
|
||||
@@ -224,10 +224,12 @@ test.describe('UserFront WASM password login and reset', () => {
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
await expect
|
||||
.poll(() =>
|
||||
capture.clientLogs.some((message) =>
|
||||
message.includes('password_or_email_mismatch'),
|
||||
),
|
||||
.poll(
|
||||
() =>
|
||||
capture.clientLogs.some((message) =>
|
||||
message.includes('password_or_email_mismatch'),
|
||||
),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
@@ -257,7 +259,10 @@ test.describe('UserFront WASM password login and reset', () => {
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => capture.resetBody?.newPassword as string | undefined)
|
||||
.poll(
|
||||
() => capture.resetBody?.newPassword as string | undefined,
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe('ValidPass1!A');
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/, { timeout: 10_000 });
|
||||
expect(capture.resetToken).toBe('reset-token-e2e');
|
||||
|
||||
Reference in New Issue
Block a user