diff --git a/adminfront/src/components/ui/table.tsx b/adminfront/src/components/ui/table.tsx index 46370544..0b0022a5 100644 --- a/adminfront/src/components/ui/table.tsx +++ b/adminfront/src/components/ui/table.tsx @@ -17,11 +17,7 @@ const Table = React.forwardRef< React.HTMLAttributes >(({ className, ...props }, ref) => (
- +
)); Table.displayName = "Table"; @@ -30,7 +26,11 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -38,11 +38,7 @@ const TableBody = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableBody.displayName = "TableBody"; @@ -62,11 +58,7 @@ const TableRow = React.forwardRef< HTMLTableRowElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableRow.displayName = "TableRow"; @@ -74,11 +66,7 @@ const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
+ )); TableHead.displayName = "TableHead"; @@ -86,11 +74,7 @@ const TableCell = React.forwardRef< HTMLTableCellElement, React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableCell.displayName = "TableCell"; diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index f564294b..94d32e33 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -1,6 +1,10 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; import type { AxiosError } from "axios"; import { + ArrowDown, + ArrowUp, + ArrowUpDown, Building2, ChevronDown, ChevronRight, @@ -79,7 +83,12 @@ import { importTenantsCSV, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { normalizeAdminRole } from "../../../lib/roles"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; +import { + filterNonHanmacFamilyTenants, + isHanmacFamilyUser, +} from "../../users/orgChartPicker"; import { isSeedTenant } from "../utils/protectedTenants"; import { type TenantImportPreviewRow, @@ -93,8 +102,14 @@ import { const tenantCSVTemplate = "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n"; +const tenantPageSize = 500; +const tenantVirtualizationThreshold = 250; +const tenantEstimatedRowHeight = 73; +const tenantLoadAheadPx = 360; +const tenantLoadAheadRows = 30; type TenantSortKey = keyof TenantSummary | "recursiveMemberCount"; +type TenantListRow = TenantSummary & { recursiveMemberCount: number }; const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { @@ -234,12 +249,11 @@ function TenantListPage() { const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list"); const [selectedIds, setSelectedIds] = React.useState([]); const [search, setSearch] = React.useState(""); - const [sortConfig, setSortConfig] = React.useState | null>( - { + const [sortConfig, setSortConfig] = + React.useState | null>({ key: "createdAt", direction: "desc", - }, - ); + }); const fileInputRef = React.useRef(null); const [importMessage, setImportMessage] = React.useState(""); const [previewRows, setPreviewRows] = React.useState< @@ -255,15 +269,17 @@ function TenantListPage() { Record >({}); const [previewOpen, setPreviewOpen] = React.useState(false); + const tenantTableScrollRef = React.useRef(null); const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, }); + const profileRole = normalizeAdminRole(profile?.role); // Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list React.useEffect(() => { - if (profile?.role === "tenant_admin") { + if (profile && profileRole === "tenant_admin") { const manageableCount = profile.manageableTenants?.length ?? 0; if ( (manageableCount === 1 || manageableCount === 0) && @@ -272,15 +288,24 @@ function TenantListPage() { navigate(`/tenants/${profile.tenantId}`, { replace: true }); } } - }, [profile, navigate]); + }, [profile, profileRole, navigate]); - const query = useQuery({ - queryKey: ["tenants", { limit: 1000, offset: 0 }], - queryFn: () => fetchTenants(1000, 0), + const query = useInfiniteQuery({ + queryKey: ["tenants", "lazy"], + queryFn: ({ pageParam }) => + fetchTenants( + tenantPageSize, + 0, + undefined, + pageParam ? pageParam : undefined, + ), + initialPageParam: "", + getNextPageParam: (lastPage) => + lastPage.nextCursor || lastPage.next_cursor || undefined, enabled: - profile?.role === "super_admin" || - (profile?.role === "tenant_admin" && - (profile.manageableTenants?.length ?? 0) > 1), + profileRole === "super_admin" || + (profileRole === "tenant_admin" && + (profile?.manageableTenants?.length ?? 0) > 1), }); const deleteMutation = useMutation({ @@ -345,8 +370,8 @@ function TenantListPage() { if ( profile && - profile.role !== "super_admin" && - profile.role !== "tenant_admin" + profileRole !== "super_admin" && + profileRole !== "tenant_admin" ) { return (
@@ -361,7 +386,7 @@ function TenantListPage() { } if ( - profile?.role === "tenant_admin" && + profileRole === "tenant_admin" && (profile.manageableTenants?.length ?? 0) <= 1 ) { return null; @@ -374,7 +399,28 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const allTenants = query.data?.items ?? []; + const tenantPages = query.data?.pages ?? []; + const rawTenants = tenantPages.flatMap((page) => page.items); + const tenantTotal = tenantPages[0]?.total ?? 0; + const hanmacFamilyTenantId = React.useMemo(() => { + const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; + if (typeof envTenantId === "string" && envTenantId.trim()) { + return envTenantId.trim(); + } + return rawTenants.find((tenant) => tenant.slug === "hanmac-family")?.id; + }, [rawTenants]); + const allTenants = React.useMemo(() => { + if (profileRole === "super_admin") { + return rawTenants; + } + if ( + profile && + isHanmacFamilyUser(profile, rawTenants, hanmacFamilyTenantId) + ) { + return rawTenants; + } + return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId); + }, [hanmacFamilyTenantId, profile, profileRole, rawTenants]); const importParentOptionGroups = buildTenantImportParentOptionGroups(allTenants); const tenantSortResolvers = React.useMemo< @@ -389,15 +435,8 @@ function TenantListPage() { [], ); const tenants = React.useMemo(() => { - // 1. Calculate recursive counts - // buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally. - // However, to easily map them back to a flat list, we can just run the builder, - // and then extract the recursive counts. const treeResult = buildTenantFullTree(allTenants); - // Flatten the tree or just extract from allTenants map? - // buildTenantFullTree does NOT mutate the objects passed in allTenants. It creates new ones. - // Let's create a map of id -> recursiveMemberCount const recursiveCounts = new Map(); const extractCounts = (nodes: TenantNode[]) => { for (const node of nodes) { @@ -424,6 +463,56 @@ function TenantListPage() { return sortItems(enriched, sortConfig, tenantSortResolvers); }, [allTenants, search, sortConfig, tenantSortResolvers]); + const shouldVirtualizeTenants = + tenants.length >= tenantVirtualizationThreshold; + const tenantRowVirtualizer = useVirtualizer({ + count: tenants.length, + getScrollElement: () => tenantTableScrollRef.current, + estimateSize: () => tenantEstimatedRowHeight, + overscan: 12, + enabled: shouldVirtualizeTenants, + }); + const virtualTenantRows = shouldVirtualizeTenants + ? tenantRowVirtualizer.getVirtualItems() + : []; + const lastVirtualTenantIndex = + virtualTenantRows[virtualTenantRows.length - 1]?.index ?? -1; + + const fetchNextTenantPage = React.useCallback(() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + void query.fetchNextPage(); + } + }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]); + + const handleTenantTableScroll = React.useCallback( + (event: React.UIEvent) => { + const scrollElement = event.currentTarget; + const distanceToEnd = + scrollElement.scrollHeight - + scrollElement.scrollTop - + scrollElement.clientHeight; + if (distanceToEnd <= tenantLoadAheadPx) { + fetchNextTenantPage(); + } + }, + [fetchNextTenantPage], + ); + + React.useEffect(() => { + if ( + !shouldVirtualizeTenants || + lastVirtualTenantIndex < tenants.length - tenantLoadAheadRows + ) { + return; + } + fetchNextTenantPage(); + }, [ + fetchNextTenantPage, + lastVirtualTenantIndex, + shouldVirtualizeTenants, + tenants.length, + ]); + const requestSort = (key: TenantSortKey) => { setSortConfig((current) => toggleSort(current, key)); }; @@ -599,6 +688,94 @@ function TenantListPage() { deleteMutation.mutate(tenantId); }; + const renderTenantRow = ( + tenant: TenantListRow, + options?: { + style?: React.CSSProperties; + virtualIndex?: number; + }, + ) => ( + + + {isSeedTenant(tenant) ? ( + + ) : ( + handleSelect(tenant, !!checked)} + /> + )} + + + {tenant.id} + + +
+ + {tenant.name} + + {isSeedTenant(tenant) && ( + + {t("ui.admin.tenants.seed_badge", "초기 설정")} + + )} +
+
+ + + {tenant.type} + + + {tenant.slug} + + + {t(`ui.common.status.${tenant.status}`, tenant.status)} + + + {tenant.recursiveMemberCount} + + {tenant.createdAt + ? new Date(tenant.createdAt).toLocaleString("ko-KR") + : "-"} + + + + +
+ ); + return (
@@ -727,7 +904,7 @@ function TenantListPage() { "msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", { - count: query.data?.total ?? 0, + count: tenantTotal, }, )} @@ -770,7 +947,12 @@ function TenantListPage() { className="flex-1 flex flex-col min-h-0 m-0" >
-
+
@@ -844,7 +1026,18 @@ function TenantListPage() { - + {query.isLoading && ( @@ -865,102 +1058,26 @@ function TenantListPage() { )} - {tenants.map((tenant) => ( - - - {isSeedTenant(tenant) ? ( - - ) : ( - - handleSelect(tenant, !!checked) - } - /> - )} - - - {tenant.id} - - -
- - {tenant.name} - - {isSeedTenant(tenant) && ( - - {t( - "ui.admin.tenants.seed_badge", - "초기 설정", - )} - - )} -
-
- - - {tenant.type} - - - - {tenant.slug} - - - - {t( - `ui.common.status.${tenant.status}`, - tenant.status, - )} - - - - {tenant.recursiveMemberCount} - - - {tenant.createdAt - ? new Date(tenant.createdAt).toLocaleString( - "ko-KR", - ) - : "-"} - - - - -
- ))} + {shouldVirtualizeTenants + ? virtualTenantRows.map((virtualRow) => { + const tenant = tenants[virtualRow.index]; + if (!tenant) { + return null; + } + return renderTenantRow(tenant, { + virtualIndex: virtualRow.index, + style: { + position: "absolute", + top: 0, + left: 0, + width: "100%", + display: "table", + tableLayout: "fixed", + transform: `translateY(${virtualRow.start}px)`, + }, + }); + }) + : tenants.map((tenant) => renderTenantRow(tenant))}
diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 3cd8eabc..ac093868 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -601,6 +601,13 @@ function UserListPage() { sortConfig={sortConfig} sortKey="status" /> + {t("msg.common.loading", "로딩 중...")} @@ -648,7 +655,7 @@ function UserListPage() { {!query.isLoading && items.length === 0 && ( {t( diff --git a/common/locales/en.toml b/common/locales/en.toml index 55082e0c..a13f95f8 100644 --- a/common/locales/en.toml +++ b/common/locales/en.toml @@ -14,6 +14,7 @@ unknown_error = "unknown error" actions = "Actions" add = "Add" all = "All" +apply = "Apply" admin_only = "Admin Only" apply = "Apply" approve = "Approve" diff --git a/common/locales/ko.toml b/common/locales/ko.toml index 7e1acee5..93555438 100644 --- a/common/locales/ko.toml +++ b/common/locales/ko.toml @@ -14,6 +14,7 @@ unknown_error = "알 수 없는 오류" actions = "액션" add = "추가" all = "전체" +apply = "적용" admin_only = "관리자 전용" apply = "적용" approve = "승인" diff --git a/common/locales/template.toml b/common/locales/template.toml index e1a8b0dc..b4a670e2 100644 --- a/common/locales/template.toml +++ b/common/locales/template.toml @@ -14,6 +14,7 @@ unknown_error = "" actions = "" add = "" all = "" +apply = "" admin_only = "" apply = "" approve = "" diff --git a/common/ui/table.ts b/common/ui/table.ts index 40a47e30..feafc531 100644 --- a/common/ui/table.ts +++ b/common/ui/table.ts @@ -1,4 +1,4 @@ -export const commonTableWrapperClass = "relative w-full overflow-auto"; +export const commonTableWrapperClass = "relative w-full"; export const commonTableClass = "w-full caption-bottom text-sm"; export const commonTableHeaderClass = "[&_tr]:border-b"; export const commonTableBodyClass = "[&_tr:last-child]:border-0"; diff --git a/devfront/src/components/ui/table.tsx b/devfront/src/components/ui/table.tsx index 46370544..0b0022a5 100644 --- a/devfront/src/components/ui/table.tsx +++ b/devfront/src/components/ui/table.tsx @@ -17,11 +17,7 @@ const Table = React.forwardRef< React.HTMLAttributes >(({ className, ...props }, ref) => (
- +
)); Table.displayName = "Table"; @@ -30,7 +26,11 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -38,11 +38,7 @@ const TableBody = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableBody.displayName = "TableBody"; @@ -62,11 +58,7 @@ const TableRow = React.forwardRef< HTMLTableRowElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableRow.displayName = "TableRow"; @@ -74,11 +66,7 @@ const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
+ )); TableHead.displayName = "TableHead"; @@ -86,11 +74,7 @@ const TableCell = React.forwardRef< HTMLTableCellElement, React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableCell.displayName = "TableCell"; diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index 941690dd..4c3666ac 100644 --- a/devfront/src/features/audit/AuditLogsPage.tsx +++ b/devfront/src/features/audit/AuditLogsPage.tsx @@ -288,154 +288,164 @@ function AuditLogsPage() {
- - - {t("ui.dev.audit.table.time", "Time")} - - - {t("ui.dev.audit.table.actor", "Actor")} - - - {t("ui.dev.audit.table.action", "Action")} - - - {t("ui.dev.audit.table.target", "Target")} - - - {t("ui.dev.audit.table.status", "Status")} - - - - - - {query.isLoading && logs.length === 0 ? ( - - - {t("msg.dev.audit.loading", "Loading audit logs...")} - - - ) : logs.length === 0 ? ( - - - {t("msg.dev.audit.empty", "No audit logs found.")} - - - ) : ( - logs.map((row, index) => { - const details = parseDetails(row.details); - const actionLabel = details.action || row.event_type; - const targetValue = details.target_id || "-"; - const rowKey = `${row.event_id}-${row.timestamp}-${index}`; - const expanded = Boolean(expandedRows[rowKey]); - return ( - - - - {formatDateTime(row.timestamp)} - - -
- {row.user_id || "-"} - {row.user_id ? ( + + + {t("ui.dev.audit.table.time", "Time")} + + + {t("ui.dev.audit.table.actor", "Actor")} + + + {t("ui.dev.audit.table.action", "Action")} + + + {t("ui.dev.audit.table.target", "Target")} + + + {t("ui.dev.audit.table.status", "Status")} + + + + + + {query.isLoading && logs.length === 0 ? ( + + + {t("msg.dev.audit.loading", "Loading audit logs...")} + + + ) : logs.length === 0 ? ( + + + {t("msg.dev.audit.empty", "No audit logs found.")} + + + ) : ( + logs.map((row, index) => { + const details = parseDetails(row.details); + const actionLabel = details.action || row.event_type; + const targetValue = details.target_id || "-"; + const rowKey = `${row.event_id}-${row.timestamp}-${index}`; + const expanded = Boolean(expandedRows[rowKey]); + return ( + + + + {formatDateTime(row.timestamp)} + + +
+ {row.user_id || "-"} + {row.user_id ? ( + + ) : null} +
+
+ + {actionLabel} + + +
+ + {targetValue} + + {targetValue !== "-" ? ( + + ) : null} +
+
+ + + {row.status} + + + - ) : null} -
-
- - {actionLabel} - - -
- {targetValue} - {targetValue !== "-" ? ( - - ) : null} -
-
- - - {row.status} - - - - - -
- {expanded ? ( - - -
-
-
- Request ID:{" "} - {formatValue(details.request_id)} +
+
+
+ Request ID:{" "} + {formatValue(details.request_id)} +
+
+ Method: {formatValue(details.method)} +
+
+ Path: {formatValue(details.path)} +
+
+ Tenant: {formatValue(details.tenant_id)} +
+
+
+
+ Before: {formatValue(details.before)} +
+
+ After: {formatValue(details.after)} +
+
+ Error: {formatValue(details.error)} +
+
-
- Method: {formatValue(details.method)} -
-
Path: {formatValue(details.path)}
-
- Tenant: {formatValue(details.tenant_id)} -
-
-
-
- Before: {formatValue(details.before)} -
-
After: {formatValue(details.after)}
-
Error: {formatValue(details.error)}
-
-
- - - ) : null} - - ); - }) - )} - + + + ) : null} + + ); + }) + )} +
diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index a078b1de..9c70cef8 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -449,7 +449,10 @@ function ClientConsentsPage() { {t("ui.dev.clients.consents.table.status", "Status")} - {t("ui.dev.clients.consents.table.scopes", "Granted Scopes")} + {t( + "ui.dev.clients.consents.table.scopes", + "Granted Scopes", + )} {t( @@ -553,7 +556,10 @@ function ClientConsentsPage() { {row.status === "revoked" && row.deletedAt ? ( - {t("ui.dev.clients.consents.revoked_at", "Revoked: ")} + {t( + "ui.dev.clients.consents.revoked_at", + "Revoked: ", + )} {new Date(row.deletedAt).toLocaleString()} ) : row.authenticatedAt ? ( diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 2d840605..d9b50101 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -1,12 +1,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { - BookOpenText, - Filter, - Plus, - Search, - X, -} from "lucide-react"; +import { BookOpenText, Filter, Plus, Search, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate } from "react-router-dom"; @@ -128,12 +122,11 @@ function ClientsPage() { const [statusFilter, setStatusFilter] = useState("all"); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); - const [sortConfig, setSortConfig] = useState | null>( - { + const [sortConfig, setSortConfig] = + useState | null>({ key: "createdAt", direction: "desc", - }, - ); + }); const clients = data?.items || []; const clientSortResolvers = useMemo< @@ -460,7 +453,10 @@ function ClientsPage() {

- {t("ui.dev.clients.tenant_scoped", "Tenant-scoped")} + {t( + "ui.dev.clients.tenant_scoped", + "Tenant-scoped", + )}

@@ -625,7 +624,9 @@ function ClientsPage() {
{client.status === "active" diff --git a/locales/en.toml b/locales/en.toml index 7c0724da..057314f9 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -91,6 +91,9 @@ notice_emphasis = "Store it in a secure location." notice_suffix = "Rotate the key immediately if you think it has been exposed." [msg.admin.api_keys.list] +edit_scopes_desc = "Keep the CLIENT_ID unchanged and modify scopes only." +rotate_confirm = "API key \"{{name}}\"'s secret will be rotated. The existing secret will no longer work." +rotate_secret_notice = "The new secret is shown only once. The CLIENT_ID has not changed." delete_confirm = "Are you sure you want to delete this API key?" empty = "No API keys have been issued yet." fetch_error = "Failed to load the API key list." @@ -188,6 +191,7 @@ description = "Jump to the most frequently used administrative workflows." audit_events_24h = "24h Audit Events" oidc_clients = "OIDC Clients" policy_gate = "Policy Gate Status" +total_users = "Total Users" total_tenants = "Total Tenants" [msg.admin.tenants] @@ -197,6 +201,7 @@ delete_confirm = "Delete Tenant \\\\\\\"{{name}}\\\\\\\"?" delete_success = "Tenant deleted." empty = "No tenants have been registered yet." fetch_error = "Failed to load the tenant list." +export_error = "Failed to export tenants." import_empty = "There are no tenant rows to import." import_error = "Failed to import tenants." import_result = "Created {{created}}, updated {{updated}}, failed {{failed}}" @@ -283,6 +288,8 @@ move_success = "{{count}} users moved successfully." parsed_count = "Parsed {{count}} rows." schema_incompatible = "Fields not in target schema may be lost:" schema_missing = "Missing required fields for target tenant:" +status_placeholder = "Select status" +permission_placeholder = "Select permission" update_success = "User info updated successfully." [msg.admin.users.create] @@ -970,6 +977,10 @@ title = "API Key Created" [ui.admin.api_keys.list] add = "Add" +edit_scopes = "Edit Scopes" +rotate_secret = "Rotate Secret" +rotate_secret_done = "Secret Rotated" +save_scopes = "Save Scopes" title = "API Key Management" [ui.admin.api_keys.list.breadcrumb] @@ -1116,6 +1127,7 @@ view_audit_logs = "View Audit Logs" audit_events_24h = "24h Events" oidc_clients = "OIDC Clients" policy_gate = "Policy Gate" +total_users = "Total Users" total_tenants = "Total Tenants" [ui.admin.profile] @@ -1378,6 +1390,7 @@ add = "Add" add_dialog_desc = "Select a tenant to add as a sub-tenant." add_dialog_title = "Add Sub-tenant" add_existing = "Add Existing Tenant" +export = "Subtree CSV" manage = "Manage" no_candidates = "No available tenants to add." search_placeholder = "Search..." @@ -1399,6 +1412,7 @@ slug = "SLUG" status = "STATUS" type = "TYPE" updated = "UPDATED" +created = "CREATED" [ui.admin.users] @@ -1416,6 +1430,8 @@ selected_count = "{{count}} users selected" start_upload = "Start Upload" tenant_resolution = "Tenant mapping" title = "Bulk Actions" +status_placeholder = "Select status" +permission_placeholder = "Select permission" [ui.admin.users.create] back = "Back" @@ -2332,6 +2348,7 @@ title = "User Info" [ui.dev.profile.org] company_code = "Company Code" tenant = "Tenant" +tenant_slug = "Tenant Slug" title = "Organization Info" [ui.dev.profile.role] @@ -2514,7 +2531,7 @@ department = "Department" email = "Email" name = "Name" tenant = "Tenant" -tenant_slug = "Tenant slug" +tenant_slug = "Tenant Slug" [ui.userfront.profile.password] change = "Change" diff --git a/locales/ko.toml b/locales/ko.toml index 377ce3dc..7941b9f7 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -588,6 +588,9 @@ notice_emphasis = "지금 한 번만" notice_suffix = "표시됩니다." [msg.admin.api_keys.list] +edit_scopes_desc = "CLIENT_ID는 유지하고 권한만 변경합니다." +rotate_confirm = "API 키 \"{{name}}\"의 Secret을 재발급할까요? 기존 Secret은 더 이상 사용할 수 없습니다." +rotate_secret_notice = "새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다." delete_confirm = "API 키 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?" empty = "등록된 API 키가 없습니다." fetch_error = "API 키 목록 조회에 실패했습니다." @@ -685,6 +688,7 @@ description = "주요 운영 화면으로 바로 이동합니다." audit_events_24h = "최근 24시간 감사 로그" oidc_clients = "등록된 OIDC 클라이언트" policy_gate = "정책 가이트 상태" +total_users = "전체 사용자 수" total_tenants = "전체 테넌트 수" [msg.admin.tenants] @@ -694,6 +698,7 @@ delete_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?" delete_success = "테넌트가 삭제되었습니다." empty = "아직 등록된 테넌트가 없습니다." fetch_error = "테넌트 목록 조회에 실패했습니다." +export_error = "테넌트 내보내기에 실패했습니다." import_empty = "임포트 파일에 테넌트 행이 없습니다." import_error = "테넌트 임포트에 실패했습니다: {{error}}" import_result = "{{count}}개의 테넌트 행을 처리했습니다." @@ -775,6 +780,8 @@ move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니 parsed_count = "{{count}}행의 데이터가 파싱되었습니다." schema_incompatible = "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:" schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:" +status_placeholder = "상태 선택" +permission_placeholder = "권한 선택" update_success = "사용자 정보가 일괄 업데이트되었습니다." [msg.admin.users.create] @@ -1460,6 +1467,10 @@ title = "API 키 생성 완료" [ui.admin.api_keys.list] add = "API 키 생성" +edit_scopes = "권한 수정" +rotate_secret = "Secret 재발급" +rotate_secret_done = "Secret 재발급 완료" +save_scopes = "권한 저장" title = "API 키 관리 (M2M)" [ui.admin.api_keys.list.breadcrumb] @@ -1606,6 +1617,7 @@ view_audit_logs = "감사 로그 보기" audit_events_24h = "24시간 이벤트" oidc_clients = "OIDC 클라이언트" policy_gate = "정책 게이트" +total_users = "전체 사용자 수" total_tenants = "전체 테넌트 수" [ui.admin.profile] @@ -1841,6 +1853,7 @@ add = "하위 테넌트 추가" add_dialog_desc = "하위 테넌트로 추가할 테넌트를 선택하세요." add_dialog_title = "하위 테넌트 추가" add_existing = "기존 테넌트 추가" +export = "하위 조직 CSV" manage = "관리" no_candidates = "추가 가능한 테넌트가 없습니다." search_placeholder = "검색..." @@ -1862,6 +1875,8 @@ slug = "SLUG" status = "STATUS" type = "유형" updated = "UPDATED" +created = "CREATED" +created = "CREATED" [ui.admin.users] @@ -1879,6 +1894,8 @@ selected_count = "{{count}}명 선택됨" start_upload = "업로드 시작" tenant_resolution = "테넌트 매핑" title = "일괄 작업" +status_placeholder = "상태 선택" +permission_placeholder = "권한 선택" [ui.admin.users.create] back = "목록으로 돌아가기" @@ -2757,6 +2774,7 @@ title = "사용자 정보" [ui.dev.profile.org] company_code = "회사 코드" tenant = "테넌트" +tenant_slug = "테넌트 Slug" title = "조직 정보" [ui.dev.profile.role] @@ -2938,7 +2956,7 @@ department = "소속" email = "이메일" name = "이름" tenant = "소속 테넌트" -tenant_slug = "테넌트 slug" +tenant_slug = "테넌트 Slug" [ui.userfront.profile.password] change = "비밀번호 변경" diff --git a/locales/template.toml b/locales/template.toml index 90a2039f..4a5024dd 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -451,6 +451,9 @@ notice_emphasis = "" notice_suffix = "" [msg.admin.api_keys.list] +edit_scopes_desc = "" +rotate_confirm = "" +rotate_secret_notice = "" delete_confirm = "" empty = "" fetch_error = "" @@ -548,6 +551,7 @@ description = "" audit_events_24h = "" oidc_clients = "" policy_gate = "" +total_users = "" total_tenants = "" [msg.admin.tenants] @@ -557,6 +561,7 @@ delete_confirm = "" delete_success = "" empty = "" fetch_error = "" +export_error = "" import_empty = "" import_error = "" import_result = "" @@ -638,6 +643,8 @@ move_success = "" parsed_count = "" schema_incompatible = "" schema_missing = "" +status_placeholder = "" +permission_placeholder = "" update_success = "" [msg.admin.users.create] @@ -1323,6 +1330,10 @@ title = "" [ui.admin.api_keys.list] add = "" +edit_scopes = "" +rotate_secret = "" +rotate_secret_done = "" +save_scopes = "" title = "" [ui.admin.api_keys.list.breadcrumb] @@ -1469,6 +1480,7 @@ view_audit_logs = "" audit_events_24h = "" oidc_clients = "" policy_gate = "" +total_users = "" total_tenants = "" [ui.admin.profile] @@ -1487,6 +1499,9 @@ seed_badge = "" title = "" view_org_chart = "" +[ui.admin.tenants.sub] +export = "" + [ui.admin.tenants.view] hierarchy = "" list = "" @@ -1740,6 +1755,7 @@ slug = "" status = "" type = "" updated = "" +created = "" [ui.admin.users] @@ -1757,6 +1773,8 @@ selected_count = "" start_upload = "" tenant_resolution = "" title = "" +status_placeholder = "" +permission_placeholder = "" [ui.admin.users.create] back = "" @@ -2636,6 +2654,7 @@ title = "" [ui.dev.profile.org] company_code = "" tenant = "" +tenant_slug = "" title = "" [ui.dev.profile.role] diff --git a/orgfront/src/components/ui/table.tsx b/orgfront/src/components/ui/table.tsx index 46370544..0b0022a5 100644 --- a/orgfront/src/components/ui/table.tsx +++ b/orgfront/src/components/ui/table.tsx @@ -17,11 +17,7 @@ const Table = React.forwardRef< React.HTMLAttributes >(({ className, ...props }, ref) => (
- +
)); Table.displayName = "Table"; @@ -30,7 +26,11 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -38,11 +38,7 @@ const TableBody = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableBody.displayName = "TableBody"; @@ -62,11 +58,7 @@ const TableRow = React.forwardRef< HTMLTableRowElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableRow.displayName = "TableRow"; @@ -74,11 +66,7 @@ const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
+ )); TableHead.displayName = "TableHead"; @@ -86,11 +74,7 @@ const TableCell = React.forwardRef< HTMLTableCellElement, React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableCell.displayName = "TableCell"; diff --git a/scripts/run_adminfront_ci_tests.sh b/scripts/run_adminfront_ci_tests.sh index 6fd90287..3df29191 100755 --- a/scripts/run_adminfront_ci_tests.sh +++ b/scripts/run_adminfront_ci_tests.sh @@ -17,7 +17,6 @@ mkdir -p reports rm -rf adminfront/node_modules tmp_dir="$(mktemp -d /tmp/baron-sso-adminfront-tests.XXXXXX)" -playwright_browsers_path="$tmp_dir/ms-playwright" mkdir -p "$tmp_dir/scripts" cp "$repo_root/scripts/playwrightHostDeps.cjs" "$tmp_dir/scripts/" @@ -162,7 +161,7 @@ fi set +e ( cd "$tmp_dir/adminfront" - PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" "${playwright_install_cmd[@]}" + "${playwright_install_cmd[@]}" ) 2>&1 | tee reports/adminfront-provision.log provision_exit_code=${PIPESTATUS[0]} set -e @@ -197,7 +196,7 @@ fi echo "==> adminfront using PORT=$port" ( cd "$tmp_dir/adminfront" - PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" \ + PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \ node ./node_modules/playwright/cli.js test "${playwright_project_args[@]}" ) 2>&1 | tee reports/adminfront-test.log test_exit_code=${PIPESTATUS[0]} diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock index 5a7fb7b9..238c821f 100644 --- a/userfront/pubspec.lock +++ b/userfront/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" cli_config: dependency: transitive description: @@ -276,6 +276,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" leak_tracker: dependency: transitive description: @@ -328,18 +336,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -661,26 +669,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.12" toml: dependency: "direct main" description: