1
0
forked from baron/baron-sso

테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거

This commit is contained in:
2026-05-13 18:10:37 +09:00
10 changed files with 232 additions and 189 deletions

View File

@@ -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>
))
)}

View File

@@ -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>

View File

@@ -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>
))
)}

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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 }) => {

View File

@@ -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");

View File

@@ -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"`

View File

@@ -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) {