From d1c3bba3e0265a88d6e33424a443d22664cbb6ac Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 4 Mar 2026 13:32:01 +0900 Subject: [PATCH] feat: optimize tenant admin view and enhance user list with dynamic columns and metadata search --- adminfront/src/components/auth/RoleGuard.tsx | 36 +++++ .../src/components/layout/AppLayout.tsx | 34 +++- .../tenants/routes/TenantListPage.tsx | 45 +++++- .../src/features/users/UserListPage.tsx | 151 ++++++++++++------ adminfront/src/locales/en.toml | 1 + adminfront/src/locales/ko.toml | 1 + adminfront/src/locales/template.toml | 1 + .../internal/repository/user_repository.go | 4 +- 8 files changed, 206 insertions(+), 67 deletions(-) create mode 100644 adminfront/src/components/auth/RoleGuard.tsx diff --git a/adminfront/src/components/auth/RoleGuard.tsx b/adminfront/src/components/auth/RoleGuard.tsx new file mode 100644 index 00000000..2e95d06e --- /dev/null +++ b/adminfront/src/components/auth/RoleGuard.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchMe } from "../../lib/adminApi"; + +interface RoleGuardProps { + children: React.ReactNode; + roles: string[]; + fallback?: React.ReactNode; +} + +/** + * RoleGuard conditionally renders children based on the current user's role. + * + * Usage: + * + * + * + */ +export function RoleGuard({ children, roles, fallback = null }: RoleGuardProps) { + const { data: profile, isLoading } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + if (isLoading) return null; + + const userRole = profile?.role || "user"; + const hasAccess = roles.includes(userRole); + + if (!hasAccess) { + return <>{fallback}; + } + + return <>{children}; +} diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index c944f59d..a1dd79bf 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -22,18 +22,14 @@ import { t } from "../../lib/i18n"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; -const navItems = [ +const staticNavItems = [ { label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard }, - { - label: "ui.admin.nav.tenants", - to: "/tenants", - icon: Building2, - }, { label: "ui.admin.nav.users", to: "/users", icon: Users }, { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, ]; + function AppLayout() { const auth = useAuth(); const navigate = useNavigate(); @@ -49,6 +45,32 @@ function AppLayout() { enabled: auth.isAuthenticated && !auth.isLoading, }); + const navItems = React.useMemo(() => { + const items = [...staticNavItems]; + const isSuperAdmin = profile?.role === "super_admin"; + const isTenantAdmin = profile?.role === "tenant_admin"; + + if (isSuperAdmin) { + // Insert Tenants at index 1 for Super Admin + items.splice(1, 0, { + label: "ui.admin.nav.tenants", + to: "/tenants", + icon: Building2, + }); + } else if (isTenantAdmin && profile?.tenantId) { + // Insert My Tenant link for Tenant Admin + items.splice(1, 0, { + label: "ui.admin.nav.my_tenant", + to: `/tenants/${profile.tenantId}`, + icon: Building2, + }); + } + + // Tenant Admin should not see global API keys or global audit logs (unless allowed) + // For now, let's keep them but they might return 403 + return items; + }, [profile]); + const handleLogout = () => { if ( window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?")) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index c1671049..7f5870d3 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -23,17 +23,32 @@ import { import { type TenantSummary, deleteTenant, + fetchMe, fetchTenants, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { RoleGuard } from "../../../components/auth/RoleGuard"; function TenantListPage() { + const navigate = useNavigate(); + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); + + // Redirect tenant_admin to their own tenant + React.useEffect(() => { + if (profile?.role === "tenant_admin" && profile?.tenantId) { + navigate(`/tenants/${profile.tenantId}`, { replace: true }); + } + }, [profile, navigate]); + const query = useQuery({ queryKey: ["tenants", { limit: 1000, offset: 0 }], queryFn: () => fetchTenants(1000, 0), + enabled: profile?.role === "super_admin", }); - const navigate = useNavigate(); const deleteMutation = useMutation({ mutationFn: (tenantId: string) => deleteTenant(tenantId), onSuccess: () => { @@ -41,6 +56,20 @@ function TenantListPage() { }, }); + if (profile && profile.role !== "super_admin" && profile.role !== "tenant_admin") { + return ( +
+

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

+ +
+ ); + } + + // While redirecting + if (profile?.role === "tenant_admin") { + return null; + } + const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response ?.data?.error; const fallbackError = @@ -95,12 +124,14 @@ function TenantListPage() { {t("ui.common.refresh", "새로고침")} - + + + diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index e1a31886..382710a5 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -30,21 +30,68 @@ import { TableHeader, TableRow, } from "../../components/ui/table"; -import { deleteUser, fetchUsers } from "../../lib/adminApi"; +import { + deleteUser, + fetchMe, + fetchTenant, + fetchTenants, + fetchUsers, +} from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { UserBulkUploadModal } from "./components/UserBulkUploadModal"; +type UserSchemaField = { + key: string; + label: string; + type: string; +}; + function UserListPage() { const navigate = useNavigate(); const [page, setPage] = React.useState(1); const [search, setSearch] = React.useState(""); const [searchDraft, setSearchDraft] = React.useState(""); + const [selectedCompany, setSelectedCompany] = React.useState(""); const limit = 50; const offset = (page - 1) * limit; + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); + + const { data: tenantsData } = useQuery({ + queryKey: ["tenants", { limit: 100 }], + queryFn: () => fetchTenants(100, 0), + }); + const tenants = tenantsData?.items ?? []; + + // Lock company for tenant_admin + React.useEffect(() => { + if (profile?.role === "tenant_admin" && profile.companyCode) { + setSelectedCompany(profile.companyCode); + } + }, [profile]); + + const selectedTenantId = React.useMemo(() => { + return tenants.find((t) => t.slug === selectedCompany)?.id ?? ""; + }, [tenants, selectedCompany]); + + const { data: tenantDetail } = useQuery({ + queryKey: ["tenant", selectedTenantId], + queryFn: () => fetchTenant(selectedTenantId), + enabled: selectedTenantId.length > 0, + }); + + const userSchema: UserSchemaField[] = Array.isArray( + tenantDetail?.config?.userSchema, + ) + ? (tenantDetail?.config?.userSchema as UserSchemaField[]) + : []; + const query = useQuery({ - queryKey: ["users", { limit, offset, search }], - queryFn: () => fetchUsers(limit, offset, search), + queryKey: ["users", { limit, offset, search, companyCode: selectedCompany }], + queryFn: () => fetchUsers(limit, offset, search, selectedCompany), placeholderData: (previousData) => previousData, }); @@ -80,12 +127,6 @@ function UserListPage() { const total = query.data?.total ?? 0; const totalPages = Math.ceil(total / limit); - React.useEffect(() => { - if (items.length > 0) { - console.log("User items:", items); - } - }, [items]); - const handleDelete = (userId: string, userName: string) => { if ( !window.confirm( @@ -118,7 +159,7 @@ function UserListPage() {

{t( "msg.admin.users.list.subtitle", - "시스템 사용자를 조회하고 관리합니다. (Local DB)", + "시스템 사용자를 조회하고 관리합니다.", )}

@@ -157,8 +198,8 @@ function UserListPage() { -
-
+
+
+ +
+ + {t("ui.admin.users.list.filter.tenant", "테넌트 필터:")} + + +
+ @@ -182,11 +246,11 @@ function UserListPage() {
)} -
+
- + {t("ui.admin.users.list.table.name_email", "NAME / EMAIL")} @@ -201,12 +265,12 @@ function UserListPage() { "TENANT / DEPT", )} - - {t( - "ui.admin.users.list.table.position_job", - "POSITION / JOB", - )} - + {/* Dynamic Columns from Schema */} + {userSchema.map((field) => ( + + {field.label} + + ))} {t("ui.admin.users.list.table.created", "CREATED")} @@ -218,14 +282,20 @@ function UserListPage() { {query.isLoading && ( - + {t("msg.common.loading", "로딩 중...")} )} {!query.isLoading && items.length === 0 && ( - + {t("msg.admin.users.list.empty", "검색 결과가 없습니다.")} @@ -264,32 +334,17 @@ function UserListPage() { {user.tenant?.name || user.companyCode || "-"} - {user.tenant && ( - - {t( - "ui.admin.users.list.tenant_slug", - "Slug: {{slug}}", - { - slug: user.tenant.slug, - }, - )} - - )} {user.department || "-"} - -
- - {user.position || "-"} - - - {user.jobTitle || "-"} - -
-
+ {/* Dynamic Metadata Cells */} + {userSchema.map((field) => ( + + {String(user.metadata?.[field.key] ?? "-")} + + ))} {new Date(user.createdAt).toLocaleDateString()} @@ -299,11 +354,6 @@ function UserListPage() { variant="ghost" size="icon" onClick={() => navigate(`/users/${user.id}`)} - aria-label={t( - "ui.admin.users.list.edit_aria", - "사용자 수정: {{name}}", - { name: user.name }, - )} > @@ -313,11 +363,6 @@ function UserListPage() { className="text-destructive hover:text-destructive" onClick={() => handleDelete(user.id, user.name)} disabled={deleteMutation.isPending} - aria-label={t( - "ui.admin.users.list.delete_aria", - "사용자 삭제: {{name}}", - { name: user.name }, - )} > diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index c5180e63..c75fe3b2 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -1392,6 +1392,7 @@ api_keys = "API Keys" audit_logs = "Audit Logs" auth_guard = "Auth Guard" logout = "Logout" +my_tenant = "My Tenant Settings" overview = "Overview" relying_parties = "Apps (RP)" user_groups = "Organization" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index ceb252ac..e44c8720 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -1484,6 +1484,7 @@ api_keys = "API 키" audit_logs = "감사 로그" auth_guard = "인증 가드" logout = "로그아웃" +my_tenant = "내 테넌트 설정" overview = "개요" relying_parties = "애플리케이션(RP)" user_groups = "조직 관리" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 88211263..94a8f869 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -689,6 +689,7 @@ api_keys = "" audit_logs = "" auth_guard = "" logout = "" +my_tenant = "" overview = "" relying_parties = "" user_groups = "" diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index eb17527c..998596d7 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -176,7 +176,9 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str if search != "" { searchTerm := "%" + search + "%" - db = db.Where("(email LIKE ? OR name LIKE ? OR company_code LIKE ?)", searchTerm, searchTerm, searchTerm) + // Search in basic fields and metadata (PostgreSQL JSONB) + db = db.Where("(email LIKE ? OR name LIKE ? OR company_code LIKE ? OR metadata::text LIKE ?)", + searchTerm, searchTerm, searchTerm, searchTerm) } if err := db.Count(&total).Error; err != nil {