1
0
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:
2026-03-19 17:19:45 +09:00
24 changed files with 2131 additions and 1495 deletions

View File

@@ -12,7 +12,8 @@
"preview": "vite preview",
"test": "playwright test",
"test:unit": "vitest run",
"test:ui": "playwright test --ui"
"test:ui": "playwright test --ui",
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.4",

View File

@@ -5,7 +5,7 @@ const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<div className="relative w-full">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
@@ -69,7 +69,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-foreground align-middle sticky top-0 bg-inherit",
className,
)}
{...props}

View File

@@ -63,8 +63,8 @@ function ApiKeyListPage() {
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>
@@ -103,8 +103,8 @@ function ApiKeyListPage() {
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.api_keys.list.registry.title", "API Key Registry")}
@@ -119,95 +119,102 @@ function ApiKeyListPage() {
</div>
<Badge variant="muted">{t("ui.common.badge.system", "System")}</Badge>
</CardHeader>
<CardContent>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
{errorMsg ?? fallbackError}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.admin.api_keys.list.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}>
{t(
"msg.admin.api_keys.list.empty",
"등록된 API 키가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{items.map((key) => (
<TableRow key={key.id}>
<TableCell className="font-semibold">
<div className="flex items-center gap-2">
<Key size={14} className="text-[var(--color-muted)]" />
{key.name}
</div>
</TableCell>
<TableCell>
<code>{key.client_id}</code>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{key.scopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="text-[10px]"
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.admin.api_keys.list.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}>
{t(
"msg.admin.api_keys.list.empty",
"등록된 API 키가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{items.map((key) => (
<TableRow key={key.id}>
<TableCell className="font-semibold">
<div className="flex items-center gap-2">
<Key
size={14}
className="text-[var(--color-muted)]"
/>
{key.name}
</div>
</TableCell>
<TableCell>
<code>{key.client_id}</code>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{key.scopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="text-[10px]"
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
: t("ui.common.never", "Never")}
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
: t("ui.common.never", "Never")}
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -158,8 +158,8 @@ function AuditLogsPage() {
}
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div>
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>{t("ui.admin.audit.breadcrumb.section", "Audit")}</span>
@@ -194,409 +194,421 @@ function AuditLogsPage() {
</div>
</header>
<div className="space-y-4">
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>
{t("ui.admin.audit.registry.title", "Audit registry")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.audit.registry.count",
"로드된 로그 {{count}}건",
{ count: logs.length },
<Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.audit.registry.title", "Audit registry")}
</CardTitle>
<CardDescription>
{t("msg.admin.audit.registry.count", "로드된 로그 {{count}}건", {
count: logs.length,
})}
</CardDescription>
</div>
<Badge variant="muted">
{t("ui.common.badge.command_only", "Command only")}
</Badge>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
<Search size={14} />
<input
value={filterDraft}
onChange={(event) => setFilterDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleAddFilter();
}
}}
placeholder={t(
"ui.admin.audit.filters.placeholder",
"필터 추가 (예: status:failure)",
)}
</CardDescription>
className="w-full bg-transparent text-sm text-foreground outline-none"
/>
<Button size="sm" variant="outline" onClick={handleAddFilter}>
{t("ui.common.add", "추가")}
</Button>
</div>
<Badge variant="muted">
{t("ui.common.badge.command_only", "Command only")}
</Badge>
</CardHeader>
<CardContent>
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
<Search size={14} />
<input
value={filterDraft}
onChange={(event) => setFilterDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleAddFilter();
{filters.length === 0 ? (
<span className="text-xs text-[var(--color-muted)]">
{t("msg.admin.audit.filters.empty", "필터 없음")}
</span>
) : (
filters.map((filter) => (
<span
key={filter}
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
>
<Terminal size={12} />
{filter}
<button
type="button"
onClick={() =>
setFilters((prev) =>
prev.filter((item) => item !== filter),
)
}
}}
placeholder={t(
"ui.admin.audit.filters.placeholder",
"필터 추가 (예: status:failure)",
)}
className="w-full bg-transparent text-sm text-foreground outline-none"
/>
<Button size="sm" variant="outline" onClick={handleAddFilter}>
{t("ui.common.add", "추가")}
</Button>
</div>
{filters.length === 0 ? (
<span className="text-xs text-[var(--color-muted)]">
{t("msg.admin.audit.filters.empty", "필터 없음")}
</span>
) : (
filters.map((filter) => (
<span
key={filter}
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
aria-label={t(
"ui.admin.audit.filters.remove",
"{{filter}} 필터 제거",
{ filter },
)}
>
<Terminal size={12} />
{filter}
<button
type="button"
onClick={() =>
setFilters((prev) =>
prev.filter((item) => item !== filter),
)
}
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
aria-label={t(
"ui.admin.audit.filters.remove",
"{{filter}} 필터 제거",
{ filter },
)}
>
×
</button>
</span>
))
)}
</div>
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">
{t("ui.admin.audit.table.time", "TIME")}
</TableHead>
<TableHead className="w-[160px]">
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.request", "REQUEST")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.path", "PATH")}
</TableHead>
<TableHead className="w-[120px]">
{t("ui.admin.audit.table.status", "STATUS")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.action_target", "Action / Target")}
</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
×
</button>
</span>
))
)}
</div>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableCell colSpan={7}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!isLoading && logs.length === 0 && (
<TableRow>
<TableCell colSpan={7}>
<TableHead className="w-[140px]">
{t("ui.admin.audit.table.time", "TIME")}
</TableHead>
<TableHead className="w-[160px]">
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.request", "REQUEST")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.path", "PATH")}
</TableHead>
<TableHead className="w-[120px]">
{t("ui.admin.audit.table.status", "STATUS")}
</TableHead>
<TableHead>
{t(
"msg.admin.audit.empty",
"아직 수집된 감사 로그가 없습니다.",
"ui.admin.audit.table.action_target",
"Action / Target",
)}
</TableCell>
</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
)}
{logs.map((row, index) => {
const details = parseDetails(row.details);
const actionLabel =
details.action ||
(details.method && details.path
? `${details.method} ${details.path}`
: row.event_type);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const isExpanded = Boolean(expandedRows[rowKey]);
return (
<React.Fragment key={rowKey}>
<TableRow className="bg-card/40">
<TableCell className="text-xs text-[var(--color-muted)]">
{(() => {
const { date, time } = formatIsoDateTime(
row.timestamp,
);
return (
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
);
})()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{row.user_id || details.actor_id || "-"}
</code>
{(row.user_id || details.actor_id) && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.actor_id",
"Copy actor id",
)}
onClick={() =>
handleCopy(
row.user_id || details.actor_id || "",
)
}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="flex items-start gap-2">
<span className="break-all">
{formatCellValue(details.request_id)}
</span>
{details.request_id && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.request_id",
"Copy request id",
)}
onClick={() =>
handleCopy(details.request_id || "")
}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="font-semibold text-foreground">
{formatCellValue(details.method)}
</div>
<div className="break-all">
{formatCellValue(details.path)}
</div>
</TableCell>
<TableCell>
<Badge
variant={
row.status === "success" || row.status === "ok"
? "success"
: "warning"
}
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="font-semibold text-foreground">
{actionLabel}
</div>
{details.target && (
</TableHeader>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={7}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!isLoading && logs.length === 0 && (
<TableRow>
<TableCell colSpan={7}>
{t(
"msg.admin.audit.empty",
"아직 수집된 감사 로그가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{logs.map((row, index) => {
const details = parseDetails(row.details);
const actionLabel =
details.action ||
(details.method && details.path
? `${details.method} ${details.path}`
: row.event_type);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const isExpanded = Boolean(expandedRows[rowKey]);
return (
<React.Fragment key={rowKey}>
<TableRow className="bg-card/40">
<TableCell className="text-xs text-[var(--color-muted)]">
{(() => {
const { date, time } = formatIsoDateTime(
row.timestamp,
);
return (
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
);
})()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="break-all">
{t(
"ui.admin.audit.target",
"Target · {{target}}",
{
target: details.target,
},
)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.target",
"Copy target",
)}
onClick={() => handleCopy(details.target || "")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !isExpanded,
}))
}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="bg-card/20">
<TableCell colSpan={7} className="text-xs">
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t(
"ui.admin.audit.details.request",
"Request",
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{row.user_id || details.actor_id || "-"}
</code>
{(row.user_id || details.actor_id) && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.actor_id",
"Copy actor id",
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.request_id",
"Request ID · {{value}}",
{
value: formatCellValue(
details.request_id,
),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.event_id",
"Event ID · {{value}}",
{
value: formatCellValue(row.event_id),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.ip",
"IP · {{value}}",
{
value: formatCellValue(row.ip_address),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.admin.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.admin.audit.details.actor_id",
"Actor ID · {{value}}",
{
value:
row.user_id || details.actor_id || "-",
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.tenant",
"Tenant · {{value}}",
{
value: formatCellValue(details.tenant_id),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.device",
"Device · {{value}}",
{
value: formatCellValue(row.device_id),
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.admin.audit.details.result", "Result")}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.error",
"Error · {{value}}",
{
value: formatCellValue(details.error),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.before",
"Before · {{value}}",
{
value: formatCellValue(details.before),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.after",
"After · {{value}}",
{
value: formatCellValue(details.after),
},
)}
</div>
</div>
onClick={() =>
handleCopy(
row.user_id || details.actor_id || "",
)
}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="flex items-start gap-2">
<span className="break-all">
{formatCellValue(details.request_id)}
</span>
{details.request_id && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.request_id",
"Copy request id",
)}
onClick={() =>
handleCopy(details.request_id || "")
}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="font-semibold text-foreground">
{formatCellValue(details.method)}
</div>
<div className="break-all">
{formatCellValue(details.path)}
</div>
</TableCell>
<TableCell>
<Badge
variant={
row.status === "success" || row.status === "ok"
? "success"
: "warning"
}
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="font-semibold text-foreground">
{actionLabel}
</div>
{details.target && (
<div className="flex items-center gap-2">
<span className="break-all">
{t(
"ui.admin.audit.target",
"Target · {{target}}",
{
target: details.target,
},
)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.target",
"Copy target",
)}
onClick={() =>
handleCopy(details.target || "")
}
>
<Copy className="h-3 w-3" />
</Button>
</div>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !isExpanded,
}))
}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
<div className="pt-4 text-center">
{hasNextPage ? (
<Button
variant="outline"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.admin.audit.load_more", "Load more")}
</Button>
) : (
<span className="text-xs text-[var(--color-muted)]">
{t("msg.admin.audit.end", "End of audit feed")}
</span>
)}
{isExpanded && (
<TableRow className="bg-card/20">
<TableCell colSpan={7} className="text-xs">
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t(
"ui.admin.audit.details.request",
"Request",
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.request_id",
"Request ID · {{value}}",
{
value: formatCellValue(
details.request_id,
),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.event_id",
"Event ID · {{value}}",
{
value: formatCellValue(row.event_id),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.ip",
"IP · {{value}}",
{
value: formatCellValue(row.ip_address),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.admin.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.admin.audit.details.actor_id",
"Actor ID · {{value}}",
{
value:
row.user_id ||
details.actor_id ||
"-",
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.tenant",
"Tenant · {{value}}",
{
value: formatCellValue(
details.tenant_id,
),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.device",
"Device · {{value}}",
{
value: formatCellValue(row.device_id),
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t(
"ui.admin.audit.details.result",
"Result",
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.error",
"Error · {{value}}",
{
value: formatCellValue(details.error),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.before",
"Before · {{value}}",
{
value: formatCellValue(details.before),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.after",
"After · {{value}}",
{
value: formatCellValue(details.after),
},
)}
</div>
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
</div>
<div className="pt-4 text-center flex-shrink-0">
{hasNextPage ? (
<Button
variant="outline"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.admin.audit.load_more", "Load more")}
</Button>
) : (
<span className="text-xs text-[var(--color-muted)]">
{t("msg.admin.audit.end", "End of audit feed")}
</span>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
Users,
} from "lucide-react";
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "../../../components/ui/badge";
@@ -52,6 +53,8 @@ import { t } from "../../../lib/i18n";
type DialogMode = "owner" | "admin";
export function TenantAdminsAndOwnersTab() {
const auth = useAuth();
const currentUserId = auth.user?.profile.sub;
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
@@ -204,218 +207,266 @@ export function TenantAdminsAndOwnersTab() {
);
return (
<div className="space-y-8 mt-6">
{/* Owners Card */}
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<Crown className="h-6 w-6 text-yellow-500" />
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.owners.subtitle",
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("owner")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
</Button>
</CardHeader>
<CardContent>
<div className="rounded-xl border border-border overflow-hidden">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.owners.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.owners.table_email", "이메일")}
</TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.owners.table_actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ownersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentOwners.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.owners.empty",
"등록된 소유자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentOwners.map((owner) => (
<TableRow
key={owner.id}
className="hover:bg-muted/30 transition-colors group"
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{owner.name.charAt(0)}
</div>
<span>{owner.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{owner.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
onClick={() =>
handleRemoveOwner(owner.id, owner.name)
}
disabled={removeOwnerMutation.isPending}
title={t(
"ui.admin.tenants.owners.remove_title",
"소유자 권한 회수",
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
{/* Owners Card */}
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<Crown className="h-6 w-6 text-yellow-500" />
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.owners.subtitle",
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("owner")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
</Button>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.owners.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.owners.table_email", "이메일")}
</TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.owners.table_actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ownersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentOwners.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.owners.empty",
"등록된 소유자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentOwners.map((owner) => (
<TableRow
key={owner.id}
className="hover:bg-muted/30 transition-colors group"
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{owner.name.charAt(0)}
</div>
<span>{owner.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{owner.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className={`opacity-0 group-hover:opacity-100 transition-all ${
owner.id === currentUserId ||
currentOwners.length <= 1
? "opacity-50 cursor-not-allowed"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}`}
onClick={() =>
handleRemoveOwner(owner.id, owner.name)
}
disabled={
removeOwnerMutation.isPending ||
owner.id === currentUserId ||
currentOwners.length <= 1
}
title={
owner.id === currentUserId
? t(
"msg.admin.tenants.owners.remove_self",
"본인의 권한은 회수할 수 없습니다.",
)
: currentOwners.length <= 1
? t(
"msg.admin.tenants.owners.remove_last",
"마지막 소유자는 회수할 수 없습니다.",
)
: t(
"ui.admin.tenants.owners.remove_title",
"소유자 권한 회수",
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
{/* Admins Card */}
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.admins.subtitle",
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("admin")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
</Button>
</CardHeader>
<CardContent>
<div className="rounded-xl border border-border overflow-hidden">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.admins.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.admins.table_email", "이메일")}
</TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.admins.table_actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.isLoading ? (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentAdmins.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.admins.empty",
"등록된 관리자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentAdmins.map((admin) => (
<TableRow
key={admin.id}
className="hover:bg-muted/30 transition-colors group"
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{admin.name.charAt(0)}
</div>
<span>{admin.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{admin.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
onClick={() =>
handleRemoveAdmin(admin.id, admin.name)
}
disabled={removeAdminMutation.isPending}
title={t(
"ui.admin.tenants.admins.remove_title",
"관리자 권한 회수",
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
{/* Admins Card */}
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.admins.subtitle",
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("admin")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
</Button>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.admins.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.admins.table_email", "이메일")}
</TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.admins.table_actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.isLoading ? (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentAdmins.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.admins.empty",
"등록된 관리자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentAdmins.map((admin) => (
<TableRow
key={admin.id}
className="hover:bg-muted/30 transition-colors group"
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{admin.name.charAt(0)}
</div>
<span>{admin.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{admin.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className={`opacity-0 group-hover:opacity-100 transition-all ${
admin.id === currentUserId ||
currentAdmins.length <= 1
? "opacity-50 cursor-not-allowed"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}`}
onClick={() =>
handleRemoveAdmin(admin.id, admin.name)
}
disabled={
removeAdminMutation.isPending ||
admin.id === currentUserId ||
currentAdmins.length <= 1
}
title={
admin.id === currentUserId
? t(
"msg.admin.tenants.admins.remove_self",
"본인의 권한은 회수할 수 없습니다.",
)
: currentAdmins.length <= 1
? t(
"msg.admin.tenants.admins.remove_last",
"마지막 관리자는 회수할 수 없습니다.",
)
: t(
"ui.admin.tenants.admins.remove_title",
"관리자 권한 회수",
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Common Dialog for adding users */}
<Dialog

View File

@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { fetchTenant } from "../../../lib/adminApi";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantDetailPage() {
@@ -16,6 +16,14 @@ function TenantDetailPage() {
enabled: tenantId.length > 0,
});
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const canAccessSchema =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
const isFederationTab = location.pathname.includes("/federation");
const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization");
@@ -98,16 +106,18 @@ function TenantDetailPage() {
>
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</Link>
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
location.pathname.includes("/schema")
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
{canAccessSchema && (
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
location.pathname.includes("/schema")
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
)}
</div>
{/* Outlet for nested routes */}

View File

@@ -343,11 +343,11 @@ function TenantGroupsPage() {
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return (
<div className="space-y-6 mt-6">
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
{/* 그룹 생성 폼 */}
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader>
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader className="flex-shrink-0">
<CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")}
@@ -359,7 +359,7 @@ function TenantGroupsPage() {
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-4 flex-1 overflow-auto">
<div className="space-y-1">
<Label htmlFor="name">
{t("ui.admin.groups.form.name_label", "그룹 이름")}
@@ -431,8 +431,8 @@ function TenantGroupsPage() {
</Card>
{/* 그룹 목록 (트리 뷰) */}
<Card className="bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.groups.list.title", "User Groups")}
@@ -458,76 +458,80 @@ function TenantGroupsPage() {
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.admin.groups.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.groups.table.members", "MEMBERS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.isLoading && (
<TableRow>
<TableCell colSpan={3}>
{t("msg.admin.groups.list.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!groupsQuery.isLoading && groupTree.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.list.empty",
"아직 등록된 그룹이 없습니다.",
)}
</TableCell>
</TableRow>
)}
{groupTree.map((node) => (
<UserGroupTreeNode
key={node.id}
node={node}
level={0}
onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId}
onDelete={(id) => {
if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
"그룹을 삭제하시겠습니까?",
),
)
) {
deleteMutation.mutate(id);
}
}}
onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
/>
))}
</TableBody>
</Table>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.admin.groups.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.groups.table.members", "MEMBERS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.isLoading && (
<TableRow>
<TableCell colSpan={3}>
{t("msg.admin.groups.list.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!groupsQuery.isLoading && groupTree.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.list.empty",
"아직 등록된 그룹이 없습니다.",
)}
</TableCell>
</TableRow>
)}
{groupTree.map((node) => (
<UserGroupTreeNode
key={node.id}
node={node}
level={0}
onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId}
onDelete={(id) => {
if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
"그룹을 삭제하시겠습니까?",
),
)
) {
deleteMutation.mutate(id);
}
}}
onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
/>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
{currentGroup && (
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader>
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader className="flex-shrink-0">
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" />
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
@@ -541,8 +545,8 @@ function TenantGroupsPage() {
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex justify-end mb-4">
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex justify-end mb-4 flex-shrink-0">
<Button
size="sm"
onClick={() => handleAddMember(currentGroup.id)}
@@ -552,56 +556,65 @@ function TenantGroupsPage() {
{t("ui.common.add", "멤버 추가")}
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.admin.groups.members.table.name", "이름")}
</TableHead>
<TableHead>
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.remove", "제거")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-4 text-muted-foreground"
>
{t("msg.admin.groups.members.empty", "멤버가 없습니다.")}
</TableCell>
</TableRow>
)}
{currentGroup.members?.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">
{user.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
removeMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
})
}
disabled={removeMemberMutation.isPending}
>
<UserMinus size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.admin.groups.members.table.name", "이름")}
</TableHead>
<TableHead>
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.remove", "제거")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-4 text-muted-foreground"
>
{t(
"msg.admin.groups.members.empty",
"멤버가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{currentGroup.members?.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">
{user.name}
</TableCell>
<TableCell className="text-muted-foreground">
{user.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
removeMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
})
}
disabled={removeMemberMutation.isPending}
>
<UserMinus size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
)}

View File

@@ -116,8 +116,8 @@ function TenantListPage() {
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>{t("ui.admin.tenants.breadcrumb.section", "Tenants")}</span>
@@ -156,8 +156,8 @@ function TenantListPage() {
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
@@ -172,120 +172,132 @@ function TenantListPage() {
{t("ui.common.badge.admin_only", "Admin only")}
</Badge>
</CardHeader>
<CardContent>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
{errorMsg ?? fallbackError}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.type", "TYPE")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.status", "STATUS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.members", "MEMBERS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.updated", "UPDATED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={7}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && tenants.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.empty",
"아직 등록된 테넌트가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{tenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">{tenant.name}</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] font-mono">
{t(
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
tenant.type,
)}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{tenant.slug}
</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell className="font-medium">
{tenant.memberCount}
</TableCell>
<TableCell className="text-xs">
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.type", "TYPE")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.status", "STATUS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.members", "MEMBERS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.updated", "UPDATED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={7}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && tenants.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className="text-center py-8 text-muted-foreground"
>
<Pencil size={14} />
{t("ui.common.edit", "편집")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(tenant.id, tenant.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{t(
"msg.admin.tenants.empty",
"아직 등록된 테넌트가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{tenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">
{tenant.name}
</TableCell>
<TableCell>
<Badge
variant="outline"
className="text-[10px] font-mono"
>
{t(
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
tenant.type,
)}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{tenant.slug}
</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
>
{t(
`ui.common.status.${tenant.status}`,
tenant.status,
)}
</Badge>
</TableCell>
<TableCell className="font-medium">
{tenant.memberCount}
</TableCell>
<TableCell className="text-xs">
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<Pencil size={14} />
{t("ui.common.edit", "편집")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(tenant.id, tenant.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -14,10 +14,16 @@ import {
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type SchemaFieldType = "text" | "number" | "boolean" | "date";
type SchemaFieldType =
| "text"
| "number"
| "boolean"
| "date"
| "float"
| "datetime";
type SchemaField = {
id: string;
@@ -27,6 +33,7 @@ type SchemaField = {
required: boolean;
adminOnly: boolean;
validation?: string;
unsigned?: boolean;
};
function createFieldId() {
@@ -40,6 +47,38 @@ export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const canAccess =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
if (isProfileLoading) {
return (
<div className="p-8 text-center animate-pulse text-muted-foreground">
{t("msg.common.loading", "로딩 중...")}
</div>
);
}
if (!canAccess) {
return (
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
<h3 className="text-xl font-bold text-destructive">
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<p className="text-muted-foreground">
{t(
"msg.admin.tenants.schema.forbidden_desc",
"사용자 스키마 설정은 관리자만 접근할 수 있습니다.",
)}
</p>
</div>
);
}
if (!tenantId) {
return (
<div className="p-8 text-center text-muted-foreground">
@@ -66,13 +105,16 @@ export function TenantSchemaPage() {
type:
field?.type === "number" ||
field?.type === "boolean" ||
field?.type === "date"
field?.type === "date" ||
field?.type === "float" ||
field?.type === "datetime"
? field.type
: "text",
required: Boolean(field?.required),
adminOnly: Boolean(field?.adminOnly),
validation:
typeof field?.validation === "string" ? field.validation : "",
unsigned: Boolean(field?.unsigned),
})),
);
}
@@ -114,6 +156,7 @@ export function TenantSchemaPage() {
required: false,
adminOnly: false,
validation: "",
unsigned: false,
},
]);
};
@@ -210,9 +253,13 @@ export function TenantSchemaPage() {
nextType === "text" ||
nextType === "number" ||
nextType === "boolean" ||
nextType === "date"
nextType === "date" ||
nextType === "float" ||
nextType === "datetime"
) {
updateField(index, { type: nextType });
updateField(index, {
type: nextType as SchemaFieldType,
});
}
}}
>
@@ -225,7 +272,13 @@ export function TenantSchemaPage() {
<option value="number">
{t(
"ui.admin.tenants.schema.field.type_number",
"숫자 (Number)",
"숫자 (Integer)",
)}
</option>
<option value="float">
{t(
"ui.admin.tenants.schema.field.type_float",
"실수 (Float)",
)}
</option>
<option value="boolean">
@@ -240,12 +293,18 @@ export function TenantSchemaPage() {
"날짜 (Date)",
)}
</option>
<option value="datetime">
{t(
"ui.admin.tenants.schema.field.type_datetime",
"일시 (DateTime)",
)}
</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
<div className="flex items-center gap-6">
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@@ -275,6 +334,24 @@ export function TenantSchemaPage() {
)}
</span>
</label>
{(field.type === "number" || field.type === "float") && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={field.unsigned}
onChange={(e) =>
updateField(index, { unsigned: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium">
{t(
"ui.admin.tenants.schema.field.unsigned",
"음수 불가",
)}
</span>
</label>
)}
</div>
<div className="space-y-2">
<Input

View File

@@ -34,8 +34,8 @@ function TenantSubTenantsPage() {
const subTenants = data?.items ?? [];
return (
<Card className="mt-6 bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
@@ -57,64 +57,73 @@ function TenantSubTenantsPage() {
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.admin.tenants.sub.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.status", "STATUS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.tenants.sub.table.action", "ACTION")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subTenants.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
{t("msg.admin.tenants.sub.empty", "하위 테넌트가 없습니다.")}
</TableCell>
</TableRow>
)}
{subTenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">{tenant.name}</TableCell>
<TableCell className="text-xs font-mono">
{tenant.slug}
</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active" ? "default" : "secondary"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
<ArrowRight size={12} className="ml-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.admin.tenants.sub.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.status", "STATUS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.tenants.sub.table.action", "ACTION")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subTenants.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.sub.empty",
"하위 테넌트가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{subTenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">
{tenant.name}
</TableCell>
<TableCell className="text-xs font-mono">
{tenant.slug}
</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active" ? "default" : "secondary"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
<ArrowRight size={12} className="ml-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);

View File

@@ -42,8 +42,8 @@ function TenantUsersPage() {
const users = usersQuery.data?.items ?? [];
return (
<Card className="mt-6 bg-[var(--color-panel)]">
<CardHeader>
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex-shrink-0">
<CardTitle className="flex items-center gap-2">
<User size={18} className="text-primary" />
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
@@ -51,66 +51,70 @@ function TenantUsersPage() {
})}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.admin.tenants.members.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.email", "EMAIL")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.members.empty",
"소속된 사용자가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-semibold">{user.name}</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{t(
`ui.common.role.${user.role}`,
user.role.replace("_", " "),
)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={user.status === "active" ? "default" : "muted"}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.admin.tenants.members.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.email", "EMAIL")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.members.empty",
"소속된 사용자가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-semibold">{user.name}</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{t(
`ui.common.role.${user.role}`,
user.role.replace("_", " "),
)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={user.status === "active" ? "default" : "muted"}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);

View File

@@ -35,8 +35,8 @@ export default function GlobalUserGroupListPage() {
return <div className="p-8">Loading tenants and groups...</div>;
return (
<div className="space-y-8">
<header className="flex items-start justify-between">
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex items-start justify-between flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
<p className="text-muted-foreground">
@@ -46,7 +46,7 @@ export default function GlobalUserGroupListPage() {
</div>
</header>
<div className="grid gap-6">
<div className="grid gap-6 flex-1 overflow-auto p-1">
{tenantList?.items.map((tenant) => (
<TenantGroupCard key={tenant.id} tenant={tenant} />
))}
@@ -62,8 +62,8 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-xl flex items-center gap-2">
<Building2 size={20} className="text-muted-foreground" />
@@ -83,62 +83,66 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"> </TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
Loading...
</TableCell>
</TableRow>
) : groups?.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground py-4"
>
.
</TableCell>
</TableRow>
) : (
groups?.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Users size={14} className="text-primary" />
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
className="hover:underline"
>
{group.name}
</Link>
</div>
</TableCell>
<TableCell>{group.description || "-"}</TableCell>
<TableCell>{group.members?.length || 0} </TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" asChild>
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
>
</Link>
</Button>
</TableCell>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[250px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"> </TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
))
)}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
Loading...
</TableCell>
</TableRow>
) : groups?.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground py-4"
>
.
</TableCell>
</TableRow>
) : (
groups?.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Users size={14} className="text-primary" />
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
className="hover:underline"
>
{group.name}
</Link>
</div>
</TableCell>
<TableCell>{group.description || "-"}</TableCell>
<TableCell>{group.members?.length || 0} </TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" asChild>
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
>
</Link>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);

View File

@@ -228,7 +228,7 @@ const MemberTable: React.FC<{
}> = ({ members, isLoading, onRefresh, showTenant }) => (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader className="bg-muted/30">
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="h-9">
{t("ui.admin.users.table.name", "NAME")}
@@ -929,9 +929,9 @@ function TenantUserGroupsTab() {
const BaseIcon = getTenantIcon(currentBase.type);
return (
<div className="space-y-6 mt-6">
<Card className="bg-[var(--color-panel)] border-none shadow-sm overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between border-b bg-muted/5 py-4">
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] border-none shadow-sm overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between border-b bg-muted/5 py-4 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-xl font-bold flex items-center gap-2">
<BaseIcon size={20} className="text-primary" />
@@ -1078,7 +1078,7 @@ function TenantUserGroupsTab() {
</Dialog>
</div>
</CardHeader>
<div className="px-6 py-3 bg-muted/5 border-b flex items-center gap-4">
<div className="px-6 py-3 bg-muted/5 border-b flex items-center gap-4 flex-shrink-0">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
@@ -1102,39 +1102,43 @@ function TenantUserGroupsTab() {
</Button>
)}
</div>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-muted/5">
<TableRow>
<TableHead className="pl-6 w-[40%]">
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead className="hidden md:table-cell">
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.members", "MEMBERS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.status", "STATUS")}
</TableHead>
<TableHead className="text-right pr-6">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TenantTreeRow
node={currentBase}
level={0}
isRoot={true}
onRemove={handleRemove}
onMove={handleMove}
isUpdating={updateParentMutation.isPending}
searchTerm={treeSearchTerm}
/>
</TableBody>
</Table>
<CardContent className="flex-1 flex flex-col min-h-0 p-0">
<div className="flex-1 rounded-md border-0 overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="pl-6 w-[40%]">
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead className="hidden md:table-cell">
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.members", "MEMBERS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.status", "STATUS")}
</TableHead>
<TableHead className="text-right pr-6">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TenantTreeRow
node={currentBase}
level={0}
isRoot={true}
onRemove={handleRemove}
onMove={handleMove}
isUpdating={updateParentMutation.isPending}
searchTerm={treeSearchTerm}
/>
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -211,8 +211,8 @@ export function UserGroupDetailPage() {
);
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-8 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link
@@ -260,10 +260,10 @@ export function UserGroupDetailPage() {
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
{/* Members Management */}
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.groups.detail.members_title", "구성원 관리")}
@@ -347,88 +347,90 @@ export function UserGroupDetailPage() {
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<div className="rounded-md border border-border overflow-hidden">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="font-bold">
{t("ui.admin.users.list.table.name_email", "사용자")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!currentGroup.members ||
currentGroup.members.length === 0 ? (
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.members.empty",
"구성원이 없습니다.",
)}
</TableCell>
<TableHead className="font-bold">
{t("ui.admin.users.list.table.name_email", "사용자")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow>
) : (
currentGroup.members.map((member) => (
<TableRow
key={member.id}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{member.name.charAt(0)}
</div>
<div>
<p className="font-medium text-sm">
{member.name}
</p>
<p className="text-xs text-muted-foreground">
{member.email}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm(
t(
"msg.admin.groups.members.remove_confirm",
"제거하시겠습니까?",
{ name: member.name },
),
)
) {
removeMemberMutation.mutate(member.id);
}
}}
>
<Trash2 size={14} />
</Button>
</TableHeader>
<TableBody>
{!currentGroup.members ||
currentGroup.members.length === 0 ? (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.members.empty",
"구성원이 없습니다.",
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
) : (
currentGroup.members.map((member) => (
<TableRow
key={member.id}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{member.name.charAt(0)}
</div>
<div>
<p className="font-medium text-sm">
{member.name}
</p>
<p className="text-xs text-muted-foreground">
{member.email}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm(
t(
"msg.admin.groups.members.remove_confirm",
"제거하시겠습니까?",
{ name: member.name },
),
)
) {
removeMemberMutation.mutate(member.id);
}
}}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
{/* Roles/Permissions Management (Keto Based) */}
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.groups.detail.permissions_title", "권한 관리")}
@@ -530,86 +532,88 @@ export function UserGroupDetailPage() {
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<div className="rounded-md border border-border overflow-hidden">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="font-bold">
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.users.detail.form.role", "역할")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isRolesLoading ? (
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableCell colSpan={3} className="text-center py-8">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
</TableCell>
<TableHead className="font-bold">
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.users.detail.form.role", "역할")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow>
) : !groupRoles || groupRoles.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.roles.empty",
"할당된 역할이 없습니다.",
)}
</TableCell>
</TableRow>
) : (
groupRoles.map((role, idx) => (
<TableRow
key={`${role.tenantId}-${role.relation}-${idx}`}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="font-medium text-sm">
{role.tenantName || role.tenantId}
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className="capitalize font-normal"
>
{role.relation}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm(
t("msg.admin.groups.roles.remove_confirm"),
)
) {
removeRoleMutation.mutate({
targetTenantId: role.tenantId,
relation: role.relation,
});
}
}}
>
<Trash2 size={14} />
</Button>
</TableHeader>
<TableBody>
{isRolesLoading ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-8">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
) : !groupRoles || groupRoles.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.roles.empty",
"할당된 역할이 없습니다.",
)}
</TableCell>
</TableRow>
) : (
groupRoles.map((role, idx) => (
<TableRow
key={`${role.tenantId}-${role.relation}-${idx}`}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="font-medium text-sm">
{role.tenantName || role.tenantId}
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className="capitalize font-normal"
>
{role.relation}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm(
t("msg.admin.groups.roles.remove_confirm"),
)
) {
removeRoleMutation.mutate({
targetTenantId: role.tenantId,
relation: role.relation,
});
}
}}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>

View File

@@ -4,6 +4,10 @@ import {
ArrowLeft,
BadgeCheck,
Building2,
Copy,
Dices,
Eye,
EyeOff,
Loader2,
Save,
Users,
@@ -15,6 +19,7 @@ import {
useForm,
} from "react-hook-form";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -36,6 +41,19 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
// Utility for secure password generation
function generateSecurePassword(length = 16) {
const charset =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+~`|}{[]:;?><,./-=";
let retVal = "";
const values = new Uint32Array(length);
crypto.getRandomValues(values);
for (let i = 0; i < length; i++) {
retVal += charset.charAt(values[i] % charset.length);
}
return retVal;
}
type UserSchemaField = {
key: string;
label?: string;
@@ -148,6 +166,7 @@ function UserDetailPage() {
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
const [showPassword, setShowPassword] = React.useState(false);
const { data: profile } = useQuery({
queryKey: ["me"],
@@ -175,6 +194,7 @@ function UserDetailPage() {
handleSubmit,
reset,
watch,
setValue,
formState: { errors },
} = useForm<UserFormValues>({
defaultValues: {
@@ -194,6 +214,28 @@ function UserDetailPage() {
const isAdmin =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
const handleGeneratePassword = () => {
const newPass = generateSecurePassword();
setValue("password", newPass);
setShowPassword(true);
toast.success(
t(
"msg.admin.users.detail.password_generated",
"안전한 비밀번호가 생성되었습니다.",
),
);
};
const handleCopyPassword = () => {
const pass = watch("password");
if (pass) {
navigator.clipboard.writeText(pass);
toast.success(
t("msg.common.copied_to_clipboard", "클립보드에 복사되었습니다."),
);
}
};
React.useEffect(() => {
if (user) {
reset({
@@ -556,15 +598,49 @@ function UserDetailPage() {
"비밀번호 변경",
)}
</Label>
<Input
id="password"
type="password"
placeholder={t(
"ui.admin.users.detail.security.password_placeholder",
"변경할 경우에만 입력",
)}
{...register("password")}
/>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={t(
"ui.admin.users.detail.security.password_placeholder",
"변경할 경우에만 입력",
)}
className="font-mono"
{...register("password")}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<Button
type="button"
variant="outline"
onClick={handleGeneratePassword}
title={t(
"ui.admin.users.detail.generate_password",
"자동 생성",
)}
>
<Dices size={16} className="mr-2" />
{t("ui.common.generate", "생성")}
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyPassword}
disabled={!watch("password")}
title={t("ui.common.copy", "복사")}
>
<Copy size={16} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.users.detail.security.password_hint",

View File

@@ -254,8 +254,8 @@ function UserListPage() {
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>{t("ui.admin.users.list.breadcrumb.section", "Users")}</span>
@@ -353,8 +353,8 @@ function UserListPage() {
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.users.list.registry.title", "User Registry")}
@@ -368,8 +368,8 @@ function UserListPage() {
</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="mb-6 flex flex-wrap items-center gap-4">
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-6 flex flex-wrap items-center gap-4 flex-shrink-0">
<div className="relative flex-1 min-w-[240px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
@@ -412,167 +412,175 @@ function UserListPage() {
</div>
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
{errorMsg ?? fallbackError}
</div>
)}
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={
items.length > 0 &&
selectedUserIds.length === items.length
}
onChange={toggleSelectAll}
/>
</TableHead>
<TableHead className="min-w-[200px]">
{t("ui.admin.users.list.table.name_email", "NAME / EMAIL")}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.status", "STATUS")}
</TableHead>
<TableHead>
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<TableHead key={field.key} className="uppercase">
{field.label}
</TableHead>
),
)}
<TableHead>
{t("ui.admin.users.list.table.created", "CREATED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.users.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableCell
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
{t("msg.admin.users.list.empty", "검색 결과가 없습니다.")}
</TableCell>
</TableRow>
)}
{items.map((user) => (
<TableRow
key={user.id}
className={
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
}
>
<TableCell>
<TableHead className="w-12">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={selectedUserIds.includes(user.id)}
onChange={() => toggleSelectUser(user.id)}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{t(`ui.admin.role.${user.role}`, user.role)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "secondary"
checked={
items.length > 0 &&
selectedUserIds.length === items.length
}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium text-blue-600">
{user.tenant?.name || user.companyCode || "-"}
</span>
<span className="text-xs text-muted-foreground">
{user.department || "-"}
</span>
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
onChange={toggleSelectAll}
/>
</TableHead>
<TableHead className="min-w-[200px]">
{t(
"ui.admin.users.list.table.name_email",
"NAME / EMAIL",
)}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.status", "STATUS")}
</TableHead>
<TableHead>
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<TableCell key={field.key} className="text-sm">
{String(user.metadata?.[field.key] ?? "-")}
</TableCell>
<TableHead key={field.key} className="uppercase">
{field.label}
</TableHead>
),
)}
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
>
<Pencil size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
</Button>
</div>
</TableCell>
<TableHead>
{t("ui.admin.users.list.table.created", "CREATED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.users.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell
colSpan={7 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell
colSpan={7 + userSchema.length}
className="h-24 text-center"
>
{t(
"msg.admin.users.list.empty",
"검색 결과가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{items.map((user) => (
<TableRow
key={user.id}
className={
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
}
>
<TableCell>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={selectedUserIds.includes(user.id)}
onChange={() => toggleSelectUser(user.id)}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{t(`ui.admin.role.${user.role}`, user.role)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "secondary"
}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium text-blue-600">
{user.tenant?.name || user.companyCode || "-"}
</span>
<span className="text-xs text-muted-foreground">
{user.department || "-"}
</span>
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<TableCell key={field.key} className="text-sm">
{String(user.metadata?.[field.key] ?? "-")}
</TableCell>
),
)}
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
>
<Pencil size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* Bulk Action Bar */}
@@ -607,6 +615,9 @@ function UserListPage() {
</Button>
<UserBulkMoveGroupModal
userIds={selectedUserIds}
selectedUsers={items.filter((u) =>
selectedUserIds.includes(u.id),
)}
onSuccess={() => {
query.refetch();
setSelectedUserIds([]);
@@ -639,7 +650,7 @@ function UserListPage() {
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-end gap-2">
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
<Button
variant="outline"
size="sm"

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { FolderTree, Loader2, Search } from "lucide-react";
import { AlertTriangle, FolderTree, Loader2, Search } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
@@ -18,19 +18,28 @@ import { ScrollArea } from "../../../components/ui/scroll-area";
import {
type GroupSummary,
type TenantSummary,
type UserSummary,
bulkUpdateUsers,
fetchGroups,
fetchTenants,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type UserSchemaField = {
key: string;
label: string;
required?: boolean;
};
interface UserBulkMoveGroupModalProps {
userIds: string[];
selectedUsers?: UserSummary[];
onSuccess?: () => void;
}
export function UserBulkMoveGroupModal({
userIds,
selectedUsers = [],
onSuccess,
}: UserBulkMoveGroupModalProps) {
const [open, setOpen] = React.useState(false);
@@ -38,6 +47,7 @@ export function UserBulkMoveGroupModal({
React.useState<string>("");
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
const [searchTerm, setSearchTerm] = React.useState("");
const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false);
const queryClient = useQueryClient();
@@ -48,10 +58,11 @@ export function UserBulkMoveGroupModal({
});
const tenants = tenantsData?.items ?? [];
const selectedTenantId = React.useMemo(
() => tenants.find((t) => t.slug === selectedTenantSlug)?.id ?? "",
const selectedTenant = React.useMemo(
() => tenants.find((t) => t.slug === selectedTenantSlug),
[tenants, selectedTenantSlug],
);
const selectedTenantId = selectedTenant?.id ?? "";
const { data: groups, isLoading: isGroupsLoading } = useQuery({
queryKey: ["tenant-groups", selectedTenantId],
@@ -59,6 +70,51 @@ export function UserBulkMoveGroupModal({
enabled: open && !!selectedTenantId,
});
const schemaWarnings = React.useMemo(() => {
if (!selectedTenant || selectedUsers.length === 0) return null;
const targetSchema =
(selectedTenant.config?.userSchema as UserSchemaField[]) || [];
const targetSchemaKeys = new Set(targetSchema.map((f) => f.key));
const requiredKeys = targetSchema
.filter((f) => f.required)
.map((f) => f.key);
const missingRequiredFields = new Set<string>();
const incompatibleFields = new Set<string>();
for (const user of selectedUsers) {
const userMeta = user.metadata || {};
// 1. Check for missing required fields
for (const key of requiredKeys) {
if (
userMeta[key] === undefined ||
userMeta[key] === null ||
userMeta[key] === ""
) {
missingRequiredFields.add(key);
}
}
// 2. Check for fields that exist in user metadata but not in the target schema (data loss)
for (const key of Object.keys(userMeta)) {
if (!targetSchemaKeys.has(key)) {
incompatibleFields.add(key);
}
}
}
if (missingRequiredFields.size === 0 && incompatibleFields.size === 0) {
return null;
}
return {
missing: Array.from(missingRequiredFields),
incompatible: Array.from(incompatibleFields),
};
}, [selectedTenant, selectedUsers]);
const mutation = useMutation({
mutationFn: bulkUpdateUsers,
onSuccess: () => {
@@ -96,7 +152,18 @@ export function UserBulkMoveGroupModal({
}, [groups, searchTerm]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setSelectedTenantSlug("");
setSelectedGroupName("");
setAcknowledgeWarning(false);
setSearchTerm("");
}
}}
>
<DialogTrigger asChild>
<Button
variant="ghost"
@@ -131,6 +198,7 @@ export function UserBulkMoveGroupModal({
onChange={(e) => {
setSelectedTenantSlug(e.target.value);
setSelectedGroupName("");
setAcknowledgeWarning(false);
}}
>
<option value="">{t("ui.common.select", "선택하세요...")}</option>
@@ -195,6 +263,49 @@ export function UserBulkMoveGroupModal({
</ScrollArea>
</div>
)}
{schemaWarnings && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 space-y-2 mt-4 text-sm">
<div className="flex items-center gap-2 text-destructive font-semibold">
<AlertTriangle size={16} />
{t("ui.admin.users.bulk.schema_warning", "스키마 호환성 경고")}
</div>
<div className="text-destructive/80 text-xs">
{schemaWarnings.missing.length > 0 && (
<p>
{t(
"msg.admin.users.bulk.schema_missing",
"대상 테넌트의 필수 필드가 누락되어 있습니다:",
)}{" "}
<strong>{schemaWarnings.missing.join(", ")}</strong>
</p>
)}
{schemaWarnings.incompatible.length > 0 && (
<p>
{t(
"msg.admin.users.bulk.schema_incompatible",
"대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:",
)}{" "}
<strong>{schemaWarnings.incompatible.join(", ")}</strong>
</p>
)}
</div>
<label className="flex items-center gap-2 cursor-pointer mt-2 pt-2 border-t border-destructive/10">
<input
type="checkbox"
checked={acknowledgeWarning}
onChange={(e) => setAcknowledgeWarning(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-destructive focus:ring-destructive"
/>
<span className="font-medium text-destructive/90">
{t(
"ui.admin.users.bulk.acknowledge_warning",
"경고를 확인했으며 계속 진행합니다.",
)}
</span>
</label>
</div>
)}
</div>
<DialogFooter>
@@ -203,7 +314,11 @@ export function UserBulkMoveGroupModal({
</Button>
<Button
onClick={handleMove}
disabled={!selectedTenantSlug || mutation.isPending}
disabled={
!selectedTenantSlug ||
mutation.isPending ||
(!!schemaWarnings && !acknowledgeWarning)
}
>
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />

View File

@@ -539,6 +539,30 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
}
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
if profile.ID == userID {
return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from admin role")
}
}
if h.Keto != nil {
if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", ""); err == nil {
adminCount := 0
isTargetAdmin := false
for _, rel := range relations {
if strings.HasPrefix(rel.SubjectID, "User:") {
adminCount++
if rel.SubjectID == "User:"+userID {
isTargetAdmin = true
}
}
}
if isTargetAdmin && adminCount <= 1 {
return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last admin")
}
}
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
@@ -646,6 +670,30 @@ func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
}
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
if profile.ID == userID {
return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from owner role")
}
}
if h.Keto != nil {
if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", ""); err == nil {
ownerCount := 0
isTargetOwner := false
for _, rel := range relations {
if strings.HasPrefix(rel.SubjectID, "User:") {
ownerCount++
if rel.SubjectID == "User:"+userID {
isTargetOwner = true
}
}
}
if isTargetOwner && ownerCount <= 1 {
return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last owner")
}
}
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",

View File

@@ -13,6 +13,7 @@ import (
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
@@ -1463,6 +1464,87 @@ func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema [
return errors.New("field " + key + " is admin only")
}
// Type validation
if expectedType, ok := config["type"].(string); ok && expectedType != "" && val != nil && val != "" {
switch expectedType {
case "number":
var numVal float64
switch v := val.(type) {
case float64:
numVal = v
case int:
numVal = float64(v)
case string:
parsed, err := strconv.ParseFloat(v, 64)
if err != nil {
return errors.New("field " + key + " must be a number")
}
numVal = parsed
default:
return errors.New("field " + key + " must be a number")
}
if float64(int(numVal)) != numVal {
return errors.New("field " + key + " must be an integer")
}
if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 {
return errors.New("field " + key + " must be an unsigned integer")
}
case "float":
var numVal float64
switch v := val.(type) {
case float64:
numVal = v
case int:
numVal = float64(v)
case string:
parsed, err := strconv.ParseFloat(v, 64)
if err != nil {
return errors.New("field " + key + " must be a float")
}
numVal = parsed
default:
return errors.New("field " + key + " must be a float")
}
if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 {
return errors.New("field " + key + " must be an unsigned float")
}
case "boolean":
switch v := val.(type) {
case bool:
// ok
case string:
if v != "true" && v != "false" {
return errors.New("field " + key + " must be a boolean")
}
default:
return errors.New("field " + key + " must be a boolean")
}
case "date":
if strVal, ok := val.(string); ok {
if _, err := time.Parse("2006-01-02", strVal); err != nil {
return errors.New("field " + key + " must be a valid date (YYYY-MM-DD)")
}
} else {
return errors.New("field " + key + " must be a date string")
}
case "datetime":
if strVal, ok := val.(string); ok {
_, err1 := time.Parse(time.RFC3339, strVal)
_, err2 := time.Parse("2006-01-02T15:04", strVal)
_, err3 := time.Parse("2006-01-02T15:04:05", strVal)
if err1 != nil && err2 != nil && err3 != nil {
return errors.New("field " + key + " must be a valid datetime")
}
} else {
return errors.New("field " + key + " must be a datetime string")
}
case "text":
if _, ok := val.(string); !ok {
return errors.New("field " + key + " must be a string")
}
}
}
// Regex validation
if regexStr, ok := config["validation"].(string); ok && regexStr != "" {
strVal := ""

43
fix_toml.py Normal file
View 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)

View File

@@ -1,4 +1,3 @@
[domain]
[domain.affiliation]
@@ -202,6 +201,8 @@ empty = "Empty"
remove_confirm = "Remove Confirm"
remove_success = "Remove Success"
subtitle = "Subtitle"
remove_last = "Cannot remove the last admin."
remove_self = "Cannot remove yourself."
[msg.admin.tenants.owners]
add_success = "Owner added successfully."
@@ -209,6 +210,8 @@ empty = "No owners registered."
remove_confirm = "Are you sure you want to remove this owner?"
remove_success = "Owner permission revoked."
subtitle = "List of owners with top-level permissions for this tenant."
remove_last = "Cannot remove the last owner."
remove_self = "Cannot remove yourself."
[msg.admin.tenants.create]
subtitle = "Subtitle"
@@ -237,6 +240,7 @@ missing_id = "Tenant ID missing"
subtitle = "Define custom attributes for users in this tenant."
update_error = "Failed to update schema"
update_success = "Schema updated successfully"
forbidden_desc = "Only administrators can access user schema settings."
[msg.admin.tenants.sub]
empty = "Empty"
@@ -253,6 +257,8 @@ move_error = "Error moving users."
move_success = "{{count}} users moved successfully."
parsed_count = "Parsed {{count}} rows."
update_success = "User info updated successfully."
schema_incompatible = "Fields not in target schema may be lost:"
schema_missing = "Missing required fields for target tenant:"
[msg.admin.users.create]
error = "Failed to User Create."
@@ -280,6 +286,7 @@ edit_subtitle = "Edit Subtitle"
not_found = "Not Found"
update_error = "Failed to User Edit."
update_success = "Update Success"
password_generated = "A secure password has been generated."
[msg.admin.users.detail.form]
field_required = "Required."
@@ -309,6 +316,8 @@ parsing = "Parsing data..."
requesting = "Requesting..."
saving = "Saving..."
unknown_error = "unknown error"
copied_to_clipboard = "Copied to clipboard."
forbidden = "Access Denied."
[msg.dev]
logout_confirm = "Are you sure you want to log out?"
@@ -990,6 +999,9 @@ type_date = "Date"
type_number = "Number"
type_text = "Text"
validation_placeholder = "Regex Pattern (Optional)"
type_datetime = "DateTime"
type_float = "Float"
unsigned = "Unsigned"
[ui.admin.tenants.sub]
add = "Add"
@@ -1029,6 +1041,8 @@ select_group = "Select Target Tenant"
selected_count = "{{count}} users selected"
start_upload = "Start Upload"
title = "Bulk Actions"
acknowledge_warning = "I acknowledge the warning and will proceed."
schema_warning = "Schema Compatibility Warning"
[ui.admin.users.create]
back = "Back"
@@ -1073,6 +1087,7 @@ title = "Title"
back = "Back"
edit_title = "Edit Title"
title = "User Details"
generate_password = "Auto Generate"
[ui.admin.users.detail.breadcrumb]
section = "Users"
@@ -1138,7 +1153,6 @@ email = "Email"
name = "Name"
role = "Role"
[ui.common]
add = "Add"
all = "All"
@@ -1191,6 +1205,7 @@ theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "Theme Toggle"
unknown = "Unknown"
generate = "Generate"
[ui.common.badge]
admin_only = "Admin only"
@@ -1688,3 +1703,4 @@ verify = "Verify"
[ui.userfront.signup.success]
action = "Action"

View File

@@ -1,4 +1,3 @@
[domain]
[domain.affiliation]
@@ -202,6 +201,8 @@ empty = "등록된 관리자가 없습니다."
remove_confirm = "관리자를 삭제하시겠습니까?"
remove_success = "권한이 회수되었습니다."
subtitle = "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다."
remove_last = "마지막 관리자는 회수할 수 없습니다."
remove_self = "본인의 권한은 회수할 수 없습니다."
[msg.admin.tenants.owners]
add_success = "소유자가 추가되었습니다."
@@ -209,6 +210,8 @@ empty = "등록된 소유자가 없습니다."
remove_confirm = "소유자를 삭제하시겠습니까?"
remove_success = "소유자 권한이 회수되었습니다."
subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다."
remove_last = "마지막 소유자는 회수할 수 없습니다."
remove_self = "본인의 권한은 회수할 수 없습니다."
[msg.admin.tenants.create]
subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다."
@@ -237,6 +240,7 @@ missing_id = "테넌트 ID가 없습니다."
subtitle = "이 테넌트의 사용자에게 적용할 커스텀 속성을 정의합니다."
update_error = "스키마 업데이트에 실패했습니다."
update_success = "스키마가 성공적으로 업데이트되었습니다."
forbidden_desc = "사용자 스키마 설정은 관리자만 접근할 수 있습니다."
[msg.admin.tenants.sub]
empty = "하위 테넌트가 없습니다."
@@ -253,6 +257,8 @@ move_error = "사용자 이동 중 오류가 발생했습니다."
move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니다."
parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
update_success = "사용자 정보가 일괄 업데이트되었습니다."
schema_incompatible = "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:"
schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:"
[msg.admin.users.create]
error = "사용자 생성에 실패했습니다."
@@ -280,6 +286,7 @@ edit_subtitle = "{{email}} 계정의 정보를 수정합니다."
not_found = "사용자를 찾을 수 없습니다."
update_error = "사용자 수정에 실패했습니다."
update_success = "사용자 정보가 수정되었습니다."
password_generated = "안전한 비밀번호가 생성되었습니다."
[msg.admin.users.detail.form]
field_required = "필수입니다."
@@ -309,6 +316,8 @@ parsing = "데이터 파싱 중..."
requesting = "요청 중..."
saving = "저장 중..."
unknown_error = "알 수 없는 오류"
copied_to_clipboard = "클립보드에 복사되었습니다."
forbidden = "접근 권한이 없습니다."
[msg.dev]
logout_confirm = "로그아웃 하시겠습니까?"
@@ -990,6 +999,9 @@ type_date = "Date"
type_number = "Number"
type_text = "Text"
validation_placeholder = "정규표현식 (선택 사항)"
type_datetime = "일시 (DateTime)"
type_float = "실수 (Float)"
unsigned = "음수 불가"
[ui.admin.tenants.sub]
add = "하위 테넌트 추가"
@@ -1029,6 +1041,8 @@ select_group = "대상 테넌트 선택"
selected_count = "{{count}}명 선택됨"
start_upload = "업로드 시작"
title = "일괄 작업"
acknowledge_warning = "경고를 확인했으며 계속 진행합니다."
schema_warning = "스키마 호환성 경고"
[ui.admin.users.create]
back = "목록으로 돌아가기"
@@ -1073,6 +1087,7 @@ title = "초기 비밀번호 생성 완료"
back = "목록으로 돌아가기"
edit_title = "정보 수정"
title = "사용자 상세"
generate_password = "자동 생성"
[ui.admin.users.detail.breadcrumb]
section = "Users"
@@ -1138,7 +1153,6 @@ email = "이메일"
name = "이름"
role = "역할"
[ui.common]
add = "추가"
all = "전체"
@@ -1191,6 +1205,7 @@ theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "테마 전환"
unknown = "Unknown"
generate = "생성"
[ui.common.badge]
admin_only = "Admin only"
@@ -1684,3 +1699,4 @@ verify = "본인인증"
[ui.userfront.signup.success]
action = "로그인하기"

View File

@@ -1,4 +1,3 @@
[domain]
[domain.affiliation]
@@ -202,6 +201,8 @@ empty = ""
remove_confirm = ""
remove_success = ""
subtitle = ""
remove_last = ""
remove_self = ""
[msg.admin.tenants.owners]
add_success = ""
@@ -209,6 +210,8 @@ empty = ""
remove_confirm = ""
remove_success = ""
subtitle = ""
remove_last = ""
remove_self = ""
[msg.admin.tenants.create]
subtitle = ""
@@ -237,6 +240,7 @@ missing_id = ""
subtitle = ""
update_error = ""
update_success = ""
forbidden_desc = ""
[msg.admin.tenants.sub]
empty = ""
@@ -253,6 +257,8 @@ move_error = ""
move_success = ""
parsed_count = ""
update_success = ""
schema_incompatible = ""
schema_missing = ""
[msg.admin.users.create]
error = ""
@@ -280,6 +286,7 @@ edit_subtitle = ""
not_found = ""
update_error = ""
update_success = ""
password_generated = ""
[msg.admin.users.detail.form]
field_required = ""
@@ -309,6 +316,8 @@ parsing = ""
requesting = ""
saving = ""
unknown_error = ""
copied_to_clipboard = ""
forbidden = ""
[msg.dev]
logout_confirm = ""
@@ -990,6 +999,9 @@ type_date = ""
type_number = ""
type_text = ""
validation_placeholder = ""
type_datetime = ""
type_float = ""
unsigned = ""
[ui.admin.tenants.sub]
add = ""
@@ -1029,6 +1041,8 @@ select_group = ""
selected_count = ""
start_upload = ""
title = ""
acknowledge_warning = ""
schema_warning = ""
[ui.admin.users.create]
back = ""
@@ -1073,6 +1087,7 @@ title = ""
back = ""
edit_title = ""
title = ""
generate_password = ""
[ui.admin.users.detail.breadcrumb]
section = ""
@@ -1138,7 +1153,6 @@ email = ""
name = ""
role = ""
[ui.common]
add = ""
all = ""
@@ -1191,6 +1205,7 @@ theme_dark = ""
theme_light = ""
theme_toggle = ""
unknown = ""
generate = ""
[ui.common.badge]
admin_only = ""
@@ -1684,3 +1699,4 @@ verify = ""
[ui.userfront.signup.success]
action = ""

View File

@@ -224,10 +224,12 @@ test.describe('UserFront WASM password login and reset', () => {
await expect(page).toHaveURL(/\/ko\/signin$/);
await expect
.poll(() =>
capture.clientLogs.some((message) =>
message.includes('password_or_email_mismatch'),
),
.poll(
() =>
capture.clientLogs.some((message) =>
message.includes('password_or_email_mismatch'),
),
{ timeout: 10000 },
)
.toBe(true);
});
@@ -257,7 +259,10 @@ test.describe('UserFront WASM password login and reset', () => {
});
await expect
.poll(() => capture.resetBody?.newPassword as string | undefined)
.poll(
() => capture.resetBody?.newPassword as string | undefined,
{ timeout: 10000 },
)
.toBe('ValidPass1!A');
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/, { timeout: 10_000 });
expect(capture.resetToken).toBe('reset-token-e2e');