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", "preview": "vite preview",
"test": "playwright test", "test": "playwright test",
"test:unit": "vitest run", "test:unit": "vitest run",
"test:ui": "playwright test --ui" "test:ui": "playwright test --ui",
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-avatar": "^1.1.4",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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