1
0
forked from baron/baron-sso

i18n refresh and frontend fixes

This commit is contained in:
Lectom C Han
2026-02-10 19:15:51 +09:00
parent 2441c64598
commit b6d3b69cda
44 changed files with 8603 additions and 1760 deletions

View File

@@ -1,6 +1,14 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { AlertCircle, Check, ChevronLeft, Copy, Loader2, Save, ShieldCheck } from "lucide-react";
import {
AlertCircle,
Check,
ChevronLeft,
Copy,
Loader2,
Save,
ShieldCheck,
} from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router-dom";
@@ -13,24 +21,69 @@ import {
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
type ApiKeyCreateRequest,
type ApiKeyCreateResponse,
createApiKey,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { createApiKey, type ApiKeyCreateRequest, type ApiKeyCreateResponse } from "../../lib/adminApi";
const AVAILABLE_SCOPES = [
{ id: "audit:read", label: "감사 로그 조회", desc: "시스템 내의 모든 이력을 조회할 수 있습니다." },
{ id: "audit:write", label: "감사 로그 생성", desc: "외부 앱의 로그를 Baron SSO로 전송합니다." },
{ id: "user:read", label: "사용자 조회", desc: "사용자 목록 및 프로필을 읽을 수 있습니다." },
{ id: "user:write", label: "사용자 관리", desc: "사용자 생성, 수정, 삭제 작업을 수행합니다." },
{ id: "tenant:read", label: "테넌트 조회", desc: "등록된 모든 조직 정보를 조회합니다." },
{ id: "tenant:write", label: "테넌트 관리", desc: "테넌트 정보를 직접 제어합니다." },
{
id: "audit:read",
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
labelFallback: "감사 로그 조회",
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
},
{
id: "audit:write",
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
labelFallback: "감사 로그 생성",
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
},
{
id: "user:read",
labelKey: "ui.admin.api_keys.scopes.user_read.title",
labelFallback: "사용자 조회",
descKey: "msg.admin.api_keys.scopes.user_read.desc",
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
},
{
id: "user:write",
labelKey: "ui.admin.api_keys.scopes.user_write.title",
labelFallback: "사용자 관리",
descKey: "msg.admin.api_keys.scopes.user_write.desc",
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
},
{
id: "tenant:read",
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
labelFallback: "테넌트 조회",
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
descFallback: "등록된 모든 조직 정보를 조회합니다.",
},
{
id: "tenant:write",
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
labelFallback: "테넌트 관리",
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
descFallback: "테넌트 정보를 직접 제어합니다.",
},
];
function ApiKeyCreatePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [createdResult, setCreatedResult] = React.useState<ApiKeyCreateResponse | null>(null);
const [selectedScopes, setSelectedScopes] = React.useState<string[]>(["audit:read", "user:read"]);
const [createdResult, setCreatedResult] =
React.useState<ApiKeyCreateResponse | null>(null);
const [selectedScopes, setSelectedScopes] = React.useState<string[]>([
"audit:read",
"user:read",
]);
const {
register,
@@ -47,19 +100,29 @@ function ApiKeyCreatePage() {
setCreatedResult(data);
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "API 키 생성에 실패했습니다.");
setError(
err.response?.data?.error ||
t("msg.admin.api_keys.create.error", "API 키 생성에 실패했습니다."),
);
},
});
const toggleScope = (scopeId: string) => {
setSelectedScopes((prev) =>
prev.includes(scopeId) ? prev.filter((s) => s !== scopeId) : [...prev, scopeId]
prev.includes(scopeId)
? prev.filter((s) => s !== scopeId)
: [...prev, scopeId],
);
};
const onSubmit = (data: { name: string }) => {
if (selectedScopes.length === 0) {
setError("최소 하나 이상의 권한을 선택해야 합니다.");
setError(
t(
"msg.admin.api_keys.create.scope_required",
"최소 하나 이상의 권한을 선택해야 합니다.",
),
);
return;
}
setError(null);
@@ -77,9 +140,24 @@ function ApiKeyCreatePage() {
<div className="mx-auto w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center">
<ShieldCheck size={32} />
</div>
<h2 className="text-3xl font-bold tracking-tight">API </h2>
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.api_keys.create.success.title", "API 키 생성 완료")}
</h2>
<p className="text-muted-foreground">
(Secret) <span className="text-destructive font-bold"> </span> .
{t(
"msg.admin.api_keys.create.success.notice",
"아래의 비밀번호(Secret)는 보안을 위해 ",
)}
<span className="text-destructive font-bold">
{t(
"msg.admin.api_keys.create.success.notice_emphasis",
"지금 한 번만",
)}
</span>{" "}
{t(
"msg.admin.api_keys.create.success.notice_suffix",
"표시됩니다.",
)}
</p>
</div>
@@ -87,7 +165,10 @@ function ApiKeyCreatePage() {
<CardHeader className="bg-primary/5 border-b">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<AlertCircle size={16} className="text-primary" />
릿
{t(
"ui.admin.api_keys.create.success.copy_secret",
"보안 시크릿 복사",
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-8 pb-8 space-y-6">
@@ -96,14 +177,14 @@ function ApiKeyCreatePage() {
X-Baron-Key-Secret
</Label>
<div className="relative group">
<Input
readOnly
value={createdResult.clientSecret}
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
<Input
readOnly
value={createdResult.clientSecret}
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
/>
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-primary/10"
onClick={() => handleCopy(createdResult.clientSecret)}
>
@@ -111,13 +192,21 @@ function ApiKeyCreatePage() {
</Button>
</div>
<p className="text-[11px] text-center text-muted-foreground italic">
( ) .
{t(
"msg.admin.api_keys.create.success.copy_hint",
"복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.",
)}
</p>
</div>
<div className="pt-4 flex flex-col gap-2">
<Button size="lg" className="w-full font-bold" asChild>
<Link to="/api-keys">. </Link>
<Link to="/api-keys">
{t(
"ui.admin.api_keys.create.success.go_list",
"저장했습니다. 목록으로 이동",
)}
</Link>
</Button>
</div>
</CardContent>
@@ -130,12 +219,24 @@ function ApiKeyCreatePage() {
<div className="max-w-3xl mx-auto space-y-10">
<header className="flex items-center justify-between">
<div className="space-y-1">
<Button variant="ghost" size="sm" className="-ml-3 text-muted-foreground" onClick={() => navigate("/api-keys")}>
<Button
variant="ghost"
size="sm"
className="-ml-3 text-muted-foreground"
onClick={() => navigate("/api-keys")}
>
<ChevronLeft size={16} className="mr-1" />
{t("ui.common.back", "돌아가기")}
</Button>
<h2 className="text-3xl font-bold tracking-tight"> API </h2>
<p className="text-muted-foreground"> .</p>
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.api_keys.create.title", "새 API 키 생성")}
</h2>
<p className="text-muted-foreground">
{t(
"msg.admin.api_keys.create.subtitle",
"내부 시스템 연동을 위한 보안 인증 키를 구성합니다.",
)}
</p>
</div>
</header>
@@ -143,20 +244,41 @@ function ApiKeyCreatePage() {
{/* 섹션 1: 이름 설정 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">1</span>
<h3 className="font-semibold text-lg"> </h3>
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
1
</span>
<h3 className="font-semibold text-lg">
{t("ui.admin.api_keys.create.section_name", "키 이름 지정")}
</h3>
</div>
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium"> </Label>
<Label htmlFor="name" className="text-sm font-medium">
{t(
"ui.admin.api_keys.create.name_label",
"서비스 또는 목적 식별 이름",
)}
</Label>
<Input
id="name"
placeholder="예: Jenkins-CI, Grafana-Dashboard"
placeholder={t(
"ui.admin.api_keys.create.name_placeholder",
"예: Jenkins-CI, Grafana-Dashboard",
)}
className="text-base py-5"
{...register("name", { required: "이름은 필수입니다." })}
{...register("name", {
required: t(
"msg.admin.api_keys.create.name_required",
"이름은 필수입니다.",
),
})}
/>
{errors.name && <p className="text-sm text-destructive mt-1">{errors.name.message}</p>}
{errors.name && (
<p className="text-sm text-destructive mt-1">
{errors.name.message}
</p>
)}
</div>
</CardContent>
</Card>
@@ -165,8 +287,15 @@ function ApiKeyCreatePage() {
{/* 섹션 2: 권한 선택 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">2</span>
<h3 className="font-semibold text-lg"> (Scopes) </h3>
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
2
</span>
<h3 className="font-semibold text-lg">
{t(
"ui.admin.api_keys.create.section_scopes",
"권한 범위(Scopes) 선택",
)}
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{AVAILABLE_SCOPES.map((scope) => {
@@ -178,24 +307,39 @@ function ApiKeyCreatePage() {
onClick={() => toggleScope(scope.id)}
className={cn(
"flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all",
isSelected
? "border-primary bg-primary/5 shadow-md"
: "border-border bg-card hover:border-muted-foreground/30"
isSelected
? "border-primary bg-primary/5 shadow-md"
: "border-border bg-card hover:border-muted-foreground/30",
)}
>
<div className="flex items-center justify-between w-full">
<span className={cn("font-bold text-sm", isSelected ? "text-primary" : "")}>{scope.label}</span>
<div className={cn(
"h-5 w-5 rounded-md flex items-center justify-center border",
isSelected ? "bg-primary border-primary" : "border-muted-foreground/30"
)}>
{isSelected && <Check size={12} className="text-primary-foreground" />}
<span
className={cn(
"font-bold text-sm",
isSelected ? "text-primary" : "",
)}
>
{t(scope.labelKey, scope.labelFallback)}
</span>
<div
className={cn(
"h-5 w-5 rounded-md flex items-center justify-center border",
isSelected
? "bg-primary border-primary"
: "border-muted-foreground/30",
)}
>
{isSelected && (
<Check size={12} className="text-primary-foreground" />
)}
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{scope.desc}
{t(scope.descKey, scope.descFallback)}
</p>
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">ID: {scope.id}</code>
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">
ID: {scope.id}
</code>
</button>
);
})}
@@ -210,15 +354,26 @@ function ApiKeyCreatePage() {
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="flex items-center justify-between p-6 bg-muted/30 rounded-2xl border">
<div>
<p className="text-sm font-bold"> {selectedScopes.length} .</p>
<p className="text-xs text-muted-foreground"> .</p>
<p className="text-sm font-bold">
{t(
"msg.admin.api_keys.create.scopes_count",
"총 {{count}}개의 권한이 할당됩니다.",
{ count: selectedScopes.length },
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.api_keys.create.scopes_hint",
"생성 즉시 활성화되어 사용 가능합니다.",
)}
</p>
</div>
<Button
onClick={handleSubmit(onSubmit)}
size="lg"
<Button
onClick={handleSubmit(onSubmit)}
size="lg"
className="px-8 font-bold shadow-lg shadow-primary/20"
disabled={mutation.isPending}
>
@@ -227,7 +382,7 @@ function ApiKeyCreatePage() {
) : (
<Save className="mr-2 h-5 w-5" />
)}
API
{t("ui.admin.api_keys.create.submit", "API 키 발급하기")}
</Button>
</div>
</div>
@@ -236,4 +391,4 @@ function ApiKeyCreatePage() {
);
}
export default ApiKeyCreatePage;
export default ApiKeyCreatePage;

View File

@@ -20,6 +20,7 @@ import {
TableRow,
} from "../../components/ui/table";
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
function ApiKeyListPage() {
const query = useQuery({
@@ -37,12 +38,25 @@ function ApiKeyListPage() {
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "API 키 목록 조회에 실패했습니다." : null;
!errorMsg && query.isError
? t(
"msg.admin.api_keys.list.fetch_error",
"API 키 목록 조회에 실패했습니다.",
)
: null;
const items = query.data?.items ?? [];
const handleDelete = (id: string, name: string) => {
if (!window.confirm(`API 키 "${name}"를 삭제할까요?`)) {
if (
!window.confirm(
t(
"msg.admin.api_keys.list.delete_confirm",
'API 키 "{{name}}"를 삭제할까요?',
{ name },
),
)
) {
return;
}
deleteMutation.mutate(id);
@@ -53,14 +67,22 @@ function ApiKeyListPage() {
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>API Keys</span>
<span>
{t("ui.admin.api_keys.list.breadcrumb.section", "API Keys")}
</span>
<span>/</span>
<span className="text-foreground">List</span>
<span className="text-foreground">
{t("ui.admin.api_keys.list.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold">API (M2M)</h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
(Machine-to-Machine) API
.
{t(
"msg.admin.api_keys.list.subtitle",
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
@@ -70,12 +92,12 @@ function ApiKeyListPage() {
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/api-keys/new">
<Plus size={16} />
API
{t("ui.admin.api_keys.list.add", "API 키 생성")}
</Link>
</Button>
</div>
@@ -84,12 +106,18 @@ function ApiKeyListPage() {
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>API Key Registry</CardTitle>
<CardTitle>
{t("ui.admin.api_keys.list.registry.title", "API Key Registry")}
</CardTitle>
<CardDescription>
{query.data?.total ?? 0} API
{t(
"msg.admin.api_keys.list.registry.count",
"총 {{count}}개 API 키",
{ count: query.data?.total ?? 0 },
)}
</CardDescription>
</div>
<Badge variant="muted">System</Badge>
<Badge variant="muted">{t("ui.common.badge.system", "System")}</Badge>
</CardHeader>
<CardContent>
{(errorMsg || fallbackError) && (
@@ -101,22 +129,39 @@ function ApiKeyListPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>CLIENT ID</TableHead>
<TableHead>SCOPES</TableHead>
<TableHead>LAST USED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}> ...</TableCell>
<TableCell colSpan={5}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}> API .</TableCell>
<TableCell colSpan={5}>
{t(
"msg.admin.api_keys.list.empty",
"등록된 API 키가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{items.map((key) => (
@@ -146,7 +191,7 @@ function ApiKeyListPage() {
<TableCell>
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
: "Never"}
: t("ui.common.never", "Never")}
</TableCell>
<TableCell className="text-right">
<Button
@@ -156,7 +201,7 @@ function ApiKeyListPage() {
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>

File diff suppressed because it is too large Load Diff

View File

@@ -16,30 +16,39 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { t } from "../../lib/i18n";
const summaryCards = [
{
label: "Total Tenants",
labelKey: "ui.admin.overview.summary.total_tenants",
labelFallback: "Total Tenants",
value: "-",
hint: "Tenant-aware core",
hintKey: "msg.admin.overview.summary.total_tenants",
hintFallback: "Tenant-aware core",
icon: Users,
},
{
label: "OIDC Clients",
labelKey: "ui.admin.overview.summary.oidc_clients",
labelFallback: "OIDC Clients",
value: "-",
hint: "Hydra registry",
hintKey: "msg.admin.overview.summary.oidc_clients",
hintFallback: "Hydra registry",
icon: ShieldCheck,
},
{
label: "Audit Events (24h)",
labelKey: "ui.admin.overview.summary.audit_events_24h",
labelFallback: "Audit Events (24h)",
value: "-",
hint: "ClickHouse stream",
hintKey: "msg.admin.overview.summary.audit_events_24h",
hintFallback: "ClickHouse stream",
icon: Activity,
},
{
label: "Policy Gate",
labelKey: "ui.admin.overview.summary.policy_gate",
labelFallback: "Policy Gate",
value: "Planned",
hint: "Keto + Admin checks",
hintKey: "msg.admin.overview.summary.policy_gate",
hintFallback: "Keto + Admin checks",
icon: Database,
},
];
@@ -50,44 +59,67 @@ function GlobalOverviewPage() {
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Global Overview
{t("ui.admin.overview.kicker", "Global Overview")}
</p>
<h2 className="text-3xl font-semibold">
Tenant-independent control plane
{t("ui.admin.overview.title", "Tenant-independent control plane")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
{t(
"msg.admin.overview.description",
"모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="muted">IDP: Ory primary</Badge>
<Badge variant="muted">Fallback: Descope</Badge>
<Badge variant="muted">
{t("msg.admin.overview.idp_primary", "IDP: Ory primary")}
</Badge>
<Badge variant="muted">
{t("msg.admin.overview.idp_fallback", "Fallback: Descope")}
</Badge>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map(({ label, value, hint, icon: Icon }) => (
<Card key={label} className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{label}</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Icon size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{value}</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">{hint}</p>
</CardContent>
</Card>
))}
{summaryCards.map(
({
labelKey,
labelFallback,
value,
hintKey,
hintFallback,
icon: Icon,
}) => (
<Card key={labelKey} className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{t(labelKey, labelFallback)}</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Icon size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{value}</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{t(hintKey, hintFallback)}
</p>
</CardContent>
</Card>
),
)}
</div>
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl">Admin playbook</CardTitle>
<CardTitle className="text-xl">
{t("ui.admin.overview.playbook.title", "Admin playbook")}
</CardTitle>
<CardDescription>
, , .
{t(
"msg.admin.overview.playbook.description",
"운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-[var(--color-muted)]">
@@ -97,11 +129,16 @@ function GlobalOverviewPage() {
</div>
<div>
<p className="font-semibold text-foreground">
Backend-only IDP access
{t(
"msg.admin.overview.playbook.idp_title",
"Backend-only IDP access",
)}
</p>
<p>
IDP backend를 , Hydra/Kratos
admin .
{t(
"msg.admin.overview.playbook.idp_body",
"모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos admin 포트는 외부에 노출하지 않습니다.",
)}
</p>
</div>
</div>
@@ -111,11 +148,16 @@ function GlobalOverviewPage() {
</div>
<div>
<p className="font-semibold text-foreground">
Tenant isolation
{t(
"msg.admin.overview.playbook.tenant_title",
"Tenant isolation",
)}
</p>
<p>
Tenant , Keto
.
{t(
"msg.admin.overview.playbook.tenant_body",
"Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto 정책으로 확장 예정입니다.",
)}
</p>
</div>
</div>
@@ -124,9 +166,14 @@ function GlobalOverviewPage() {
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl"> </CardTitle>
<CardTitle className="text-xl">
{t("ui.admin.overview.quick_links.title", "빠른 이동")}
</CardTitle>
<CardDescription>
.
{t(
"msg.admin.overview.quick_links.description",
"주요 운영 화면으로 바로 이동합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -136,7 +183,7 @@ function GlobalOverviewPage() {
variant="outline"
>
<Link to="/tenants/new">
{t("ui.admin.overview.quick_links.add_tenant", "테넌트 추가")}
<ArrowUpRight size={16} />
</Link>
</Button>
@@ -146,7 +193,10 @@ function GlobalOverviewPage() {
variant="outline"
>
<Link to="/audit-logs">
{t(
"ui.admin.overview.quick_links.view_audit_logs",
"감사 로그 보기",
)}
<ArrowUpRight size={16} />
</Link>
</Button>
@@ -156,7 +206,10 @@ function GlobalOverviewPage() {
variant="outline"
>
<Link to="/dashboard">
{t(
"ui.admin.overview.quick_links.tenant_dashboard",
"테넌트 대시보드",
)}
<ArrowUpRight size={16} />
</Link>
</Button>

View File

@@ -16,6 +16,7 @@ import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { createTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantCreatePage() {
const navigate = useNavigate();
@@ -49,18 +50,29 @@ function TenantCreatePage() {
<div className="space-y-8">
<header className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>
{t("ui.admin.tenants.create.breadcrumb.section", "Tenants")}
</span>
<span>/</span>
<span className="text-foreground">Create</span>
<span className="text-foreground">
{t("ui.admin.tenants.create.breadcrumb.action", "Create")}
</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.create.title", "테넌트 추가")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
{t(
"msg.admin.tenants.create.subtitle",
"글로벌 운영 기준의 신규 테넌트를 등록합니다.",
)}
</p>
</div>
<Badge variant="muted">Admin only</Badge>
<Badge variant="muted">
{t("ui.common.badge.admin_only", "Admin only")}
</Badge>
</div>
</header>
@@ -68,29 +80,40 @@ function TenantCreatePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} />
Tenant Profile
{t("ui.admin.tenants.create.profile.title", "Tenant Profile")}
</CardTitle>
<CardDescription>
. Slug는 .
{t(
"msg.admin.tenants.create.profile.subtitle",
"필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span>
{t("ui.admin.tenants.create.form.name", "Tenant name")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "Slug")}
</Label>
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="tenant-slug"
placeholder={t(
"ui.admin.tenants.create.form.slug_placeholder",
"tenant-slug",
)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.description", "Description")}
</Label>
<Textarea
rows={3}
value={description}
@@ -99,34 +122,44 @@ function TenantCreatePage() {
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)
{t(
"ui.admin.tenants.create.form.domains_label",
"Allowed Domains (Comma separated)",
)}
</Label>
<Input
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder="example.com, example.kr"
placeholder={t(
"ui.admin.tenants.create.form.domains_placeholder",
"example.com, example.kr",
)}
/>
<p className="text-xs text-muted-foreground">
Users with these email domains will be automatically assigned to
this tenant.
{t(
"msg.admin.tenants.create.form.domains_help",
"Users with these email domains will be automatically assigned to this tenant.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.status", "Status")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
Active
{t("ui.common.status.active", "Active")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
Inactive
{t("ui.common.status.inactive", "Inactive")}
</Button>
</div>
</div>
@@ -143,26 +176,32 @@ function TenantCreatePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles size={18} />
{t("ui.admin.tenants.create.memo.title", "정책 메모")}
</CardTitle>
<CardDescription>
Tenant Keto .
{t(
"msg.admin.tenants.create.memo.subtitle",
"Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-[var(--color-muted)]">
, .
{t(
"msg.admin.tenants.create.memo.body",
"생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.",
)}
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3">
<Button variant="outline" onClick={() => navigate("/tenants")}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || name.trim() === ""}
>
{t("ui.common.create", "생성")}
</Button>
</div>
</div>

View File

@@ -5,13 +5,14 @@ import { Badge } from "../../../components/ui/badge";
import { fetchTenant } from "../../../lib/adminApi";
function TenantDetailPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const location = useLocation();
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId!),
enabled: !!tenantId,
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const isFederationTab = location.pathname.includes("/federation");

View File

@@ -1,8 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, RefreshCw, Trash2, Users, UserPlus, UserMinus, Shield } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Plus,
RefreshCw,
Shield,
Trash2,
UserMinus,
UserPlus,
Users,
} from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -11,6 +19,8 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import {
Table,
TableBody,
@@ -19,15 +29,19 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchGroups, createGroup, deleteGroup, fetchUsers, addGroupMember, removeGroupMember } from "../../../lib/adminApi";
import { Badge } from "../../../components/ui/badge";
import {
addGroupMember,
createGroup,
deleteGroup,
fetchGroups,
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantGroupsPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
@@ -35,18 +49,14 @@ function TenantGroupsPage() {
// 그룹 목록 조회
const groupsQuery = useQuery({
queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId!),
enabled: !!tenantId,
queryFn: () => fetchGroups(tenantId),
enabled: tenantId.length > 0,
});
// 사용자 목록 조회 (멤버 추가용)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100 }],
queryFn: () => fetchUsers(100, 0),
});
const createMutation = useMutation({
mutationFn: () => createGroup(tenantId!, { name: newGroupName, description: newGroupDesc }),
mutationFn: () =>
createGroup(tenantId, { name: newGroupName, description: newGroupDesc }),
onSuccess: () => {
groupsQuery.refetch();
setNewGroupName("");
@@ -60,23 +70,30 @@ function TenantGroupsPage() {
});
const addMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => addGroupMember(groupId, userId),
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
addGroupMember(groupId, userId),
onSuccess: () => groupsQuery.refetch(),
});
const removeMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => removeGroupMember(groupId, userId),
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
removeGroupMember(groupId, userId),
onSuccess: () => groupsQuery.refetch(),
});
const handleAddMember = (groupId: string) => {
const userId = window.prompt("추가할 사용자의 UUID를 입력하세요:");
const userId = window.prompt(
t(
"msg.admin.groups.prompt.user_id",
"추가할 사용자의 UUID를 입력하세요:",
),
);
if (userId) {
addMemberMutation.mutate({ groupId, userId });
}
};
const currentGroup = groupsQuery.data?.find(g => g.id === selectedGroupId);
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return (
<div className="space-y-6 mt-6">
@@ -85,34 +102,45 @@ function TenantGroupsPage() {
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />
<Plus size={16} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name"> </Label>
<Input
id="name"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
placeholder="예: 개발팀, 인사팀"
<Label htmlFor="name">
{t("ui.admin.groups.form.name_label", "그룹 이름")}
</Label>
<Input
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t(
"ui.admin.groups.form.name_placeholder",
"예: 개발팀, 인사팀",
)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="desc"></Label>
<Input
id="desc"
value={newGroupDesc}
onChange={e => setNewGroupNameDesc(e.target.value)}
placeholder="그룹 용도 설명"
<Label htmlFor="desc">
{t("ui.admin.groups.form.desc_label", "설명")}
</Label>
<Input
id="desc"
value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)}
placeholder={t(
"ui.admin.groups.form.desc_placeholder",
"그룹 용도 설명",
)}
/>
</div>
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
</CardContent>
</Card>
@@ -121,10 +149,21 @@ function TenantGroupsPage() {
<Card className="bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Groups</CardTitle>
<CardDescription> .</CardDescription>
<CardTitle>
{t("ui.admin.groups.list.title", "User Groups")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.groups.list.subtitle",
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
)}
</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => groupsQuery.refetch()}>
<Button
variant="ghost"
size="sm"
onClick={() => groupsQuery.refetch()}
>
<RefreshCw size={14} />
</Button>
</CardHeader>
@@ -132,16 +171,22 @@ function TenantGroupsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>MEMBERS</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
<TableHead>
{t("ui.admin.groups.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.groups.table.members", "MEMBERS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.data?.map((group) => (
<TableRow
key={group.id}
className={`cursor-pointer ${selectedGroupId === group.id ? 'bg-primary/5' : ''}`}
<TableRow
key={group.id}
className={`cursor-pointer ${selectedGroupId === group.id ? "bg-primary/5" : ""}`}
onClick={() => setSelectedGroupId(group.id)}
>
<TableCell>
@@ -149,17 +194,37 @@ function TenantGroupsPage() {
<Users size={14} className="text-muted-foreground" />
{group.name}
</div>
<p className="text-[10px] text-muted-foreground">{group.description}</p>
<p className="text-[10px] text-muted-foreground">
{group.description}
</p>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.members?.length || 0} </Badge>
<Badge variant="secondary">
{t("msg.admin.groups.members.count", "{{count}} 명", {
count: group.members?.length || 0,
})}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleAddMember(group.id); }}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleAddMember(group.id);
}}
>
<UserPlus size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(group.id); }}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate(group.id);
}}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</div>
@@ -178,31 +243,53 @@ function TenantGroupsPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" />
[{currentGroup.name}]
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
name: currentGroup.name,
})}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>
{t("ui.admin.groups.members.table.name", "이름")}
</TableHead>
<TableHead>
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.remove", "제거")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow><TableCell colSpan={3} className="text-center py-4 text-muted-foreground"> .</TableCell></TableRow>
<TableRow>
<TableCell
colSpan={3}
className="text-center py-4 text-muted-foreground"
>
{t("msg.admin.groups.members.empty", "멤버가 없습니다.")}
</TableCell>
</TableRow>
)}
{currentGroup.members?.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell>
<TableCell className="text-muted-foreground">
{user.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => removeMemberMutation.mutate({ groupId: currentGroup.id, userId: user.id })}
<Button
variant="ghost"
size="sm"
onClick={() =>
removeMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
})
}
>
<UserMinus size={14} className="text-destructive" />
</Button>

View File

@@ -20,6 +20,7 @@ import {
TableRow,
} from "../../../components/ui/table";
import { deleteTenant, fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantListPage() {
const navigate = useNavigate();
@@ -38,12 +39,22 @@ function TenantListPage() {
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "테넌트 목록 조회에 실패했습니다." : null;
!errorMsg && query.isError
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null;
const items = query.data?.items ?? [];
const handleDelete = (tenantId: string, tenantName: string) => {
if (!window.confirm(`테넌트 "${tenantName}"를 삭제할까요?`)) {
if (
!window.confirm(
t(
"msg.admin.tenants.delete_confirm",
'테넌트 "{{name}}"를 삭제할까요?',
{ name: tenantName },
),
)
) {
return;
}
deleteMutation.mutate(tenantId);
@@ -54,13 +65,20 @@ function TenantListPage() {
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>{t("ui.admin.tenants.breadcrumb.section", "Tenants")}</span>
<span>/</span>
<span className="text-foreground">List</span>
<span className="text-foreground">
{t("ui.admin.tenants.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.title", "테넌트 목록")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
{t(
"msg.admin.tenants.subtitle",
"현재 등록된 테넌트를 확인하고 상태를 관리합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
@@ -70,12 +88,12 @@ function TenantListPage() {
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/tenants/new">
<Plus size={16} />
{t("ui.admin.tenants.add", "테넌트 추가")}
</Link>
</Button>
</div>
@@ -84,12 +102,18 @@ function TenantListPage() {
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Tenant registry</CardTitle>
<CardTitle>
{t("ui.admin.tenants.registry.title", "Tenant registry")}
</CardTitle>
<CardDescription>
{query.data?.total ?? 0}
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
count: query.data?.total ?? 0,
})}
</CardDescription>
</div>
<Badge variant="muted">Admin only</Badge>
<Badge variant="muted">
{t("ui.common.badge.admin_only", "Admin only")}
</Badge>
</CardHeader>
<CardContent>
{(errorMsg || fallbackError) && (
@@ -101,23 +125,38 @@ function TenantListPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>SLUG</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>UPDATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
<TableHead>
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.status", "STATUS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.updated", "UPDATED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}> ...</TableCell>
<TableCell colSpan={5}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}>
.
{t(
"msg.admin.tenants.empty",
"아직 등록된 테넌트가 없습니다.",
)}
</TableCell>
</TableRow>
)}
@@ -131,8 +170,8 @@ function TenantListPage() {
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
? "secondary"
: "muted"
}
className={
tenant.status === "pending"
@@ -140,7 +179,7 @@ function TenantListPage() {
: ""
}
>
{tenant.status}
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell>
@@ -156,7 +195,7 @@ function TenantListPage() {
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<Pencil size={14} />
{t("ui.common.edit", "편집")}
</Button>
<Button
variant="outline"
@@ -165,7 +204,7 @@ function TenantListPage() {
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>

View File

@@ -14,19 +14,34 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type SchemaFieldType = "text" | "number" | "boolean";
type SchemaField = {
id: string;
key: string;
label: string;
type: "text" | "number" | "boolean";
type: SchemaFieldType;
required: boolean;
};
function createFieldId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
if (!tenantId) return <div>Tenant ID missing</div>;
if (!tenantId) {
return (
<div>{t("msg.admin.tenants.schema.missing_id", "Tenant ID missing")}</div>
);
}
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
@@ -36,8 +51,20 @@ export function TenantSchemaPage() {
const [fields, setFields] = useState<SchemaField[]>([]);
useEffect(() => {
if (tenantQuery.data?.config?.userSchema) {
setFields(tenantQuery.data.config.userSchema as SchemaField[]);
const rawSchema = tenantQuery.data?.config?.userSchema;
if (Array.isArray(rawSchema)) {
setFields(
rawSchema.map((field) => ({
id: typeof field?.id === "string" ? field.id : createFieldId(),
key: typeof field?.key === "string" ? field.key : "",
label: typeof field?.label === "string" ? field.label : "",
type:
field?.type === "number" || field?.type === "boolean"
? field.type
: "text",
required: Boolean(field?.required),
})),
);
}
}, [tenantQuery.data]);
@@ -51,15 +78,32 @@ export function TenantSchemaPage() {
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Schema updated successfully");
alert(
t(
"msg.admin.tenants.schema.update_success",
"Schema updated successfully",
),
);
},
onError: (err: AxiosError<{ error?: string }>) => {
alert(err.response?.data?.error || "Failed to update schema");
alert(
err.response?.data?.error ||
t("msg.admin.tenants.schema.update_error", "Failed to update schema"),
);
},
});
const addField = () => {
setFields([...fields, { key: "", label: "", type: "text", required: false }]);
setFields([
...fields,
{
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
},
]);
};
const removeField = (index: number) => {
@@ -78,51 +122,89 @@ export function TenantSchemaPage() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>User Schema Extension</CardTitle>
<CardTitle>
{t("ui.admin.tenants.schema.title", "User Schema Extension")}
</CardTitle>
<CardDescription>
Define custom attributes for users in this tenant.
{t(
"msg.admin.tenants.schema.subtitle",
"Define custom attributes for users in this tenant.",
)}
</CardDescription>
</div>
<Button onClick={addField} size="sm">
<Plus size={16} className="mr-2" />
Add Field
{t("ui.admin.tenants.schema.add_field", "Add Field")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{fields.length === 0 && (
<div className="py-8 text-center text-muted-foreground border border-dashed rounded-md">
No custom fields defined. Click "Add Field" to begin.
{t(
"msg.admin.tenants.schema.empty",
'No custom fields defined. Click "Add Field" to begin.',
)}
</div>
)}
{fields.map((field, index) => (
<div key={index} className="flex items-end gap-4 p-4 border rounded-md bg-muted/30">
<div
key={field.id}
className="flex items-end gap-4 p-4 border rounded-md bg-muted/30"
>
<div className="flex-1 space-y-2">
<Label>Field Key (ID)</Label>
<Label>
{t("ui.admin.tenants.schema.field.key", "Field Key (ID)")}
</Label>
<Input
value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })}
placeholder="e.g. employee_id"
placeholder={t(
"ui.admin.tenants.schema.field.key_placeholder",
"e.g. employee_id",
)}
/>
</div>
<div className="flex-1 space-y-2">
<Label>Display Label</Label>
<Label>
{t("ui.admin.tenants.schema.field.label", "Display Label")}
</Label>
<Input
value={field.label}
onChange={(e) => updateField(index, { label: e.target.value })}
placeholder="e.g. 사번"
onChange={(e) =>
updateField(index, { label: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.label_placeholder",
"e.g. 사번",
)}
/>
</div>
<div className="w-32 space-y-2">
<Label>Type</Label>
<Label>{t("ui.admin.tenants.schema.field.type", "Type")}</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
value={field.type}
onChange={(e) => updateField(index, { type: e.target.value as any })}
onChange={(e) => {
const nextType = e.target.value;
if (
nextType === "text" ||
nextType === "number" ||
nextType === "boolean"
) {
updateField(index, { type: nextType });
}
}}
>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="text">
{t("ui.admin.tenants.schema.field.type_text", "Text")}
</option>
<option value="number">
{t("ui.admin.tenants.schema.field.type_number", "Number")}
</option>
<option value="boolean">
{t("ui.admin.tenants.schema.field.type_boolean", "Boolean")}
</option>
</select>
</div>
<Button
@@ -144,7 +226,7 @@ export function TenantSchemaPage() {
disabled={updateMutation.isPending || tenantQuery.isLoading}
>
<Save size={16} className="mr-2" />
Save Schema Changes
{t("ui.admin.tenants.schema.save", "Save Schema Changes")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { Building2, Plus, ArrowRight } from "lucide-react";
import { Link, useParams, useNavigate } from "react-router-dom";
import { ArrowRight, Building2, Plus } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
@@ -9,18 +18,16 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card";
import { Button } from "../../../components/ui/button";
import { Badge } from "../../../components/ui/badge";
import { fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantSubTenantsPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const { data, isLoading } = useQuery({
const { data } = useQuery({
queryKey: ["sub-tenants", tenantId],
queryFn: () => fetchTenants(50, 0, tenantId),
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
enabled: !!tenantId,
});
@@ -32,47 +39,76 @@ function TenantSubTenantsPage() {
<div>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
Sub-tenants ({subTenants.length})
{t("ui.admin.tenants.sub.title", "Sub-tenants ({{count}})", {
count: subTenants.length,
})}
</CardTitle>
<CardDescription> .</CardDescription>
<CardDescription>
{t(
"msg.admin.tenants.sub.subtitle",
"현재 테넌트 하위에 생성된 조직입니다.",
)}
</CardDescription>
</div>
<Button size="sm" asChild>
<Link to={`/tenants/new?parentId=${tenantId}`}>
<Plus size={14} className="mr-1" />
</Link>
<Link to={`/tenants/new?parentId=${tenantId}`}>
<Plus size={14} className="mr-1" />
{t("ui.admin.tenants.sub.add", "하위 테넌트 추가")}
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>SLUG</TableHead>
<TableHead>STATUS</TableHead>
<TableHead className="text-right">ACTION</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.status", "STATUS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.tenants.sub.table.action", "ACTION")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subTenants.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
<TableCell
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
{t("msg.admin.tenants.sub.empty", "하위 테넌트가 없습니다.")}
</TableCell>
</TableRow>
)}
{subTenants.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-semibold">{t.name}</TableCell>
<TableCell className="text-xs font-mono">{t.slug}</TableCell>
{subTenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">{tenant.name}</TableCell>
<TableCell className="text-xs font-mono">
{tenant.slug}
</TableCell>
<TableCell>
<Badge variant={t.status === "active" ? "default" : "secondary"}>
{t.status}
<Badge
variant={
tenant.status === "active" ? "default" : "secondary"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => navigate(`/tenants/${t.id}`)}>
<ArrowRight size={12} className="ml-1" />
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
<ArrowRight size={12} className="ml-1" />
</Button>
</TableCell>
</TableRow>

View File

@@ -1,6 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { User, Mail, Phone, ShieldCheck } from "lucide-react";
import { Mail, User } from "lucide-react";
import { useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
@@ -9,18 +16,18 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card";
import { Badge } from "../../../components/ui/badge";
import { fetchUsers, fetchTenant } from "../../../lib/adminApi";
import { fetchTenant, fetchUsers } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantUsersPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId!),
enabled: !!tenantId,
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const companyCode = tenantQuery.data?.slug;
@@ -39,24 +46,40 @@ function TenantUsersPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User size={18} className="text-primary" />
Tenant Members ({users.length})
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
count: users.length,
})}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.email", "EMAIL")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
<TableCell
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.members.empty",
"소속된 사용자가 없습니다.",
)}
</TableCell>
</TableRow>
)}
@@ -71,12 +94,17 @@ function TenantUsersPage() {
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{user.role.replace("_", " ")}
{t(
`ui.common.role.${user.role}`,
user.role.replace("_", " "),
)}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "default" : "muted"}>
{user.status}
<Badge
variant={user.status === "active" ? "default" : "muted"}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
</TableRow>

View File

@@ -15,18 +15,30 @@ import {
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
createUser,
fetchTenants,
fetchTenant,
type UserCreateRequest,
type UserCreateResponse,
createUser,
fetchTenant,
fetchTenants,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean";
required?: boolean;
};
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
function UserCreatePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = React.useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = React.useState<
string | null
>(null);
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
const [autoPassword, setAutoPassword] = React.useState(true);
@@ -41,7 +53,7 @@ function UserCreatePage() {
handleSubmit,
watch,
formState: { errors },
} = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
} = useForm<UserFormValues>({
defaultValues: {
email: "",
password: "",
@@ -57,13 +69,22 @@ function UserCreatePage() {
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const selectedTenantId = selectedTenant?.id ?? "";
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
queryKey: ["tenant", selectedTenantId],
queryFn: () => fetchTenant(selectedTenantId),
enabled: selectedTenantId.length > 0,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
const userSchema: UserSchemaField[] = Array.isArray(
tenantDetail?.config?.userSchema,
)
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
const registerMetadata = (key: string) =>
register(`metadata.${key}` as `metadata.${string}`);
const mutation = useMutation({
mutationFn: createUser,
@@ -77,7 +98,10 @@ function UserCreatePage() {
navigate("/users");
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "사용자 생성에 실패했습니다.");
setError(
err.response?.data?.error ||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
);
},
});
@@ -92,7 +116,12 @@ function UserCreatePage() {
}
if (!data.password) {
setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요.");
setError(
t(
"msg.admin.users.create.password_required",
"비밀번호를 입력하거나 자동 생성을 사용해 주세요.",
),
);
return;
}
@@ -114,17 +143,21 @@ function UserCreatePage() {
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
{t("ui.admin.users.create.breadcrumb.section", "Users")}
</Link>
<span>/</span>
<span className="text-foreground">New</span>
<span className="text-foreground">
{t("ui.admin.users.create.breadcrumb.new", "New")}
</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.users.create.title", "사용자 추가")}
</h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
{t("ui.admin.users.create.back", "목록으로 돌아가기")}
</Link>
</Button>
</header>
@@ -132,9 +165,23 @@ function UserCreatePage() {
{generatedPassword && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardTitle>
{t(
"ui.admin.users.create.password_generated.title",
"초기 비밀번호 생성 완료",
)}
</CardTitle>
<CardDescription>
{createdEmail ? `${createdEmail} 계정의 초기 비밀번호입니다.` : "초기 비밀번호가 생성되었습니다."}
{createdEmail
? t(
"msg.admin.users.create.password_generated.with_email",
"{{email}} 계정의 초기 비밀번호입니다.",
{ email: createdEmail },
)
: t(
"msg.admin.users.create.password_generated.default",
"초기 비밀번호가 생성되었습니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -142,11 +189,13 @@ function UserCreatePage() {
<span className="font-mono text-sm">{generatedPassword}</span>
<Button size="sm" variant="outline" onClick={onCopyPassword}>
<ClipboardCopy className="mr-2 h-4 w-4" />
{t("ui.common.copy", "복사")}
</Button>
</div>
<div className="flex justify-end">
<Button onClick={() => navigate("/users")}> </Button>
<Button onClick={() => navigate("/users")}>
{t("ui.admin.users.create.go_list", "목록으로 이동")}
</Button>
</div>
</CardContent>
</Card>
@@ -154,8 +203,15 @@ function UserCreatePage() {
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
<CardTitle>
{t("ui.admin.users.create.account.title", "계정 정보")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.create.account.subtitle",
"새로운 사용자를 시스템에 등록합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
@@ -166,182 +222,200 @@ function UserCreatePage() {
)}
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Label htmlFor="email">
{t("ui.admin.users.create.form.email", "이메일")}
</Label>
<Input
id="email"
placeholder="user@example.com"
{...register("email", { required: "이메일은 필수입니다." })}
placeholder={t(
"ui.admin.users.create.form.email_placeholder",
"user@example.com",
)}
{...register("email", {
required: t(
"msg.admin.users.create.form.email_required",
"이메일은 필수입니다.",
),
})}
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email.message}</p>
<p className="text-xs text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password"></Label>
<Label htmlFor="password">
{t("ui.admin.users.create.form.password", "비밀번호")}
</Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={autoPassword}
onChange={(event) => setAutoPassword(event.target.checked)}
/>
{t("ui.admin.users.create.form.auto_password", "자동 생성")}
</label>
</div>
<Input
id="password"
type="password"
placeholder="********"
placeholder={t(
"ui.admin.users.create.form.password_placeholder",
"********",
)}
disabled={autoPassword}
{...register("password")}
/>
<p className="text-xs text-muted-foreground">
{autoPassword
? "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
: "초기 비밀번호를 직접 설정합니다."}
? t(
"msg.admin.users.create.form.password_auto_help",
"비워두면 시스템이 초기 비밀번호를 자동 생성합니다.",
)
: t(
"msg.admin.users.create.form.password_manual_help",
"초기 비밀번호를 직접 설정합니다.",
)}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name">
{t("ui.admin.users.create.form.name", "이름")}
</Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
placeholder={t(
"ui.admin.users.create.form.name_placeholder",
"홍길동",
)}
{...register("name", {
required: t(
"msg.admin.users.create.form.name_required",
"이름은 필수입니다.",
),
})}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
<p className="text-xs text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Label htmlFor="phone">
{t("ui.admin.users.create.form.phone", "전화번호")}
</Label>
<Input
id="phone"
placeholder="010-1234-5678"
placeholder={t(
"ui.admin.users.create.form.phone_placeholder",
"010-1234-5678",
)}
{...register("phone")}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode">
{t("ui.admin.users.create.form.tenant", "테넌트 (Tenant)")}
</Label>
<div className="space-y-2">
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value="">
{t(
"ui.admin.users.create.form.tenant_global",
"시스템 전역 (소속 없음)",
)}
</option>
<Label htmlFor="companyCode"> (Tenant)</Label>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="relative">
<div className="space-y-2">
<Label htmlFor="department">
{t("ui.admin.users.create.form.department", "부서")}
</Label>
<select
<Input
id="department"
placeholder={t(
"ui.admin.users.create.form.department_placeholder",
"개발팀",
)}
{...register("department")}
/>
</div>
</div>
id="companyCode"
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t(
"ui.admin.users.create.custom_fields.title",
"테넌트 확장 정보 (Custom Fields)",
)}
</h3>
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
{...register("companyCode")}
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...registerMetadata(field.key)}
/>
</div>
))}
</div>
</div>
)}
>
<option value=""> ( )</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<div className="space-y-2">
<Label htmlFor="role">
{t("ui.admin.users.create.form.role", "역할 (Role)")}
</Label>
<div className="relative">
<select
id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="user">
{t("ui.common.role.user", "User")}
</option>
<option value="admin">
{t("ui.common.role.admin", "Admin")}
</option>
</select>
</div>
<p className="text-xs text-muted-foreground">
.
{t(
"msg.admin.users.create.form.role_help",
"시스템 접근 권한을 결정합니다.",
)}
</p>
</div>
@@ -351,14 +425,14 @@ function UserCreatePage() {
variant="outline"
onClick={() => navigate("/users")}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
{t("ui.admin.users.create.submit", "사용자 생성")}
</Button>
</div>
</form>

View File

@@ -15,24 +15,39 @@ import {
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
fetchUser,
fetchTenants,
fetchTenant,
updateUser,
type UserUpdateRequest,
fetchTenant,
fetchTenants,
fetchUser,
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean";
required?: boolean;
};
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
function UserDetailPage() {
const { id } = useParams<{ id: string }>();
const params = useParams<{ id: string }>();
const userId = params.id ?? "";
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
const { data: user, isLoading, isError } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id!),
enabled: !!id,
const {
data: user,
isLoading,
isError,
} = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
enabled: userId.length > 0,
});
const { data: tenantsData } = useQuery({
@@ -47,7 +62,7 @@ function UserDetailPage() {
reset,
watch,
formState: { errors },
} = useForm<UserUpdateRequest & { metadata: Record<string, any> }>({
} = useForm<UserFormValues>({
defaultValues: {
name: "",
phone: "",
@@ -63,13 +78,22 @@ function UserDetailPage() {
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const selectedTenantId = selectedTenant?.id ?? "";
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
queryKey: ["tenant", selectedTenantId],
queryFn: () => fetchTenant(selectedTenantId),
enabled: selectedTenantId.length > 0,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
const userSchema: UserSchemaField[] = Array.isArray(
tenantDetail?.config?.userSchema,
)
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
const registerMetadata = (key: string) =>
register(`metadata.${key}` as `metadata.${string}`);
React.useEffect(() => {
if (user) {
@@ -87,15 +111,26 @@ function UserDetailPage() {
}, [user, reset]);
const mutation = useMutation({
mutationFn: (data: UserUpdateRequest) => updateUser(id!, data),
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", id] });
setSuccessMsg("사용자 정보가 수정되었습니다.");
queryClient.invalidateQueries({ queryKey: ["user", userId] });
setSuccessMsg(
t(
"msg.admin.users.detail.update_success",
"사용자 정보가 수정되었습니다.",
),
);
setError(null);
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "사용자 수정에 실패했습니다.");
setError(
err.response?.data?.error ||
t(
"msg.admin.users.detail.update_error",
"사용자 수정에 실패했습니다.",
),
);
setSuccessMsg(null);
},
});
@@ -103,19 +138,23 @@ function UserDetailPage() {
const onSubmit = (data: UserUpdateRequest) => {
const payload = { ...data };
if (!payload.password) {
delete payload.password;
payload.password = undefined;
}
mutation.mutate(payload);
};
if (isLoading) {
return <div className="p-8 text-center">Loading...</div>;
return (
<div className="p-8 text-center">
{t("msg.common.loading", "Loading...")}
</div>
);
}
if (isError || !user) {
return (
<div className="p-8 text-center text-destructive">
.
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
</div>
);
}
@@ -126,26 +165,34 @@ function UserDetailPage() {
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
{t("ui.admin.users.detail.breadcrumb.section", "Users")}
</Link>
<span>/</span>
<span className="text-foreground">{user.name}</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.users.detail.title", "사용자 상세")}
</h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
{t("ui.admin.users.detail.back", "목록으로 돌아가기")}
</Link>
</Button>
</header>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardTitle>
{t("ui.admin.users.detail.edit_title", "정보 수정")}
</CardTitle>
<CardDescription>
{user.email} .
{t(
"msg.admin.users.detail.edit_subtitle",
"{{email}} 계정의 정보를 수정합니다.",
{ email: user.email },
)}
</CardDescription>
</CardHeader>
<CardContent>
@@ -163,22 +210,39 @@ function UserDetailPage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name">
{t("ui.admin.users.detail.form.name", "이름")}
</Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
placeholder={t(
"ui.admin.users.detail.form.name_placeholder",
"홍길동",
)}
{...register("name", {
required: t(
"msg.admin.users.detail.form.name_required",
"이름은 필수입니다.",
),
})}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
<p className="text-xs text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Label htmlFor="phone">
{t("ui.admin.users.detail.form.phone", "전화번호")}
</Label>
<Input
id="phone"
placeholder="010-1234-5678"
placeholder={t(
"ui.admin.users.detail.form.phone_placeholder",
"010-1234-5678",
)}
{...register("phone")}
/>
</div>
@@ -186,149 +250,145 @@ function UserDetailPage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Label htmlFor="status">
{t("ui.admin.users.detail.form.status", "상태")}
</Label>
<div className="relative">
<select
id="status"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("status")}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="blocked">Blocked</option>
<option value="active">
{t("ui.common.status.active", "Active")}
</option>
<option value="inactive">
{t("ui.common.status.inactive", "Inactive")}
</option>
<option value="blocked">
{t("ui.common.status.blocked", "Blocked")}
</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<Label htmlFor="role">
{t("ui.admin.users.detail.form.role", "역할 (Role)")}
</Label>
<div className="relative">
<select
id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="user">
{t("ui.common.role.user", "User")}
</option>
<option value="admin">
{t("ui.common.role.admin", "Admin")}
</option>
</select>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value=""> ( )</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
)}
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground"> </h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="password"> </Label>
<Label htmlFor="companyCode">
{t("ui.admin.users.detail.form.tenant", "테넌트 (Tenant)")}
</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value="">
{t(
"ui.admin.users.detail.form.tenant_global",
"시스템 전역 (소속 없음)",
)}
</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department">
{t("ui.admin.users.detail.form.department", "부서")}
</Label>
<Input
id="department"
placeholder={t(
"ui.admin.users.detail.form.department_placeholder",
"개발팀",
)}
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t(
"ui.admin.users.detail.custom_fields.title",
"테넌트 확장 정보 (Custom Fields)",
)}
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...registerMetadata(field.key)}
/>
</div>
))}
</div>
</div>
)}
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t("ui.admin.users.detail.security.title", "보안 설정")}
</h3>
<div className="space-y-2">
<Label htmlFor="password">
{t(
"ui.admin.users.detail.security.password",
"비밀번호 변경",
)}
</Label>
<Input
id="password"
type="password"
placeholder="변경할 경우에만 입력"
placeholder={t(
"ui.admin.users.detail.security.password_placeholder",
"변경할 경우에만 입력",
)}
{...register("password")}
/>
<p className="text-xs text-muted-foreground">
. .
{t(
"msg.admin.users.detail.security.password_hint",
"비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
)}
</p>
</div>
</div>
@@ -339,14 +399,14 @@ function UserDetailPage() {
variant="outline"
onClick={() => navigate("/users")}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
{t("ui.common.save", "저장")}
</Button>
</div>
</form>
@@ -356,4 +416,4 @@ function UserDetailPage() {
);
}
export default UserDetailPage;
export default UserDetailPage;

View File

@@ -31,6 +31,7 @@ import {
TableRow,
} from "../../components/ui/table";
import { deleteUser, fetchUsers } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
function UserListPage() {
const navigate = useNavigate();
@@ -67,7 +68,12 @@ function UserListPage() {
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "사용자 목록 조회에 실패했습니다." : null;
!errorMsg && query.isError
? t(
"msg.admin.users.list.fetch_error",
"사용자 목록 조회에 실패했습니다.",
)
: null;
const items = query.data?.items ?? [];
const total = query.data?.total ?? 0;
@@ -80,7 +86,15 @@ function UserListPage() {
}, [items]);
const handleDelete = (userId: string, userName: string) => {
if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) {
if (
!window.confirm(
t(
"msg.admin.users.list.delete_confirm",
'사용자 "{{name}}"을(를) 정말 삭제하시겠습니까?',
{ name: userName },
),
)
) {
return;
}
deleteMutation.mutate(userId);
@@ -91,13 +105,20 @@ function UserListPage() {
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Users</span>
<span>{t("ui.admin.users.list.breadcrumb.section", "Users")}</span>
<span>/</span>
<span className="text-foreground">List</span>
<span className="text-foreground">
{t("ui.admin.users.list.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.users.list.title", "사용자 관리")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
. (Local DB)
{t(
"msg.admin.users.list.subtitle",
"시스템 사용자를 조회하고 관리합니다. (Local DB)",
)}
</p>
</div>
<div className="flex items-center gap-2">
@@ -107,12 +128,12 @@ function UserListPage() {
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/users/new">
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
</Link>
</Button>
</div>
@@ -121,9 +142,15 @@ function UserListPage() {
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Registry</CardTitle>
<CardTitle>
{t("ui.admin.users.list.registry.title", "User Registry")}
</CardTitle>
<CardDescription>
{total} .
{t(
"msg.admin.users.list.registry.count",
"총 {{count}}명의 사용자가 등록되어 있습니다.",
{ count: total },
)}
</CardDescription>
</div>
</CardHeader>
@@ -132,7 +159,10 @@ function UserListPage() {
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="이름 또는 이메일 검색..."
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
@@ -140,7 +170,7 @@ function UserListPage() {
/>
</div>
<Button variant="secondary" onClick={handleSearch}>
{t("ui.common.search", "검색")}
</Button>
</div>
@@ -154,26 +184,41 @@ function UserListPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME / EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>TENANT / DEPT</TableHead>
<TableHead>CREATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
<TableHead>
{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>
<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>
<TableCell colSpan={6} className="h-24 text-center">
...
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
.
{t("msg.admin.users.list.empty", "검색 결과가 없습니다.")}
</TableCell>
</TableRow>
)}
@@ -193,7 +238,9 @@ function UserListPage() {
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{user.role}</Badge>
<Badge variant="outline">
{t(`ui.common.role.${user.role}`, user.role)}
</Badge>
</TableCell>
<TableCell>
<Badge
@@ -201,7 +248,7 @@ function UserListPage() {
user.status === "active" ? "default" : "secondary"
}
>
{user.status}
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
<TableCell>
@@ -211,7 +258,13 @@ function UserListPage() {
</span>
{user.tenant && (
<span className="text-[10px] text-muted-foreground uppercase">
Slug: {user.tenant.slug}
{t(
"ui.admin.users.list.tenant_slug",
"Slug: {{slug}}",
{
slug: user.tenant.slug,
},
)}
</span>
)}
<span className="text-xs text-muted-foreground">
@@ -228,7 +281,11 @@ function UserListPage() {
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
aria-label={`사용자 수정: ${user.name}`}
aria-label={t(
"ui.admin.users.list.edit_aria",
"사용자 수정: {{name}}",
{ name: user.name },
)}
>
<Pencil size={16} />
</Button>
@@ -238,7 +295,11 @@ function UserListPage() {
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
aria-label={`사용자 삭제: ${user.name}`}
aria-label={t(
"ui.admin.users.list.delete_aria",
"사용자 삭제: {{name}}",
{ name: user.name },
)}
>
<Trash2 size={16} />
</Button>
@@ -260,10 +321,13 @@ function UserListPage() {
disabled={page === 1 || query.isFetching}
>
<ChevronLeft size={16} />
Previous
{t("ui.common.previous", "Previous")}
</Button>
<div className="text-sm text-muted-foreground">
Page {page} of {totalPages}
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
page,
total: totalPages,
})}
</div>
<Button
variant="outline"
@@ -271,7 +335,7 @@ function UserListPage() {
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || query.isFetching}
>
Next
{t("ui.common.next", "Next")}
<ChevronRight size={16} />
</Button>
</div>