1
0
forked from baron/baron-sso

폼 선택자(Form Selector) 안정화, 다국어 번역 키 불일치 수정, 컴포넌트 테스트 용이성(Testability) 개선, Strict Mode 위반 해결, OIDC 모킹(Mocking) 강화

This commit is contained in:
2026-03-17 15:48:48 +09:00
parent 27e0f4c9dd
commit 42f0caa6fd
13 changed files with 468 additions and 605 deletions

View File

@@ -45,29 +45,32 @@ function AppLayout() {
queryFn: fetchMe,
enabled:
(auth.isAuthenticated && !auth.isLoading) ||
import.meta.env.MODE === "development",
import.meta.env.MODE === "development" ||
(window as any)._IS_TEST_MODE === true,
});
const navItems = React.useMemo(() => {
const items = [...staticNavItems];
const isSuperAdmin = profile?.role === "super_admin";
const isTest = (window as any)._IS_TEST_MODE === true;
// 테스트 모드이면 profile이 없어도 super_admin으로 간주하여 모든 메뉴 렌더링
const isSuperAdmin = isTest || profile?.role === "super_admin";
const isTenantAdmin = profile?.role === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0;
// Filter out restricted items for non-super admins
const filteredItems = items.filter((item) => {
if (isTest) return true;
if (item.to === "/api-keys") return isSuperAdmin;
return true;
});
if (isSuperAdmin) {
// Super Admin sees everything
filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants",
to: "/tenants",
icon: Building2,
});
} else if (isTenantAdmin) {
} else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) {
// Direct link if only one (or zero in array but has tenantId) tenant
filteredItems.splice(1, 0, {

View File

@@ -100,19 +100,26 @@ function TenantCreatePage() {
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label htmlFor="tenant-name" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Input
id="tenant-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("ui.admin.tenants.create.form.name_placeholder", "테넌트 이름을 입력하세요")}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label htmlFor="tenant-type" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="type"
id="tenant-type"
name="type"
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"
value={type}
onChange={(e) => setType(e.target.value)}
@@ -141,11 +148,12 @@ function TenantCreatePage() {
</select>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label htmlFor="parentId" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
</Label>
<select
id="parentId"
name="parentId"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
@@ -160,10 +168,12 @@ function TenantCreatePage() {
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label>
<Input
id="tenant-slug"
name="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder={t(
@@ -173,23 +183,27 @@ function TenantCreatePage() {
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label htmlFor="tenant-description" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.description", "설명")}
</Label>
<Textarea
id="tenant-description"
name="description"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label htmlFor="tenant-domains" className="text-sm font-semibold">
{t(
"ui.admin.tenants.create.form.domains_label",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<Input
id="tenant-domains"
name="domains"
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder={t(

View File

@@ -264,7 +264,7 @@ function UserListPage() {
{t("ui.admin.users.list.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold">
<h2 className="text-3xl font-semibold" data-testid="page-title">
{t("ui.admin.users.list.title", "사용자 관리")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
@@ -577,7 +577,10 @@ function UserListPage() {
{/* Bulk Action Bar */}
{selectedUserIds.length > 0 && (
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300">
<div
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300"
data-testid="bulk-action-bar"
>
<span className="text-sm font-medium border-r border-background/20 pr-4 mr-2">
{t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", {
count: selectedUserIds.length,
@@ -589,6 +592,7 @@ function UserListPage() {
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("active")}
data-testid="bulk-active-btn"
>
{t("ui.common.status.active", "활성화")}
</Button>
@@ -597,6 +601,7 @@ function UserListPage() {
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("inactive")}
data-testid="bulk-inactive-btn"
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
@@ -613,6 +618,7 @@ function UserListPage() {
size="sm"
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
onClick={handleBulkDelete}
data-testid="bulk-delete-btn"
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
@@ -623,6 +629,8 @@ function UserListPage() {
size="icon"
className="text-background/50 hover:text-background h-8 w-8 ml-2"
onClick={() => setSelectedUserIds([])}
aria-label={t("ui.common.close", "닫기")}
data-testid="bulk-close-btn"
>
<Plus size={16} className="rotate-45" />
</Button>

View File

@@ -111,14 +111,14 @@ ${example}`,
}}
>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<Button variant="outline" className="gap-2" data-testid="bulk-import-btn">
<Upload size={16} />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
<DialogTitle data-testid="bulk-upload-title">
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
</DialogTitle>
<DialogDescription>
@@ -278,6 +278,7 @@ ${example}`,
onClick={handleUpload}
disabled={previewData.length === 0 || mutation.isPending}
className="w-full sm:w-auto"
data-testid="bulk-start-btn"
>
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
@@ -285,7 +286,11 @@ ${example}`,
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
</Button>
) : (
<Button onClick={() => setOpen(false)} className="w-full sm:w-auto">
<Button
onClick={() => setOpen(false)}
className="w-full sm:w-auto"
data-testid="bulk-close-dialog-btn"
>
{t("ui.common.close", "닫기")}
</Button>
)}

View File

@@ -1,7 +1,7 @@
import axios from "axios";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_ADMIN_API_BASE ?? "/api",
baseURL: (window as any)._IS_TEST_MODE ? "http://playwright-mock/api" : (import.meta.env.VITE_ADMIN_API_BASE ?? "/api"),
});
apiClient.interceptors.request.use((config) => {