From a6c552236f579d392faea6af94d185e80b74056e Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Mar 2026 11:12:42 +0900 Subject: [PATCH 01/12] feat: prevent self-removal and last admin/owner removal in tenant handler --- backend/internal/handler/tenant_handler.go | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 17cd07ae..1b29de6b 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -539,6 +539,30 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { + if profile.ID == userID { + return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from admin role") + } + } + + if h.Keto != nil { + if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", ""); err == nil { + adminCount := 0 + isTargetAdmin := false + for _, rel := range relations { + if strings.HasPrefix(rel.SubjectID, "User:") { + adminCount++ + if rel.SubjectID == "User:"+userID { + isTargetAdmin = true + } + } + } + if isTargetAdmin && adminCount <= 1 { + return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last admin") + } + } + } + if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", @@ -646,6 +670,30 @@ func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { + if profile.ID == userID { + return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from owner role") + } + } + + if h.Keto != nil { + if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", ""); err == nil { + ownerCount := 0 + isTargetOwner := false + for _, rel := range relations { + if strings.HasPrefix(rel.SubjectID, "User:") { + ownerCount++ + if rel.SubjectID == "User:"+userID { + isTargetOwner = true + } + } + } + if isTargetOwner && ownerCount <= 1 { + return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last owner") + } + } + } + if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", From 4d11d3e554bf7a10dd7fd7810f082e7f8a1c004a Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Mar 2026 11:21:03 +0900 Subject: [PATCH 02/12] feat: disable remove button for self and last admin/owner in UI --- .../routes/TenantAdminsAndOwnersTab.tsx | 69 +++++++++++++++---- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index 80841deb..56112c98 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -10,6 +10,7 @@ import { Users, } from "lucide-react"; import { useState } from "react"; +import { useAuth } from "react-oidc-context"; import { useParams } from "react-router-dom"; import { toast } from "sonner"; import { Badge } from "../../../components/ui/badge"; @@ -52,6 +53,8 @@ import { t } from "../../../lib/i18n"; type DialogMode = "owner" | "admin"; export function TenantAdminsAndOwnersTab() { + const auth = useAuth(); + const currentUserId = auth.user?.profile.sub; const { tenantId } = useParams<{ tenantId: string }>(); const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); @@ -289,15 +292,36 @@ export function TenantAdminsAndOwnersTab() { @@ -395,15 +419,36 @@ export function TenantAdminsAndOwnersTab() { From 83991b13cae7d5a2b3a8792f1bfed3027c925d42 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Mar 2026 12:57:00 +0900 Subject: [PATCH 03/12] feat: implement sticky header and inner scrolling for user list page --- .../src/features/users/UserListPage.tsx | 314 +++++++++--------- 1 file changed, 161 insertions(+), 153 deletions(-) diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index ebe23fbc..d6af7999 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -254,8 +254,8 @@ function UserListPage() { }; return ( -
-
+
+
{t("ui.admin.users.list.breadcrumb.section", "Users")} @@ -353,8 +353,8 @@ function UserListPage() {
- - + +
{t("ui.admin.users.list.registry.title", "User Registry")} @@ -368,8 +368,8 @@ function UserListPage() {
- -
+ +
{(errorMsg || fallbackError) && ( -
+
{errorMsg ?? fallbackError}
)} -
- - - - - 0 && - selectedUserIds.length === items.length - } - onChange={toggleSelectAll} - /> - - - {t("ui.admin.users.list.table.name_email", "NAME / EMAIL")} - - - {t("ui.admin.users.list.table.role", "ROLE")} - - - {t("ui.admin.users.list.table.status", "STATUS")} - - - {t( - "ui.admin.users.list.table.tenant_dept", - "TENANT / DEPT", - )} - - {/* Dynamic Columns from Schema */} - {userSchema.map( - (field) => - visibleColumns[field.key] !== false && ( - - {field.label} - - ), - )} - - {t("ui.admin.users.list.table.created", "CREATED")} - - - {t("ui.admin.users.list.table.actions", "ACTIONS")} - - - - - {query.isLoading && ( +
+
+
+ - - {t("msg.common.loading", "로딩 중...")} - - - )} - {!query.isLoading && items.length === 0 && ( - - - {t("msg.admin.users.list.empty", "검색 결과가 없습니다.")} - - - )} - {items.map((user) => ( - - + toggleSelectUser(user.id)} - /> - - -
-
- -
-
- {user.name} - - {user.email} - -
-
-
- - - {t(`ui.admin.role.${user.role}`, user.role)} - - - - 0 && + selectedUserIds.length === items.length } - > - {t(`ui.common.status.${user.status}`, user.status)} - - - -
- - {user.tenant?.name || user.companyCode || "-"} - - - {user.department || "-"} - -
-
- {/* Dynamic Metadata Cells */} + onChange={toggleSelectAll} + /> + + + {t( + "ui.admin.users.list.table.name_email", + "NAME / EMAIL", + )} + + + {t("ui.admin.users.list.table.role", "ROLE")} + + + {t("ui.admin.users.list.table.status", "STATUS")} + + + {t( + "ui.admin.users.list.table.tenant_dept", + "TENANT / DEPT", + )} + + {/* Dynamic Columns from Schema */} {userSchema.map( (field) => visibleColumns[field.key] !== false && ( - - {String(user.metadata?.[field.key] ?? "-")} - + + {field.label} + ), )} - - {new Date(user.createdAt).toLocaleDateString()} - - -
- - -
-
+ + {t("ui.admin.users.list.table.created", "CREATED")} + + + {t("ui.admin.users.list.table.actions", "ACTIONS")} +
- ))} - -
+ + + {query.isLoading && ( + + + {t("msg.common.loading", "로딩 중...")} + + + )} + {!query.isLoading && items.length === 0 && ( + + + {t( + "msg.admin.users.list.empty", + "검색 결과가 없습니다.", + )} + + + )} + {items.map((user) => ( + + + toggleSelectUser(user.id)} + /> + + +
+
+ +
+
+ {user.name} + + {user.email} + +
+
+
+ + + {t(`ui.admin.role.${user.role}`, user.role)} + + + + + {t(`ui.common.status.${user.status}`, user.status)} + + + +
+ + {user.tenant?.name || user.companyCode || "-"} + + + {user.department || "-"} + +
+
+ {/* Dynamic Metadata Cells */} + {userSchema.map( + (field) => + visibleColumns[field.key] !== false && ( + + {String(user.metadata?.[field.key] ?? "-")} + + ), + )} + + {new Date(user.createdAt).toLocaleDateString()} + + +
+ + +
+
+
+ ))} +
+ +
{/* Bulk Action Bar */} @@ -639,7 +647,7 @@ function UserListPage() { {/* Pagination */} {totalPages > 1 && ( -
+
+ + + ))} + + +
+
diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index 56112c98..3ac0f505 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -207,260 +207,266 @@ export function TenantAdminsAndOwnersTab() { ); return ( -
- {/* Owners Card */} - - -
- - - {t("ui.admin.tenants.owners.title", "테넌트 소유자")} - - - {t( - "msg.admin.tenants.owners.subtitle", - "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.", - )} - -
- -
- -
- - - - - {t("ui.admin.tenants.owners.table_name", "이름")} - - - {t("ui.admin.tenants.owners.table_email", "이메일")} - - - {t("ui.admin.tenants.owners.table_actions", "액션")} - - - - - {ownersQuery.isLoading ? ( - - -
- - - ) : currentOwners.length === 0 ? ( - - -
- -

- {t( - "msg.admin.tenants.owners.empty", - "등록된 소유자가 없습니다.", - )} -

-
-
-
- ) : ( - currentOwners.map((owner) => ( - - -
-
- {owner.name.charAt(0)} -
- {owner.name} -
-
- - {owner.email} - - - - -
- )) +
+
+ {/* Owners Card */} + + +
+ + + {t("ui.admin.tenants.owners.title", "테넌트 소유자")} + + + {t( + "msg.admin.tenants.owners.subtitle", + "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.", )} - -
-
-
-
+ +
+ + + +
+
+ + + + + {t("ui.admin.tenants.owners.table_name", "이름")} + + + {t("ui.admin.tenants.owners.table_email", "이메일")} + + + {t("ui.admin.tenants.owners.table_actions", "액션")} + + + + + {ownersQuery.isLoading ? ( + + +
+ + + ) : currentOwners.length === 0 ? ( + + +
+ +

+ {t( + "msg.admin.tenants.owners.empty", + "등록된 소유자가 없습니다.", + )} +

+
+
+
+ ) : ( + currentOwners.map((owner) => ( + + +
+
+ {owner.name.charAt(0)} +
+ {owner.name} +
+
+ + {owner.email} + + + + +
+ )) + )} + +
+
+
+
+ - {/* Admins Card */} - - -
- - - {t("ui.admin.tenants.admins.title", "테넌트 관리자")} - - - {t( - "msg.admin.tenants.admins.subtitle", - "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.", - )} - -
- -
- -
- - - - - {t("ui.admin.tenants.admins.table_name", "이름")} - - - {t("ui.admin.tenants.admins.table_email", "이메일")} - - - {t("ui.admin.tenants.admins.table_actions", "액션")} - - - - - {adminsQuery.isLoading ? ( - - -
- - - ) : currentAdmins.length === 0 ? ( - - -
- -

- {t( - "msg.admin.tenants.admins.empty", - "등록된 관리자가 없습니다.", - )} -

-
-
-
- ) : ( - currentAdmins.map((admin) => ( - - -
-
- {admin.name.charAt(0)} -
- {admin.name} -
-
- - {admin.email} - - - - -
- )) + {/* Admins Card */} + + +
+ + + {t("ui.admin.tenants.admins.title", "테넌트 관리자")} + + + {t( + "msg.admin.tenants.admins.subtitle", + "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.", )} - -
-
-
-
+ +
+ + + +
+
+ + + + + {t("ui.admin.tenants.admins.table_name", "이름")} + + + {t("ui.admin.tenants.admins.table_email", "이메일")} + + + {t("ui.admin.tenants.admins.table_actions", "액션")} + + + + + {adminsQuery.isLoading ? ( + + +
+ + + ) : currentAdmins.length === 0 ? ( + + +
+ +

+ {t( + "msg.admin.tenants.admins.empty", + "등록된 관리자가 없습니다.", + )} +

+
+
+
+ ) : ( + currentAdmins.map((admin) => ( + + +
+
+ {admin.name.charAt(0)} +
+ {admin.name} +
+
+ + {admin.email} + + + + +
+ )) + )} + +
+
+
+
+ +
{/* Common Dialog for adding users */} g.id === selectedGroupId); return ( -
-
+
+
{/* 그룹 생성 폼 */} - - + + {" "} {t("ui.admin.groups.create.title", "새 그룹 생성")} @@ -359,7 +359,7 @@ function TenantGroupsPage() { )} - +
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */} {currentGroup && ( - - + + {t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", { @@ -541,8 +545,8 @@ function TenantGroupsPage() { )} - -
+ +
- - - - - {t("ui.admin.groups.members.table.name", "이름")} - - - {t("ui.admin.groups.members.table.email", "이메일")} - - - {t("ui.admin.groups.members.table.remove", "제거")} - - - - - {currentGroup.members?.length === 0 && ( - - - {t("msg.admin.groups.members.empty", "멤버가 없습니다.")} - - - )} - {currentGroup.members?.map((user) => ( - - {user.name} - - {user.email} - - - - - - ))} - -
+
+
+ + + + + {t("ui.admin.groups.members.table.name", "이름")} + + + {t("ui.admin.groups.members.table.email", "이메일")} + + + {t("ui.admin.groups.members.table.remove", "제거")} + + + + + {currentGroup.members?.length === 0 && ( + + + {t( + "msg.admin.groups.members.empty", + "멤버가 없습니다.", + )} + + + )} + {currentGroup.members?.map((user) => ( + + + {user.name} + + + {user.email} + + + + + + ))} + +
+
+
)} diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index d1aa14e6..a434ee14 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -116,8 +116,8 @@ function TenantListPage() { }; return ( -
-
+
+
{t("ui.admin.tenants.breadcrumb.section", "Tenants")} @@ -156,8 +156,8 @@ function TenantListPage() {
- - + +
{t("ui.admin.tenants.registry.title", "Tenant Registry")} @@ -172,120 +172,132 @@ function TenantListPage() { {t("ui.common.badge.admin_only", "Admin only")} - + {(errorMsg || fallbackError) && (
{errorMsg ?? fallbackError}
)} - - - - - {t("ui.admin.tenants.table.name", "NAME")} - - - {t("ui.admin.tenants.table.type", "TYPE")} - - - {t("ui.admin.tenants.table.slug", "SLUG")} - - - {t("ui.admin.tenants.table.status", "STATUS")} - - - {t("ui.admin.tenants.table.members", "MEMBERS")} - - - {t("ui.admin.tenants.table.updated", "UPDATED")} - - - {t("ui.admin.tenants.table.actions", "ACTIONS")} - - - - - {query.isLoading && ( - - - {t("msg.common.loading", "로딩 중...")} - - - )} - {!query.isLoading && tenants.length === 0 && ( - - - {t( - "msg.admin.tenants.empty", - "아직 등록된 테넌트가 없습니다.", - )} - - - )} - {tenants.map((tenant) => ( - - {tenant.name} - - - {t( - `domain.tenant_type.${tenant.type?.toLowerCase()}`, - tenant.type, - )} - - - - {tenant.slug} - - - - {t(`ui.common.status.${tenant.status}`, tenant.status)} - - - - {tenant.memberCount} - - - {tenant.updatedAt - ? new Date(tenant.updatedAt).toLocaleString("ko-KR") - : "-"} - - -
-
+ + + + {t("ui.admin.tenants.table.name", "NAME")} + + + {t("ui.admin.tenants.table.type", "TYPE")} + + + {t("ui.admin.tenants.table.slug", "SLUG")} + + + {t("ui.admin.tenants.table.status", "STATUS")} + + + {t("ui.admin.tenants.table.members", "MEMBERS")} + + + {t("ui.admin.tenants.table.updated", "UPDATED")} + + + {t("ui.admin.tenants.table.actions", "ACTIONS")} + + + + + {query.isLoading && ( + + + {t("msg.common.loading", "로딩 중...")} + + + )} + {!query.isLoading && tenants.length === 0 && ( + + - - {t("ui.common.edit", "편집")} - - - - - - ))} - -
+ {t( + "msg.admin.tenants.empty", + "아직 등록된 테넌트가 없습니다.", + )} + + + )} + {tenants.map((tenant) => ( + + + {tenant.name} + + + + {t( + `domain.tenant_type.${tenant.type?.toLowerCase()}`, + tenant.type, + )} + + + + {tenant.slug} + + + + {t( + `ui.common.status.${tenant.status}`, + tenant.status, + )} + + + + {tenant.memberCount} + + + {tenant.updatedAt + ? new Date(tenant.updatedAt).toLocaleString("ko-KR") + : "-"} + + +
+ + +
+
+
+ ))} + + +
+
diff --git a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx index 73276498..cd58c513 100644 --- a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx @@ -34,8 +34,8 @@ function TenantSubTenantsPage() { const subTenants = data?.items ?? []; return ( - - + +
@@ -57,64 +57,73 @@ function TenantSubTenantsPage() { - - - - - - {t("ui.admin.tenants.sub.table.name", "NAME")} - - - {t("ui.admin.tenants.sub.table.slug", "SLUG")} - - - {t("ui.admin.tenants.sub.table.status", "STATUS")} - - - {t("ui.admin.tenants.sub.table.action", "ACTION")} - - - - - {subTenants.length === 0 && ( - - - {t("msg.admin.tenants.sub.empty", "하위 테넌트가 없습니다.")} - - - )} - {subTenants.map((tenant) => ( - - {tenant.name} - - {tenant.slug} - - - - {t(`ui.common.status.${tenant.status}`, tenant.status)} - - - - - - - ))} - -
+ +
+
+ + + + + {t("ui.admin.tenants.sub.table.name", "NAME")} + + + {t("ui.admin.tenants.sub.table.slug", "SLUG")} + + + {t("ui.admin.tenants.sub.table.status", "STATUS")} + + + {t("ui.admin.tenants.sub.table.action", "ACTION")} + + + + + {subTenants.length === 0 && ( + + + {t( + "msg.admin.tenants.sub.empty", + "하위 테넌트가 없습니다.", + )} + + + )} + {subTenants.map((tenant) => ( + + + {tenant.name} + + + {tenant.slug} + + + + {t(`ui.common.status.${tenant.status}`, tenant.status)} + + + + + + + ))} + +
+
+
); diff --git a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx index fe722db9..e7f63104 100644 --- a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx @@ -42,8 +42,8 @@ function TenantUsersPage() { const users = usersQuery.data?.items ?? []; return ( - - + + {t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", { @@ -51,66 +51,70 @@ function TenantUsersPage() { })} - - - - - - {t("ui.admin.tenants.members.table.name", "NAME")} - - - {t("ui.admin.tenants.members.table.email", "EMAIL")} - - - {t("ui.admin.tenants.members.table.role", "ROLE")} - - - {t("ui.admin.tenants.members.table.status", "STATUS")} - - - - - {users.length === 0 && ( - - - {t( - "msg.admin.tenants.members.empty", - "소속된 사용자가 없습니다.", - )} - - - )} - {users.map((user) => ( - - {user.name} - -
- - {user.email} -
-
- - - {t( - `ui.common.role.${user.role}`, - user.role.replace("_", " "), - )} - - - - - {t(`ui.common.status.${user.status}`, user.status)} - - -
- ))} -
-
+ +
+
+ + + + + {t("ui.admin.tenants.members.table.name", "NAME")} + + + {t("ui.admin.tenants.members.table.email", "EMAIL")} + + + {t("ui.admin.tenants.members.table.role", "ROLE")} + + + {t("ui.admin.tenants.members.table.status", "STATUS")} + + + + + {users.length === 0 && ( + + + {t( + "msg.admin.tenants.members.empty", + "소속된 사용자가 없습니다.", + )} + + + )} + {users.map((user) => ( + + {user.name} + +
+ + {user.email} +
+
+ + + {t( + `ui.common.role.${user.role}`, + user.role.replace("_", " "), + )} + + + + + {t(`ui.common.status.${user.status}`, user.status)} + + +
+ ))} +
+
+
+
); diff --git a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx index c4c84dc4..074a5f0f 100644 --- a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx +++ b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx @@ -35,8 +35,8 @@ export default function GlobalUserGroupListPage() { return
Loading tenants and groups...
; return ( -
-
+
+

User Groups

@@ -46,7 +46,7 @@ export default function GlobalUserGroupListPage() {

-
+
{tenantList?.items.map((tenant) => ( ))} @@ -62,8 +62,8 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) { }); return ( - - + +
@@ -83,62 +83,66 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) { - - - - - 그룹명 - 설명 - 멤버 수 - 작업 - - - - {isLoading ? ( - - - Loading... - - - ) : groups?.length === 0 ? ( - - - 등록된 유저 그룹이 없습니다. - - - ) : ( - groups?.map((group) => ( - - -
- - - {group.name} - -
-
- {group.description || "-"} - {group.members?.length || 0} 명 - - - + +
+
+
+ + + 그룹명 + 설명 + 멤버 수 + 작업 - )) - )} - -
+ + + {isLoading ? ( + + + Loading... + + + ) : groups?.length === 0 ? ( + + + 등록된 유저 그룹이 없습니다. + + + ) : ( + groups?.map((group) => ( + + +
+ + + {group.name} + +
+
+ {group.description || "-"} + {group.members?.length || 0} 명 + + + +
+ )) + )} +
+ +
+
); diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index c56f8702..ad4ffdc7 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -929,9 +929,9 @@ function TenantUserGroupsTab() { const BaseIcon = getTenantIcon(currentBase.type); return ( -
- - +
+ +
@@ -1078,7 +1078,7 @@ function TenantUserGroupsTab() {
-
+
)}
- - - - - - {t("ui.admin.tenants.table.name", "NAME")} - - - {t("ui.admin.tenants.table.slug", "SLUG")} - - - {t("ui.admin.tenants.table.members", "MEMBERS")} - - - {t("ui.admin.tenants.table.status", "STATUS")} - - - {t("ui.admin.tenants.table.actions", "ACTIONS")} - - - - - - -
+ +
+
+ + + + + {t("ui.admin.tenants.table.name", "NAME")} + + + {t("ui.admin.tenants.table.slug", "SLUG")} + + + {t("ui.admin.tenants.table.members", "MEMBERS")} + + + {t("ui.admin.tenants.table.status", "STATUS")} + + + {t("ui.admin.tenants.table.actions", "ACTIONS")} + + + + + + +
+
+
diff --git a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx index 98b9f9c7..860a8142 100644 --- a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx +++ b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx @@ -211,8 +211,8 @@ export function UserGroupDetailPage() { ); return ( -
-
+
+
-
+
{/* Members Management */} - - + +
{t("ui.admin.groups.detail.members_title", "구성원 관리")} @@ -347,88 +347,90 @@ export function UserGroupDetailPage() { - -
- - - - - {t("ui.admin.users.list.table.name_email", "사용자")} - - - {t("ui.admin.groups.table.actions", "액션")} - - - - - {!currentGroup.members || - currentGroup.members.length === 0 ? ( + +
+
+
+ - - {t( - "msg.admin.groups.members.empty", - "구성원이 없습니다.", - )} - + + {t("ui.admin.users.list.table.name_email", "사용자")} + + + {t("ui.admin.groups.table.actions", "액션")} + - ) : ( - currentGroup.members.map((member) => ( - - -
-
- {member.name.charAt(0)} -
-
-

- {member.name} -

-

- {member.email} -

-
-
-
- - +
+ + {!currentGroup.members || + currentGroup.members.length === 0 ? ( + + + {t( + "msg.admin.groups.members.empty", + "구성원이 없습니다.", + )} - )) - )} - -
+ ) : ( + currentGroup.members.map((member) => ( + + +
+
+ {member.name.charAt(0)} +
+
+

+ {member.name} +

+

+ {member.email} +

+
+
+
+ + + +
+ )) + )} + + +
{/* Roles/Permissions Management (Keto Based) */} - - + +
{t("ui.admin.groups.detail.permissions_title", "권한 관리")} @@ -530,86 +532,88 @@ export function UserGroupDetailPage() { - -
- - - - - {t("ui.admin.users.detail.form.tenant", "대상 테넌트")} - - - {t("ui.admin.users.detail.form.role", "역할")} - - - {t("ui.admin.groups.table.actions", "액션")} - - - - - {isRolesLoading ? ( + +
+
+
+ - -
- + + {t("ui.admin.users.detail.form.tenant", "대상 테넌트")} + + + {t("ui.admin.users.detail.form.role", "역할")} + + + {t("ui.admin.groups.table.actions", "액션")} + - ) : !groupRoles || groupRoles.length === 0 ? ( - - - {t( - "msg.admin.groups.roles.empty", - "할당된 역할이 없습니다.", - )} - - - ) : ( - groupRoles.map((role, idx) => ( - - -
- {role.tenantName || role.tenantId} -
-
- - - {role.relation} - - - - + + + {isRolesLoading ? ( + + +
- )) - )} - -
+ ) : !groupRoles || groupRoles.length === 0 ? ( + + + {t( + "msg.admin.groups.roles.empty", + "할당된 역할이 없습니다.", + )} + + + ) : ( + groupRoles.map((role, idx) => ( + + +
+ {role.tenantName || role.tenantId} +
+
+ + + {role.relation} + + + + + +
+ )) + )} + + +
From 926c26b1ad8c730a840d11f02e2fe55f7a671e24 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Mar 2026 13:15:50 +0900 Subject: [PATCH 05/12] feat: apply sticky header and inner scroll to audit logs page --- .../src/features/audit/AuditLogsPage.tsx | 794 +++++++++--------- 1 file changed, 403 insertions(+), 391 deletions(-) diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index a10c5675..0e90b4b1 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -158,8 +158,8 @@ function AuditLogsPage() { } return ( -
-
+
+
{t("ui.admin.audit.breadcrumb.section", "Audit")} @@ -194,409 +194,421 @@ function AuditLogsPage() {
-
- - -
- - {t("ui.admin.audit.registry.title", "Audit registry")} - - - {t( - "msg.admin.audit.registry.count", - "로드된 로그 {{count}}건", - { count: logs.length }, + + +
+ + {t("ui.admin.audit.registry.title", "Audit registry")} + + + {t("msg.admin.audit.registry.count", "로드된 로그 {{count}}건", { + count: logs.length, + })} + +
+ + {t("ui.common.badge.command_only", "Command only")} + +
+ +
+
+ + setFilterDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleAddFilter(); + } + }} + placeholder={t( + "ui.admin.audit.filters.placeholder", + "필터 추가 (예: status:failure)", )} - + className="w-full bg-transparent text-sm text-foreground outline-none" + /> +
- - {t("ui.common.badge.command_only", "Command only")} - - - -
-
- - setFilterDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - handleAddFilter(); + {filters.length === 0 ? ( + + {t("msg.admin.audit.filters.empty", "필터 없음")} + + ) : ( + filters.map((filter) => ( + + + {filter} + -
- {filters.length === 0 ? ( - - {t("msg.admin.audit.filters.empty", "필터 없음")} - - ) : ( - filters.map((filter) => ( - - - {filter} - - - )) - )} -
- - - - - {t("ui.admin.audit.table.time", "TIME")} - - - {t("ui.admin.audit.table.actor", "ACTOR (ID)")} - - - {t("ui.admin.audit.table.request", "REQUEST")} - - - {t("ui.admin.audit.table.path", "PATH")} - - - {t("ui.admin.audit.table.status", "STATUS")} - - - {t("ui.admin.audit.table.action_target", "Action / Target")} - - - - - - {isLoading && ( + × + + + )) + )} + +
+
+
+ - - {t("msg.common.loading", "로딩 중...")} - - - )} - {!isLoading && logs.length === 0 && ( - - + + {t("ui.admin.audit.table.time", "TIME")} + + + {t("ui.admin.audit.table.actor", "ACTOR (ID)")} + + + {t("ui.admin.audit.table.request", "REQUEST")} + + + {t("ui.admin.audit.table.path", "PATH")} + + + {t("ui.admin.audit.table.status", "STATUS")} + + {t( - "msg.admin.audit.empty", - "아직 수집된 감사 로그가 없습니다.", + "ui.admin.audit.table.action_target", + "Action / Target", )} - + + - )} - {logs.map((row, index) => { - const details = parseDetails(row.details); - const actionLabel = - details.action || - (details.method && details.path - ? `${details.method} ${details.path}` - : row.event_type); - const rowKey = `${row.event_id}-${row.timestamp}-${index}`; - const isExpanded = Boolean(expandedRows[rowKey]); - return ( - - - - {(() => { - const { date, time } = formatIsoDateTime( - row.timestamp, - ); - return ( -
-
{date}
-
{time}
-
- ); - })()} -
- -
- - {row.user_id || details.actor_id || "-"} - - {(row.user_id || details.actor_id) && ( - - )} -
-
- -
- - {formatCellValue(details.request_id)} - - {details.request_id && ( - - )} -
-
- -
- {formatCellValue(details.method)} -
-
- {formatCellValue(details.path)} -
-
- - - {row.status} - - - -
- {actionLabel} -
- {details.target && ( +
+ + {isLoading && ( + + + {t("msg.common.loading", "로딩 중...")} + + + )} + {!isLoading && logs.length === 0 && ( + + + {t( + "msg.admin.audit.empty", + "아직 수집된 감사 로그가 없습니다.", + )} + + + )} + {logs.map((row, index) => { + const details = parseDetails(row.details); + const actionLabel = + details.action || + (details.method && details.path + ? `${details.method} ${details.path}` + : row.event_type); + const rowKey = `${row.event_id}-${row.timestamp}-${index}`; + const isExpanded = Boolean(expandedRows[rowKey]); + return ( + + + + {(() => { + const { date, time } = formatIsoDateTime( + row.timestamp, + ); + return ( +
+
{date}
+
{time}
+
+ ); + })()} +
+
- - {t( - "ui.admin.audit.target", - "Target · {{target}}", - { - target: details.target, - }, - )} - - -
- )} -
- - - -
- {isExpanded && ( - - -
-
-
- {t( - "ui.admin.audit.details.request", - "Request", + + {row.user_id || details.actor_id || "-"} + + {(row.user_id || details.actor_id) && ( +
-
-
- {t("ui.admin.audit.details.actor", "Actor")} -
-
- {t( - "ui.admin.audit.details.actor_id", - "Actor ID · {{value}}", - { - value: - row.user_id || details.actor_id || "-", - }, - )} -
-
- {t( - "ui.admin.audit.details.tenant", - "Tenant · {{value}}", - { - value: formatCellValue(details.tenant_id), - }, - )} -
-
- {t( - "ui.admin.audit.details.device", - "Device · {{value}}", - { - value: formatCellValue(row.device_id), - }, - )} -
-
-
-
- {t("ui.admin.audit.details.result", "Result")} -
-
- {t( - "ui.admin.audit.details.error", - "Error · {{value}}", - { - value: formatCellValue(details.error), - }, - )} -
-
- {t( - "ui.admin.audit.details.before", - "Before · {{value}}", - { - value: formatCellValue(details.before), - }, - )} -
-
- {t( - "ui.admin.audit.details.after", - "After · {{value}}", - { - value: formatCellValue(details.after), - }, - )} -
-
+ onClick={() => + handleCopy( + row.user_id || details.actor_id || "", + ) + } + > + + + )}
+ +
+ + {formatCellValue(details.request_id)} + + {details.request_id && ( + + )} +
+
+ +
+ {formatCellValue(details.method)} +
+
+ {formatCellValue(details.path)} +
+
+ + + {row.status} + + + +
+ {actionLabel} +
+ {details.target && ( +
+ + {t( + "ui.admin.audit.target", + "Target · {{target}}", + { + target: details.target, + }, + )} + + +
+ )} +
+ + + - )} - - ); - })} - -
-
- {hasNextPage ? ( - - ) : ( - - {t("msg.admin.audit.end", "End of audit feed")} - - )} + {isExpanded && ( + + +
+
+
+ {t( + "ui.admin.audit.details.request", + "Request", + )} +
+
+ {t( + "ui.admin.audit.details.request_id", + "Request ID · {{value}}", + { + value: formatCellValue( + details.request_id, + ), + }, + )} +
+
+ {t( + "ui.admin.audit.details.event_id", + "Event ID · {{value}}", + { + value: formatCellValue(row.event_id), + }, + )} +
+
+ {t( + "ui.admin.audit.details.ip", + "IP · {{value}}", + { + value: formatCellValue(row.ip_address), + }, + )} +
+
+ {t( + "ui.admin.audit.details.latency", + "Latency · {{value}}", + { + value: + details.latency_ms !== undefined + ? `${details.latency_ms}ms` + : "-", + }, + )} +
+
+
+
+ {t("ui.admin.audit.details.actor", "Actor")} +
+
+ {t( + "ui.admin.audit.details.actor_id", + "Actor ID · {{value}}", + { + value: + row.user_id || + details.actor_id || + "-", + }, + )} +
+
+ {t( + "ui.admin.audit.details.tenant", + "Tenant · {{value}}", + { + value: formatCellValue( + details.tenant_id, + ), + }, + )} +
+
+ {t( + "ui.admin.audit.details.device", + "Device · {{value}}", + { + value: formatCellValue(row.device_id), + }, + )} +
+
+
+
+ {t( + "ui.admin.audit.details.result", + "Result", + )} +
+
+ {t( + "ui.admin.audit.details.error", + "Error · {{value}}", + { + value: formatCellValue(details.error), + }, + )} +
+
+ {t( + "ui.admin.audit.details.before", + "Before · {{value}}", + { + value: formatCellValue(details.before), + }, + )} +
+
+ {t( + "ui.admin.audit.details.after", + "After · {{value}}", + { + value: formatCellValue(details.after), + }, + )} +
+
+
+
+
+ )} + + ); + })} + +
-
- -
+
+
+ {hasNextPage ? ( + + ) : ( + + {t("msg.admin.audit.end", "End of audit feed")} + + )} +
+ +
); } From aff21f195b0b480b22a2fe0b4ca6a0db40b1fce0 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Mar 2026 13:23:02 +0900 Subject: [PATCH 06/12] style: make page headers sticky and improve table header contrast/visibility --- adminfront/src/components/ui/table.tsx | 2 +- adminfront/src/features/api-keys/ApiKeyListPage.tsx | 4 ++-- adminfront/src/features/audit/AuditLogsPage.tsx | 4 ++-- .../features/tenants/routes/TenantAdminsAndOwnersTab.tsx | 4 ++-- adminfront/src/features/tenants/routes/TenantGroupsPage.tsx | 4 ++-- adminfront/src/features/tenants/routes/TenantListPage.tsx | 4 ++-- .../src/features/tenants/routes/TenantSubTenantsPage.tsx | 2 +- adminfront/src/features/tenants/routes/TenantUsersPage.tsx | 2 +- .../features/user-groups/routes/GlobalUserGroupListPage.tsx | 4 ++-- .../src/features/user-groups/routes/TenantUserGroupsTab.tsx | 4 ++-- .../src/features/user-groups/routes/UserGroupDetailPage.tsx | 6 +++--- adminfront/src/features/users/UserListPage.tsx | 4 ++-- 12 files changed, 22 insertions(+), 22 deletions(-) diff --git a/adminfront/src/components/ui/table.tsx b/adminfront/src/components/ui/table.tsx index b20952d6..8ca9d06c 100644 --- a/adminfront/src/components/ui/table.tsx +++ b/adminfront/src/components/ui/table.tsx @@ -69,7 +69,7 @@ const TableHead = React.forwardRef< -
+
@@ -129,7 +129,7 @@ function ApiKeyListPage() {
- + {t("ui.admin.api_keys.list.table.name", "NAME")} diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index 0e90b4b1..74f3baa2 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -159,7 +159,7 @@ function AuditLogsPage() { return (
-
+
{t("ui.admin.audit.breadcrumb.section", "Audit")} @@ -267,7 +267,7 @@ function AuditLogsPage() {
- + {t("ui.admin.audit.table.time", "TIME")} diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index 3ac0f505..e1ebb9df 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -236,7 +236,7 @@ export function TenantAdminsAndOwnersTab() {
- + {t("ui.admin.tenants.owners.table_name", "이름")} @@ -365,7 +365,7 @@ export function TenantAdminsAndOwnersTab() {
- + {t("ui.admin.tenants.admins.table_name", "이름")} diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index f53f90b3..14d05871 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -462,7 +462,7 @@ function TenantGroupsPage() {
- + {t("ui.admin.groups.table.name", "NAME")} @@ -559,7 +559,7 @@ function TenantGroupsPage() {
- + {t("ui.admin.groups.members.table.name", "이름")} diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index a434ee14..c1e68d3a 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -117,7 +117,7 @@ function TenantListPage() { return (
-
+
{t("ui.admin.tenants.breadcrumb.section", "Tenants")} @@ -182,7 +182,7 @@ function TenantListPage() {
- + {t("ui.admin.tenants.table.name", "NAME")} diff --git a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx index cd58c513..c0946db1 100644 --- a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx @@ -61,7 +61,7 @@ function TenantSubTenantsPage() {
- + {t("ui.admin.tenants.sub.table.name", "NAME")} diff --git a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx index e7f63104..f64958d3 100644 --- a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx @@ -55,7 +55,7 @@ function TenantUsersPage() {
- + {t("ui.admin.tenants.members.table.name", "NAME")} diff --git a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx index 074a5f0f..e4bdae5b 100644 --- a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx +++ b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx @@ -36,7 +36,7 @@ export default function GlobalUserGroupListPage() { return (
-
+

User Groups

@@ -87,7 +87,7 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {

- + 그룹명 설명 diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index ad4ffdc7..15988252 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -228,7 +228,7 @@ const MemberTable: React.FC<{ }> = ({ members, isLoading, onRefresh, showTenant }) => (
- + {t("ui.admin.users.table.name", "NAME")} @@ -1106,7 +1106,7 @@ function TenantUserGroupsTab() {
- + {t("ui.admin.tenants.table.name", "NAME")} diff --git a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx index 860a8142..384b2fdd 100644 --- a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx +++ b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx @@ -212,7 +212,7 @@ export function UserGroupDetailPage() { return (
-
+
- + {t("ui.admin.users.list.table.name_email", "사용자")} @@ -536,7 +536,7 @@ export function UserGroupDetailPage() {
- + {t("ui.admin.users.detail.form.tenant", "대상 테넌트")} diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index d6af7999..b2f6219a 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -255,7 +255,7 @@ function UserListPage() { return (
-
+
{t("ui.admin.users.list.breadcrumb.section", "Users")} @@ -420,7 +420,7 @@ function UserListPage() {
- + Date: Thu, 19 Mar 2026 13:30:16 +0900 Subject: [PATCH 07/12] style: fix table header stickiness and improve visibility for all list pages --- adminfront/src/components/ui/table.tsx | 4 ++-- adminfront/src/features/api-keys/ApiKeyListPage.tsx | 4 ++-- adminfront/src/features/audit/AuditLogsPage.tsx | 2 +- .../src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx | 4 ++-- adminfront/src/features/tenants/routes/TenantGroupsPage.tsx | 4 ++-- adminfront/src/features/tenants/routes/TenantListPage.tsx | 4 ++-- .../src/features/tenants/routes/TenantSubTenantsPage.tsx | 2 +- adminfront/src/features/tenants/routes/TenantUsersPage.tsx | 2 +- .../features/user-groups/routes/GlobalUserGroupListPage.tsx | 4 ++-- .../src/features/user-groups/routes/TenantUserGroupsTab.tsx | 4 ++-- .../src/features/user-groups/routes/UserGroupDetailPage.tsx | 4 ++-- adminfront/src/features/users/UserListPage.tsx | 2 +- 12 files changed, 20 insertions(+), 20 deletions(-) diff --git a/adminfront/src/components/ui/table.tsx b/adminfront/src/components/ui/table.tsx index 8ca9d06c..62c33432 100644 --- a/adminfront/src/components/ui/table.tsx +++ b/adminfront/src/components/ui/table.tsx @@ -5,7 +5,7 @@ const Table = React.forwardRef< HTMLTableElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
{(errorMsg || fallbackError) && ( -
+
{errorMsg ?? fallbackError}
)} @@ -129,7 +129,7 @@ function ApiKeyListPage() {
- + {t("ui.admin.api_keys.list.table.name", "NAME")} diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index 74f3baa2..530c3a42 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -267,7 +267,7 @@ function AuditLogsPage() {
- + {t("ui.admin.audit.table.time", "TIME")} diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index e1ebb9df..33faca35 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -236,7 +236,7 @@ export function TenantAdminsAndOwnersTab() {
- + {t("ui.admin.tenants.owners.table_name", "이름")} @@ -365,7 +365,7 @@ export function TenantAdminsAndOwnersTab() {
- + {t("ui.admin.tenants.admins.table_name", "이름")} diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index 14d05871..511f6680 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -462,7 +462,7 @@ function TenantGroupsPage() {
- + {t("ui.admin.groups.table.name", "NAME")} @@ -559,7 +559,7 @@ function TenantGroupsPage() {
- + {t("ui.admin.groups.members.table.name", "이름")} diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index c1e68d3a..2d9705d0 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -174,7 +174,7 @@ function TenantListPage() { {(errorMsg || fallbackError) && ( -
+
{errorMsg ?? fallbackError}
)} @@ -182,7 +182,7 @@ function TenantListPage() {
- + {t("ui.admin.tenants.table.name", "NAME")} diff --git a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx index c0946db1..dbc965a9 100644 --- a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx @@ -61,7 +61,7 @@ function TenantSubTenantsPage() {
- + {t("ui.admin.tenants.sub.table.name", "NAME")} diff --git a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx index f64958d3..e2956a68 100644 --- a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx @@ -55,7 +55,7 @@ function TenantUsersPage() {
- + {t("ui.admin.tenants.members.table.name", "NAME")} diff --git a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx index e4bdae5b..7bfb4891 100644 --- a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx +++ b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx @@ -85,9 +85,9 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
-
+
- + 그룹명 설명 diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index 15988252..ad60e0d3 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -228,7 +228,7 @@ const MemberTable: React.FC<{ }> = ({ members, isLoading, onRefresh, showTenant }) => (
- + {t("ui.admin.users.table.name", "NAME")} @@ -1106,7 +1106,7 @@ function TenantUserGroupsTab() {
- + {t("ui.admin.tenants.table.name", "NAME")} diff --git a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx index 384b2fdd..4bf60753 100644 --- a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx +++ b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx @@ -351,7 +351,7 @@ export function UserGroupDetailPage() {
- + {t("ui.admin.users.list.table.name_email", "사용자")} @@ -536,7 +536,7 @@ export function UserGroupDetailPage() {
- + {t("ui.admin.users.detail.form.tenant", "대상 테넌트")} diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index b2f6219a..18f8bbec 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -420,7 +420,7 @@ function UserListPage() {
- + Date: Thu, 19 Mar 2026 13:40:50 +0900 Subject: [PATCH 08/12] feat: add schema tab access control and user password generator --- .../tenants/routes/TenantDetailPage.tsx | 32 ++++--- .../tenants/routes/TenantSchemaPage.tsx | 34 ++++++- .../src/features/users/UserDetailPage.tsx | 94 +++++++++++++++++-- 3 files changed, 139 insertions(+), 21 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index 44bad3fc..ac77a3ac 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowLeft } from "lucide-react"; import { Link, Outlet, useLocation, useParams } from "react-router-dom"; import { Badge } from "../../../components/ui/badge"; -import { fetchTenant } from "../../../lib/adminApi"; +import { fetchMe, fetchTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; function TenantDetailPage() { @@ -16,6 +16,14 @@ function TenantDetailPage() { enabled: tenantId.length > 0, }); + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); + + const canAccessSchema = + profile?.role === "super_admin" || profile?.role === "tenant_admin"; + const isFederationTab = location.pathname.includes("/federation"); const isPermissionsTab = location.pathname.includes("/permissions"); const isOrganizationTab = location.pathname.includes("/organization"); @@ -98,16 +106,18 @@ function TenantDetailPage() { > {t("ui.admin.tenants.detail.tab_organization", "조직 관리")} - - {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} - + {canAccessSchema && ( + + {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} + + )} {/* Outlet for nested routes */} diff --git a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx index 1c95d705..f9a8d04a 100644 --- a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx @@ -14,7 +14,7 @@ import { } from "../../../components/ui/card"; import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; -import { fetchTenant, updateTenant } from "../../../lib/adminApi"; +import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; type SchemaFieldType = "text" | "number" | "boolean" | "date"; @@ -40,6 +40,38 @@ export function TenantSchemaPage() { const { tenantId } = useParams<{ tenantId: string }>(); const queryClient = useQueryClient(); + const { data: profile, isLoading: isProfileLoading } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); + + const canAccess = + profile?.role === "super_admin" || profile?.role === "tenant_admin"; + + if (isProfileLoading) { + return ( +
+ {t("msg.common.loading", "로딩 중...")} +
+ ); + } + + if (!canAccess) { + return ( +
+

+ {t("msg.common.forbidden", "접근 권한이 없습니다.")} +

+

+ {t( + "msg.admin.tenants.schema.forbidden_desc", + "사용자 스키마 설정은 관리자만 접근할 수 있습니다.", + )} +

+
+ ); + } + if (!tenantId) { return (
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 6278c917..9842cfa9 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -4,6 +4,10 @@ import { ArrowLeft, BadgeCheck, Building2, + Copy, + Dices, + Eye, + EyeOff, Loader2, Save, Users, @@ -15,6 +19,7 @@ import { useForm, } from "react-hook-form"; import { Link, useNavigate, useParams } from "react-router-dom"; +import { toast } from "sonner"; import { Button } from "../../components/ui/button"; import { Card, @@ -36,6 +41,19 @@ import { } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; +// Utility for secure password generation +function generateSecurePassword(length = 16) { + const charset = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+~`|}{[]:;?><,./-="; + let retVal = ""; + const values = new Uint32Array(length); + crypto.getRandomValues(values); + for (let i = 0; i < length; i++) { + retVal += charset.charAt(values[i] % charset.length); + } + return retVal; +} + type UserSchemaField = { key: string; label?: string; @@ -148,6 +166,7 @@ function UserDetailPage() { const queryClient = useQueryClient(); const [error, setError] = React.useState(null); const [successMsg, setSuccessMsg] = React.useState(null); + const [showPassword, setShowPassword] = React.useState(false); const { data: profile } = useQuery({ queryKey: ["me"], @@ -175,6 +194,7 @@ function UserDetailPage() { handleSubmit, reset, watch, + setValue, formState: { errors }, } = useForm({ defaultValues: { @@ -194,6 +214,28 @@ function UserDetailPage() { const isAdmin = profile?.role === "super_admin" || profile?.role === "tenant_admin"; + const handleGeneratePassword = () => { + const newPass = generateSecurePassword(); + setValue("password", newPass); + setShowPassword(true); + toast.success( + t( + "msg.admin.users.detail.password_generated", + "안전한 비밀번호가 생성되었습니다.", + ), + ); + }; + + const handleCopyPassword = () => { + const pass = watch("password"); + if (pass) { + navigator.clipboard.writeText(pass); + toast.success( + t("msg.common.copied_to_clipboard", "클립보드에 복사되었습니다."), + ); + } + }; + React.useEffect(() => { if (user) { reset({ @@ -556,15 +598,49 @@ function UserDetailPage() { "비밀번호 변경", )} - +
+
+ + +
+ + +

{t( "msg.admin.users.detail.security.password_hint", From 896a51df3d382beb52ca2cbe26391bff9237929f Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Mar 2026 14:02:56 +0900 Subject: [PATCH 09/12] feat: add schema check on bulk user move and support for float/datetime in custom metadata --- .../tenants/routes/TenantSchemaPage.tsx | 57 +++++++- .../src/features/users/UserListPage.tsx | 3 + .../components/UserBulkMoveGroupModal.tsx | 125 +++++++++++++++++- backend/internal/handler/user_handler.go | 82 ++++++++++++ 4 files changed, 256 insertions(+), 11 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx index f9a8d04a..221c9a90 100644 --- a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx @@ -17,7 +17,13 @@ import { Label } from "../../../components/ui/label"; import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -type SchemaFieldType = "text" | "number" | "boolean" | "date"; +type SchemaFieldType = + | "text" + | "number" + | "boolean" + | "date" + | "float" + | "datetime"; type SchemaField = { id: string; @@ -27,6 +33,7 @@ type SchemaField = { required: boolean; adminOnly: boolean; validation?: string; + unsigned?: boolean; }; function createFieldId() { @@ -98,13 +105,16 @@ export function TenantSchemaPage() { type: field?.type === "number" || field?.type === "boolean" || - field?.type === "date" + field?.type === "date" || + field?.type === "float" || + field?.type === "datetime" ? field.type : "text", required: Boolean(field?.required), adminOnly: Boolean(field?.adminOnly), validation: typeof field?.validation === "string" ? field.validation : "", + unsigned: Boolean(field?.unsigned), })), ); } @@ -146,6 +156,7 @@ export function TenantSchemaPage() { required: false, adminOnly: false, validation: "", + unsigned: false, }, ]); }; @@ -242,9 +253,13 @@ export function TenantSchemaPage() { nextType === "text" || nextType === "number" || nextType === "boolean" || - nextType === "date" + nextType === "date" || + nextType === "float" || + nextType === "datetime" ) { - updateField(index, { type: nextType }); + updateField(index, { + type: nextType as SchemaFieldType, + }); } }} > @@ -257,7 +272,13 @@ export function TenantSchemaPage() { + +

-
+
+ {(field.type === "number" || field.type === "float") && ( + + )}
+ selectedUserIds.includes(u.id), + )} onSuccess={() => { query.refetch(); setSelectedUserIds([]); diff --git a/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx b/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx index ad1c51a1..e0b9f57f 100644 --- a/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx +++ b/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { FolderTree, Loader2, Search } from "lucide-react"; +import { AlertTriangle, FolderTree, Loader2, Search } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { Button } from "../../../components/ui/button"; @@ -18,19 +18,28 @@ import { ScrollArea } from "../../../components/ui/scroll-area"; import { type GroupSummary, type TenantSummary, + type UserSummary, bulkUpdateUsers, fetchGroups, fetchTenants, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +type UserSchemaField = { + key: string; + label: string; + required?: boolean; +}; + interface UserBulkMoveGroupModalProps { userIds: string[]; + selectedUsers?: UserSummary[]; onSuccess?: () => void; } export function UserBulkMoveGroupModal({ userIds, + selectedUsers = [], onSuccess, }: UserBulkMoveGroupModalProps) { const [open, setOpen] = React.useState(false); @@ -38,6 +47,7 @@ export function UserBulkMoveGroupModal({ React.useState(""); const [selectedGroupName, setSelectedGroupName] = React.useState(""); const [searchTerm, setSearchTerm] = React.useState(""); + const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false); const queryClient = useQueryClient(); @@ -48,10 +58,11 @@ export function UserBulkMoveGroupModal({ }); const tenants = tenantsData?.items ?? []; - const selectedTenantId = React.useMemo( - () => tenants.find((t) => t.slug === selectedTenantSlug)?.id ?? "", + const selectedTenant = React.useMemo( + () => tenants.find((t) => t.slug === selectedTenantSlug), [tenants, selectedTenantSlug], ); + const selectedTenantId = selectedTenant?.id ?? ""; const { data: groups, isLoading: isGroupsLoading } = useQuery({ queryKey: ["tenant-groups", selectedTenantId], @@ -59,6 +70,51 @@ export function UserBulkMoveGroupModal({ enabled: open && !!selectedTenantId, }); + const schemaWarnings = React.useMemo(() => { + if (!selectedTenant || selectedUsers.length === 0) return null; + + const targetSchema = + (selectedTenant.config?.userSchema as UserSchemaField[]) || []; + const targetSchemaKeys = new Set(targetSchema.map((f) => f.key)); + const requiredKeys = targetSchema + .filter((f) => f.required) + .map((f) => f.key); + + const missingRequiredFields = new Set(); + const incompatibleFields = new Set(); + + for (const user of selectedUsers) { + const userMeta = user.metadata || {}; + + // 1. Check for missing required fields + for (const key of requiredKeys) { + if ( + userMeta[key] === undefined || + userMeta[key] === null || + userMeta[key] === "" + ) { + missingRequiredFields.add(key); + } + } + + // 2. Check for fields that exist in user metadata but not in the target schema (data loss) + for (const key of Object.keys(userMeta)) { + if (!targetSchemaKeys.has(key)) { + incompatibleFields.add(key); + } + } + } + + if (missingRequiredFields.size === 0 && incompatibleFields.size === 0) { + return null; + } + + return { + missing: Array.from(missingRequiredFields), + incompatible: Array.from(incompatibleFields), + }; + }, [selectedTenant, selectedUsers]); + const mutation = useMutation({ mutationFn: bulkUpdateUsers, onSuccess: () => { @@ -96,7 +152,18 @@ export function UserBulkMoveGroupModal({ }, [groups, searchTerm]); return ( - + { + setOpen(val); + if (!val) { + setSelectedTenantSlug(""); + setSelectedGroupName(""); + setAcknowledgeWarning(false); + setSearchTerm(""); + } + }} + >
)} + + {schemaWarnings && ( +
+
+ + {t("ui.admin.users.bulk.schema_warning", "스키마 호환성 경고")} +
+
+ {schemaWarnings.missing.length > 0 && ( +

+ {t( + "msg.admin.users.bulk.schema_missing", + "대상 테넌트의 필수 필드가 누락되어 있습니다:", + )}{" "} + {schemaWarnings.missing.join(", ")} +

+ )} + {schemaWarnings.incompatible.length > 0 && ( +

+ {t( + "msg.admin.users.bulk.schema_incompatible", + "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:", + )}{" "} + {schemaWarnings.incompatible.join(", ")} +

+ )} +
+ +
+ )}
@@ -203,7 +314,11 @@ export function UserBulkMoveGroupModal({