1
0
forked from baron/baron-sso

feat: optimize tenant admin view and enhance user list with dynamic columns and metadata search

This commit is contained in:
2026-03-04 13:32:01 +09:00
parent 02acdf835f
commit d1c3bba3e0
8 changed files with 206 additions and 67 deletions

View File

@@ -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:
* <RoleGuard roles={['super_admin']}>
* <button>System Only Action</button>
* </RoleGuard>
*/
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}</>;
}

View File

@@ -22,18 +22,14 @@ import { t } from "../../lib/i18n";
import LanguageSelector from "../common/LanguageSelector"; import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher"; import RoleSwitcher from "./RoleSwitcher";
const navItems = [ const staticNavItems = [
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard }, { 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.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, { 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.audit_logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
]; ];
function AppLayout() { function AppLayout() {
const auth = useAuth(); const auth = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -49,6 +45,32 @@ function AppLayout() {
enabled: auth.isAuthenticated && !auth.isLoading, 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 = () => { const handleLogout = () => {
if ( if (
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?")) window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))

View File

@@ -23,17 +23,32 @@ import {
import { import {
type TenantSummary, type TenantSummary,
deleteTenant, deleteTenant,
fetchMe,
fetchTenants, fetchTenants,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { RoleGuard } from "../../../components/auth/RoleGuard";
function TenantListPage() { 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({ const query = useQuery({
queryKey: ["tenants", { limit: 1000, offset: 0 }], queryKey: ["tenants", { limit: 1000, offset: 0 }],
queryFn: () => fetchTenants(1000, 0), queryFn: () => fetchTenants(1000, 0),
enabled: profile?.role === "super_admin",
}); });
const navigate = useNavigate();
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (tenantId: string) => deleteTenant(tenantId), mutationFn: (tenantId: string) => deleteTenant(tenantId),
onSuccess: () => { onSuccess: () => {
@@ -41,6 +56,20 @@ function TenantListPage() {
}, },
}); });
if (profile && profile.role !== "super_admin" && profile.role !== "tenant_admin") {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-xl font-bold">{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}</h3>
<Button onClick={() => navigate("/")}>{t("ui.common.go_home", "홈으로 이동")}</Button>
</div>
);
}
// While redirecting
if (profile?.role === "tenant_admin") {
return null;
}
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error; ?.data?.error;
const fallbackError = const fallbackError =
@@ -95,12 +124,14 @@ function TenantListPage() {
<RefreshCw size={16} /> <RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")} {t("ui.common.refresh", "새로고침")}
</Button> </Button>
<RoleGuard roles={["super_admin"]}>
<Button asChild> <Button asChild>
<Link to="/tenants/new"> <Link to="/tenants/new">
<Plus size={16} /> <Plus size={16} />
{t("ui.admin.tenants.add", "테넌트 추가")} {t("ui.admin.tenants.add", "테넌트 추가")}
</Link> </Link>
</Button> </Button>
</RoleGuard>
</div> </div>
</header> </header>

View File

@@ -30,21 +30,68 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../components/ui/table"; } 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 { t } from "../../lib/i18n";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal"; import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
type UserSchemaField = {
key: string;
label: string;
type: string;
};
function UserListPage() { function UserListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [page, setPage] = React.useState(1); const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const [searchDraft, setSearchDraft] = React.useState(""); const [searchDraft, setSearchDraft] = React.useState("");
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
const limit = 50; const limit = 50;
const offset = (page - 1) * limit; 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({ const query = useQuery({
queryKey: ["users", { limit, offset, search }], queryKey: ["users", { limit, offset, search, companyCode: selectedCompany }],
queryFn: () => fetchUsers(limit, offset, search), queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
}); });
@@ -80,12 +127,6 @@ function UserListPage() {
const total = query.data?.total ?? 0; const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit); const totalPages = Math.ceil(total / limit);
React.useEffect(() => {
if (items.length > 0) {
console.log("User items:", items);
}
}, [items]);
const handleDelete = (userId: string, userName: string) => { const handleDelete = (userId: string, userName: string) => {
if ( if (
!window.confirm( !window.confirm(
@@ -118,7 +159,7 @@ function UserListPage() {
<p className="text-sm text-[var(--color-muted)]"> <p className="text-sm text-[var(--color-muted)]">
{t( {t(
"msg.admin.users.list.subtitle", "msg.admin.users.list.subtitle",
"시스템 사용자를 조회하고 관리합니다. (Local DB)", "시스템 사용자를 조회하고 관리합니다.",
)} )}
</p> </p>
</div> </div>
@@ -157,8 +198,8 @@ function UserListPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="mb-4 flex items-center gap-2"> <div className="mb-6 flex flex-wrap items-center gap-4">
<div className="relative flex-1 max-w-sm"> <div className="relative flex-1 min-w-[240px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder={t( placeholder={t(
@@ -171,6 +212,29 @@ function UserListPage() {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
/> />
</div> </div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
{t("ui.admin.users.list.filter.tenant", "테넌트 필터:")}
</span>
<select
className="flex h-9 w-[200px] 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:opacity-50"
value={selectedCompany}
onChange={(e) => {
setSelectedCompany(e.target.value);
setPage(1);
}}
disabled={profile?.role === "tenant_admin"}
>
<option value="">{t("ui.common.all", "전체")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name}
</option>
))}
</select>
</div>
<Button variant="secondary" onClick={handleSearch}> <Button variant="secondary" onClick={handleSearch}>
{t("ui.common.search", "검색")} {t("ui.common.search", "검색")}
</Button> </Button>
@@ -182,11 +246,11 @@ function UserListPage() {
</div> </div>
)} )}
<div className="rounded-md border"> <div className="rounded-md border overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead> <TableHead className="min-w-[200px]">
{t("ui.admin.users.list.table.name_email", "NAME / EMAIL")} {t("ui.admin.users.list.table.name_email", "NAME / EMAIL")}
</TableHead> </TableHead>
<TableHead> <TableHead>
@@ -201,12 +265,12 @@ function UserListPage() {
"TENANT / DEPT", "TENANT / DEPT",
)} )}
</TableHead> </TableHead>
<TableHead> {/* Dynamic Columns from Schema */}
{t( {userSchema.map((field) => (
"ui.admin.users.list.table.position_job", <TableHead key={field.key} className="uppercase">
"POSITION / JOB", {field.label}
)}
</TableHead> </TableHead>
))}
<TableHead> <TableHead>
{t("ui.admin.users.list.table.created", "CREATED")} {t("ui.admin.users.list.table.created", "CREATED")}
</TableHead> </TableHead>
@@ -218,14 +282,20 @@ function UserListPage() {
<TableBody> <TableBody>
{query.isLoading && ( {query.isLoading && (
<TableRow> <TableRow>
<TableCell colSpan={6} className="h-24 text-center"> <TableCell
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")} {t("msg.common.loading", "로딩 중...")}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{!query.isLoading && items.length === 0 && ( {!query.isLoading && items.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={6} className="h-24 text-center"> <TableCell
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
{t("msg.admin.users.list.empty", "검색 결과가 없습니다.")} {t("msg.admin.users.list.empty", "검색 결과가 없습니다.")}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -264,32 +334,17 @@ function UserListPage() {
<span className="font-medium text-blue-600"> <span className="font-medium text-blue-600">
{user.tenant?.name || user.companyCode || "-"} {user.tenant?.name || user.companyCode || "-"}
</span> </span>
{user.tenant && (
<span className="text-[10px] text-muted-foreground uppercase">
{t(
"ui.admin.users.list.tenant_slug",
"Slug: {{slug}}",
{
slug: user.tenant.slug,
},
)}
</span>
)}
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{user.department || "-"} {user.department || "-"}
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell> {/* Dynamic Metadata Cells */}
<div className="flex flex-col text-sm"> {userSchema.map((field) => (
<span className="font-medium"> <TableCell key={field.key} className="text-sm">
{user.position || "-"} {String(user.metadata?.[field.key] ?? "-")}
</span>
<span className="text-xs text-muted-foreground">
{user.jobTitle || "-"}
</span>
</div>
</TableCell> </TableCell>
))}
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleDateString()}
</TableCell> </TableCell>
@@ -299,11 +354,6 @@ function UserListPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate(`/users/${user.id}`)} onClick={() => navigate(`/users/${user.id}`)}
aria-label={t(
"ui.admin.users.list.edit_aria",
"사용자 수정: {{name}}",
{ name: user.name },
)}
> >
<Pencil size={16} /> <Pencil size={16} />
</Button> </Button>
@@ -313,11 +363,6 @@ function UserListPage() {
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)} onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
aria-label={t(
"ui.admin.users.list.delete_aria",
"사용자 삭제: {{name}}",
{ name: user.name },
)}
> >
<Trash2 size={16} /> <Trash2 size={16} />
</Button> </Button>

View File

@@ -1392,6 +1392,7 @@ api_keys = "API Keys"
audit_logs = "Audit Logs" audit_logs = "Audit Logs"
auth_guard = "Auth Guard" auth_guard = "Auth Guard"
logout = "Logout" logout = "Logout"
my_tenant = "My Tenant Settings"
overview = "Overview" overview = "Overview"
relying_parties = "Apps (RP)" relying_parties = "Apps (RP)"
user_groups = "Organization" user_groups = "Organization"

View File

@@ -1484,6 +1484,7 @@ api_keys = "API 키"
audit_logs = "감사 로그" audit_logs = "감사 로그"
auth_guard = "인증 가드" auth_guard = "인증 가드"
logout = "로그아웃" logout = "로그아웃"
my_tenant = "내 테넌트 설정"
overview = "개요" overview = "개요"
relying_parties = "애플리케이션(RP)" relying_parties = "애플리케이션(RP)"
user_groups = "조직 관리" user_groups = "조직 관리"

View File

@@ -689,6 +689,7 @@ api_keys = ""
audit_logs = "" audit_logs = ""
auth_guard = "" auth_guard = ""
logout = "" logout = ""
my_tenant = ""
overview = "" overview = ""
relying_parties = "" relying_parties = ""
user_groups = "" user_groups = ""

View File

@@ -176,7 +176,9 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
if search != "" { if search != "" {
searchTerm := "%" + 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 { if err := db.Count(&total).Error; err != nil {