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 {