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