forked from baron/baron-sso
테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -66,6 +66,7 @@ function mergePendingMembers(
|
||||
|
||||
export function TenantAdminsAndOwnersTab() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -398,22 +399,19 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.owners.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.owners.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ownersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<TableCell colSpan={2} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentOwners.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
colSpan={2}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -431,7 +429,8 @@ export function TenantAdminsAndOwnersTab() {
|
||||
currentOwners.map((owner) => (
|
||||
<TableRow
|
||||
key={owner.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
className="hover:bg-muted/30 transition-colors group cursor-pointer"
|
||||
onClick={() => navigate(`/users/${owner.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -444,46 +443,6 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{owner.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="relative inline-block group/tt">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveOwner(owner.id, owner.name)
|
||||
}
|
||||
disabled={
|
||||
removeOwnerMutation.isPending ||
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
|
||||
{owner.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentOwners.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_last",
|
||||
"마지막 소유자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.owners.remove_title",
|
||||
"소유자 권한 회수",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
@@ -529,22 +488,19 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.admins.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<TableCell colSpan={2} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentAdmins.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
colSpan={2}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -562,7 +518,8 @@ export function TenantAdminsAndOwnersTab() {
|
||||
currentAdmins.map((admin) => (
|
||||
<TableRow
|
||||
key={admin.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
className="hover:bg-muted/30 transition-colors group cursor-pointer"
|
||||
onClick={() => navigate(`/users/${admin.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -575,46 +532,6 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{admin.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="relative inline-block group/tt">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveAdmin(admin.id, admin.name)
|
||||
}
|
||||
disabled={
|
||||
removeAdminMutation.isPending ||
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
|
||||
{admin.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentAdmins.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_last",
|
||||
"마지막 관리자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.admins.remove_title",
|
||||
"관리자 권한 회수",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -72,16 +72,13 @@ function TenantSubTenantsPage() {
|
||||
<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}
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -92,7 +89,11 @@ function TenantSubTenantsPage() {
|
||||
</TableRow>
|
||||
)}
|
||||
{subTenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableRow
|
||||
key={tenant.id}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
<TableCell className="font-semibold">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
@@ -108,16 +109,6 @@ function TenantSubTenantsPage() {
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
|
||||
<ArrowRight size={12} className="ml-1" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -38,6 +38,7 @@ import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantUsersPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -137,15 +138,12 @@ function TenantUsersPage() {
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
{t("ui.admin.tenants.members.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-20">
|
||||
<TableCell colSpan={4} className="text-center py-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2
|
||||
className="animate-spin text-muted-foreground"
|
||||
@@ -160,7 +158,7 @@ function TenantUsersPage() {
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -171,7 +169,11 @@ function TenantUsersPage() {
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => navigate(`/users/${user.id}`)}
|
||||
>
|
||||
<TableCell className="font-semibold">
|
||||
{user.name}
|
||||
</TableCell>
|
||||
@@ -198,43 +200,6 @@ function TenantUsersPage() {
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/users/${user.id}`}>
|
||||
<User size={14} className="mr-2" />
|
||||
{t(
|
||||
"ui.admin.tenants.members.view_profile",
|
||||
"상세 정보",
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() =>
|
||||
handleRemoveMember(user.id, user.name)
|
||||
}
|
||||
disabled={removeTenantMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="mr-2" />
|
||||
{t(
|
||||
"ui.admin.tenants.members.remove",
|
||||
"조직에서 제외",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -152,6 +152,7 @@ function UserCreatePage() {
|
||||
grade: "",
|
||||
position: "",
|
||||
jobTitle: "",
|
||||
role: "user",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
@@ -354,6 +355,7 @@ function UserCreatePage() {
|
||||
password: data.password,
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
role: data.role,
|
||||
metadata,
|
||||
};
|
||||
|
||||
@@ -632,6 +634,37 @@ function UserCreatePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">
|
||||
{t("ui.admin.users.create.form.role", "역할")}
|
||||
</Label>
|
||||
<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 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("role")}
|
||||
disabled={profile?.role !== "super_admin"}
|
||||
>
|
||||
<option value="super_admin">
|
||||
{t("ui.admin.role.super_admin", "시스템 관리자")}
|
||||
</option>
|
||||
<option value="tenant_admin">
|
||||
{t("ui.admin.role.tenant_admin", "테넌트 관리자")}
|
||||
</option>
|
||||
<option value="rp_admin">
|
||||
{t("ui.admin.role.rp_admin", "서비스 관리자")}
|
||||
</option>
|
||||
<option value="user">
|
||||
{t("ui.admin.role.user", "일반 사용자")}
|
||||
</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.users.create.form.role_help",
|
||||
"시스템 접근 권한을 결정합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={userCategory} onValueChange={handleUserCategoryChange}>
|
||||
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
||||
<TabsTrigger
|
||||
|
||||
@@ -735,10 +735,8 @@ function UserDetailPage() {
|
||||
...safeMetadata,
|
||||
};
|
||||
|
||||
const profileData = { ...data };
|
||||
profileData.role = undefined;
|
||||
const payload: UserUpdateRequest = {
|
||||
...profileData,
|
||||
...data,
|
||||
metadata,
|
||||
};
|
||||
|
||||
@@ -1060,6 +1058,38 @@ function UserDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="role"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t("ui.admin.users.detail.form.role", "역할")}
|
||||
</Label>
|
||||
<div className="flex h-11 items-center gap-3">
|
||||
<select
|
||||
id="role"
|
||||
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
|
||||
{...register("role")}
|
||||
disabled={
|
||||
profile?.role !== "super_admin" ||
|
||||
profile?.id === user?.id
|
||||
}
|
||||
>
|
||||
<option value="super_admin">
|
||||
{t("ui.admin.role.super_admin", "시스템 관리자")}
|
||||
</option>
|
||||
<option value="tenant_admin">
|
||||
{t("ui.admin.role.tenant_admin", "테넌트 관리자")}
|
||||
</option>
|
||||
<option value="rp_admin">
|
||||
{t("ui.admin.role.rp_admin", "서비스 관리자")}
|
||||
</option>
|
||||
<option value="user">
|
||||
{t("ui.admin.role.user", "일반 사용자")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -328,6 +328,11 @@ function UserListPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkRoleChange = (role: string) => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
bulkUpdateMutation.mutate({ userIds: selectedUserIds, role });
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
if (
|
||||
@@ -577,6 +582,15 @@ function UserListPage() {
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("role")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||
{getSortIcon("role")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("tenant_dept")}
|
||||
@@ -721,6 +735,45 @@ function UserListPage() {
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
defaultValue={user.role}
|
||||
onValueChange={(value) =>
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: [user.id],
|
||||
role: value,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
bulkUpdateMutation.isPending ||
|
||||
profile?.role !== "super_admin" ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profile?.role === "super_admin" && (
|
||||
<SelectItem value="super_admin">
|
||||
{t(
|
||||
"ui.admin.role.super_admin",
|
||||
"시스템 관리자",
|
||||
)}
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="tenant_admin">
|
||||
{t("ui.admin.role.tenant_admin", "테넌트 관리자")}
|
||||
</SelectItem>
|
||||
<SelectItem value="rp_admin">
|
||||
{t("ui.admin.role.rp_admin", "서비스 관리자")}
|
||||
</SelectItem>
|
||||
<SelectItem value="user">
|
||||
{t("ui.admin.role.user", "일반 사용자")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
@@ -797,6 +850,35 @@ function UserListPage() {
|
||||
</Button>
|
||||
)}
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
{profile?.role === "super_admin" && (
|
||||
<>
|
||||
<Select onValueChange={handleBulkRoleChange}>
|
||||
<SelectTrigger className="h-8 w-[140px] bg-transparent border-background/20 text-background text-xs">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.table.role",
|
||||
"ROLE",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="super_admin">
|
||||
{t("ui.admin.role.super_admin", "시스템 관리자")}
|
||||
</SelectItem>
|
||||
<SelectItem value="tenant_admin">
|
||||
{t("ui.admin.role.tenant_admin", "테넌트 관리자")}
|
||||
</SelectItem>
|
||||
<SelectItem value="rp_admin">
|
||||
{t("ui.admin.role.rp_admin", "서비스 관리자")}
|
||||
</SelectItem>
|
||||
<SelectItem value="user">
|
||||
{t("ui.admin.role.user", "일반 사용자")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -99,11 +99,9 @@ test.describe("Seed tenant protection", () => {
|
||||
const seedRow = page.getByRole("row", { name: /한맥가족/ });
|
||||
await expect(seedRow.getByRole("checkbox")).toHaveCount(0);
|
||||
await expect(seedRow.getByText("초기 설정")).toBeVisible();
|
||||
await expect(seedRow.getByRole("button", { name: /삭제/ })).toBeDisabled();
|
||||
|
||||
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
|
||||
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
|
||||
await expect(normalRow.getByRole("button", { name: /삭제/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("disables delete action on seed tenant profile", async ({ page }) => {
|
||||
|
||||
@@ -457,7 +457,7 @@ test.describe("User Management", () => {
|
||||
const table = page.locator("table");
|
||||
await expect(
|
||||
table.getByRole("columnheader", { name: /ROLE|역할/i }),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("user-contact-u-1")).toContainText(
|
||||
"John Doe john@test.com 010-1111-2222",
|
||||
);
|
||||
@@ -528,7 +528,7 @@ test.describe("User Management", () => {
|
||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
|
||||
await expect(page.locator("select#role")).toHaveCount(0);
|
||||
await expect(page.locator("select#role")).toBeVisible();
|
||||
await expect(page.locator("input#department")).toHaveCount(0);
|
||||
|
||||
await expect(page.getByText(/대표 소속/i)).toHaveCount(0);
|
||||
@@ -592,7 +592,9 @@ test.describe("User Management", () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(createPayload).not.toHaveProperty("role");
|
||||
expect(createPayload).toMatchObject({
|
||||
role: "user",
|
||||
});
|
||||
expect(createPayload).not.toHaveProperty("department");
|
||||
expect(createPayload).not.toHaveProperty("tenantSlug");
|
||||
expect(createPayload).not.toHaveProperty("companyCode");
|
||||
|
||||
@@ -221,25 +221,41 @@ func gradeFromTraits(traits map[string]interface{}) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func tenantSlugFromRequest(tenantSlug string, legacyCompanyCode string) string {
|
||||
if value := strings.TrimSpace(tenantSlug); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(legacyCompanyCode)
|
||||
}
|
||||
|
||||
func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string) *string {
|
||||
if tenantSlug != nil {
|
||||
value := strings.TrimSpace(*tenantSlug)
|
||||
return &value
|
||||
}
|
||||
if legacyCompanyCode != nil {
|
||||
value := strings.TrimSpace(*legacyCompanyCode)
|
||||
return &value
|
||||
func rejectLegacyCompanyCode(value string) error {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return errors.New("companyCode is deprecated; use tenantSlug")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectLegacyCompanyCodePointer(value *string) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return rejectLegacyCompanyCode(*value)
|
||||
}
|
||||
|
||||
func tenantSlugFromRequest(tenantSlug string, legacyCompanyCode string) (string, error) {
|
||||
if err := rejectLegacyCompanyCode(legacyCompanyCode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if value := strings.TrimSpace(tenantSlug); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string) (*string, error) {
|
||||
if err := rejectLegacyCompanyCodePointer(legacyCompanyCode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantSlug != nil {
|
||||
value := strings.TrimSpace(*tenantSlug)
|
||||
return &value, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type userSummary struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
|
||||
@@ -1571,7 +1571,7 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_CreateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T) {
|
||||
func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
@@ -1594,8 +1594,8 @@ func TestUserHandler_CreateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T)
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Once()
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
return user.Attributes["companyCode"] == "test-tenant" &&
|
||||
user.Attributes["tenant_id"] == "tenant-id"
|
||||
_, hasCompanyCode := user.Attributes["companyCode"]
|
||||
return !hasCompanyCode && user.Attributes["tenant_id"] == "tenant-id"
|
||||
}), "Password1!").Return("user-id", nil).Once()
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
@@ -1603,7 +1603,6 @@ func TestUserHandler_CreateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T)
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"companyCode": "test-tenant",
|
||||
"tenant_id": "tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
@@ -1619,9 +1618,16 @@ func TestUserHandler_CreateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T)
|
||||
mockTenant.AssertExpectations(t)
|
||||
mockOry.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(`{"email":"legacy@test.com","password":"Password1!","name":"Legacy User","companyCode":"test-tenant"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err = app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T) {
|
||||
func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
@@ -1637,7 +1643,6 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T)
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"companyCode": "old-tenant",
|
||||
"tenant_id": "old-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
@@ -1653,15 +1658,14 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T)
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Once()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
return traits["companyCode"] == "new-tenant" &&
|
||||
traits["tenant_id"] == "new-tenant-id"
|
||||
_, hasCompanyCode := traits["companyCode"]
|
||||
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
|
||||
}), "").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"companyCode": "new-tenant",
|
||||
"tenant_id": "new-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
@@ -1678,7 +1682,7 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugWithoutCompanyCode(t *testing.T) {
|
||||
func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
@@ -1701,7 +1705,6 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugWithoutCompanyCode(t *testi
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"companyCode": "old-tenant",
|
||||
"tenant_id": "old-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
@@ -1711,15 +1714,14 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugWithoutCompanyCode(t *testi
|
||||
Slug: "new-tenant",
|
||||
}, nil).Once()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
return traits["companyCode"] == "new-tenant" &&
|
||||
traits["tenant_id"] == "new-tenant-id"
|
||||
_, hasCompanyCode := traits["companyCode"]
|
||||
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
|
||||
}), "active").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"companyCode": "new-tenant",
|
||||
"tenant_id": "new-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
@@ -1734,6 +1736,13 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugWithoutCompanyCode(t *testi
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockTenant.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
|
||||
req = httptest.NewRequest(http.MethodPut, "/users/bulk", strings.NewReader(`{"userIds":["legacy-id"],"companyCode":"legacy-tenant"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err = app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user