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 390a349de6
commit 05cdc7d8ae
44 changed files with 8603 additions and 1760 deletions

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>