1
0
forked from baron/baron-sso

92e607aee8 기준 code check 오류 수정

This commit is contained in:
2026-05-14 10:22:59 +09:00
parent e803a0b150
commit da10b4be15
17 changed files with 536 additions and 379 deletions

View File

@@ -17,11 +17,7 @@ const Table = React.forwardRef<
React.HTMLAttributes<HTMLTableElement> React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className={commonTableWrapperClass}> <div className={commonTableWrapperClass}>
<table <table ref={ref} className={cn(commonTableClass, className)} {...props} />
ref={ref}
className={cn(commonTableClass, className)}
{...props}
/>
</div> </div>
)); ));
Table.displayName = "Table"; Table.displayName = "Table";
@@ -30,7 +26,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn(commonTableHeaderClass, className)} {...props} /> <thead
ref={ref}
className={cn(commonTableHeaderClass, className)}
{...props}
/>
)); ));
TableHeader.displayName = "TableHeader"; TableHeader.displayName = "TableHeader";
@@ -38,11 +38,7 @@ const TableBody = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tbody <tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
ref={ref}
className={cn(commonTableBodyClass, className)}
{...props}
/>
)); ));
TableBody.displayName = "TableBody"; TableBody.displayName = "TableBody";
@@ -62,11 +58,7 @@ const TableRow = React.forwardRef<
HTMLTableRowElement, HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement> React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tr <tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
ref={ref}
className={cn(commonTableRowClass, className)}
{...props}
/>
)); ));
TableRow.displayName = "TableRow"; TableRow.displayName = "TableRow";
@@ -74,11 +66,7 @@ const TableHead = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement> React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<th <th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
ref={ref}
className={cn(commonTableHeadClass, className)}
{...props}
/>
)); ));
TableHead.displayName = "TableHead"; TableHead.displayName = "TableHead";
@@ -86,11 +74,7 @@ const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement> React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
ref={ref}
className={cn(commonTableCellClass, className)}
{...props}
/>
)); ));
TableCell.displayName = "TableCell"; TableCell.displayName = "TableCell";

View File

@@ -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 type { AxiosError } from "axios";
import { import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Building2, Building2,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -79,7 +83,12 @@ import {
importTenantsCSV, importTenantsCSV,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import {
filterNonHanmacFamilyTenants,
isHanmacFamilyUser,
} from "../../users/orgChartPicker";
import { isSeedTenant } from "../utils/protectedTenants"; import { isSeedTenant } from "../utils/protectedTenants";
import { import {
type TenantImportPreviewRow, type TenantImportPreviewRow,
@@ -93,8 +102,14 @@ import {
const tenantCSVTemplate = const tenantCSVTemplate =
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n"; "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 TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
type TenantListRow = TenantSummary & { recursiveMemberCount: number };
const getTenantIcon = (type?: string) => { const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) { switch (type?.toUpperCase()) {
@@ -234,12 +249,11 @@ function TenantListPage() {
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list"); const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
const [selectedIds, setSelectedIds] = React.useState<string[]>([]); const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const [sortConfig, setSortConfig] = React.useState<SortConfig<TenantSortKey> | null>( const [sortConfig, setSortConfig] =
{ React.useState<SortConfig<TenantSortKey> | null>({
key: "createdAt", key: "createdAt",
direction: "desc", direction: "desc",
}, });
);
const fileInputRef = React.useRef<HTMLInputElement | null>(null); const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [importMessage, setImportMessage] = React.useState(""); const [importMessage, setImportMessage] = React.useState("");
const [previewRows, setPreviewRows] = React.useState< const [previewRows, setPreviewRows] = React.useState<
@@ -255,15 +269,17 @@ function TenantListPage() {
Record<number, string> Record<number, string>
>({}); >({});
const [previewOpen, setPreviewOpen] = React.useState(false); const [previewOpen, setPreviewOpen] = React.useState(false);
const tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
const { data: profile } = useQuery({ const { data: profile } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: fetchMe, queryFn: fetchMe,
}); });
const profileRole = normalizeAdminRole(profile?.role);
// Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list // Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list
React.useEffect(() => { React.useEffect(() => {
if (profile?.role === "tenant_admin") { if (profile && profileRole === "tenant_admin") {
const manageableCount = profile.manageableTenants?.length ?? 0; const manageableCount = profile.manageableTenants?.length ?? 0;
if ( if (
(manageableCount === 1 || manageableCount === 0) && (manageableCount === 1 || manageableCount === 0) &&
@@ -272,15 +288,24 @@ function TenantListPage() {
navigate(`/tenants/${profile.tenantId}`, { replace: true }); navigate(`/tenants/${profile.tenantId}`, { replace: true });
} }
} }
}, [profile, navigate]); }, [profile, profileRole, navigate]);
const query = useQuery({ const query = useInfiniteQuery({
queryKey: ["tenants", { limit: 1000, offset: 0 }], queryKey: ["tenants", "lazy"],
queryFn: () => fetchTenants(1000, 0), queryFn: ({ pageParam }) =>
fetchTenants(
tenantPageSize,
0,
undefined,
pageParam ? pageParam : undefined,
),
initialPageParam: "",
getNextPageParam: (lastPage) =>
lastPage.nextCursor || lastPage.next_cursor || undefined,
enabled: enabled:
profile?.role === "super_admin" || profileRole === "super_admin" ||
(profile?.role === "tenant_admin" && (profileRole === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) > 1), (profile?.manageableTenants?.length ?? 0) > 1),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -345,8 +370,8 @@ function TenantListPage() {
if ( if (
profile && profile &&
profile.role !== "super_admin" && profileRole !== "super_admin" &&
profile.role !== "tenant_admin" profileRole !== "tenant_admin"
) { ) {
return ( return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4"> <div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
@@ -361,7 +386,7 @@ function TenantListPage() {
} }
if ( if (
profile?.role === "tenant_admin" && profileRole === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) <= 1 (profile.manageableTenants?.length ?? 0) <= 1
) { ) {
return null; return null;
@@ -374,7 +399,28 @@ function TenantListPage() {
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null; : 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 = const importParentOptionGroups =
buildTenantImportParentOptionGroups(allTenants); buildTenantImportParentOptionGroups(allTenants);
const tenantSortResolvers = React.useMemo< const tenantSortResolvers = React.useMemo<
@@ -389,15 +435,8 @@ function TenantListPage() {
[], [],
); );
const tenants = React.useMemo(() => { 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); 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<string, number>(); const recursiveCounts = new Map<string, number>();
const extractCounts = (nodes: TenantNode[]) => { const extractCounts = (nodes: TenantNode[]) => {
for (const node of nodes) { for (const node of nodes) {
@@ -424,6 +463,56 @@ function TenantListPage() {
return sortItems(enriched, sortConfig, tenantSortResolvers); return sortItems(enriched, sortConfig, tenantSortResolvers);
}, [allTenants, search, 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<HTMLDivElement>) => {
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) => { const requestSort = (key: TenantSortKey) => {
setSortConfig((current) => toggleSort(current, key)); setSortConfig((current) => toggleSort(current, key));
}; };
@@ -599,6 +688,94 @@ function TenantListPage() {
deleteMutation.mutate(tenantId); deleteMutation.mutate(tenantId);
}; };
const renderTenantRow = (
tenant: TenantListRow,
options?: {
style?: React.CSSProperties;
virtualIndex?: number;
},
) => (
<TableRow
key={tenant.id}
data-index={options?.virtualIndex}
ref={
options?.virtualIndex === undefined
? undefined
: tenantRowVirtualizer.measureElement
}
style={options?.style}
>
<TableCell className="text-center">
{isSeedTenant(tenant) ? (
<span className="inline-block h-4 w-4" />
) : (
<Checkbox
checked={selectedIds.includes(tenant.id)}
onCheckedChange={(checked) => handleSelect(tenant, !!checked)}
/>
)}
</TableCell>
<TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
data-testid={`tenant-internal-id-${tenant.id}`}
>
{tenant.id}
</TableCell>
<TableCell className="font-semibold">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/tenants/${tenant.id}`}
className="hover:underline text-primary cursor-pointer"
>
{tenant.name}
</Link>
{isSeedTenant(tenant) && (
<Badge variant="secondary" className="text-[10px]">
{t("ui.admin.tenants.seed_badge", "초기 설정")}
</Badge>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge variant="outline" className="text-[10px] font-mono">
{tenant.type}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{tenant.slug}</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell className="font-medium">{tenant.recursiveMemberCount}</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{tenant.createdAt
? new Date(tenant.createdAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
disabled={isSeedTenant(tenant) || deleteMutation.isPending}
onClick={() => handleDelete(tenant.id, tenant.name)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} className="mr-2" />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>
);
return ( return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]"> <div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4"> <header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
@@ -727,7 +904,7 @@ function TenantListPage() {
"msg.admin.tenants.registry.count", "msg.admin.tenants.registry.count",
"총 {{count}}개 테넌트", "총 {{count}}개 테넌트",
{ {
count: query.data?.total ?? 0, count: tenantTotal,
}, },
)} )}
</CardDescription> </CardDescription>
@@ -770,7 +947,12 @@ function TenantListPage() {
className="flex-1 flex flex-col min-h-0 m-0" className="flex-1 flex flex-col min-h-0 m-0"
> >
<div className={commonTableShellClass}> <div className={commonTableShellClass}>
<div className={commonTableViewportClass}> <div
className={commonTableViewportClass}
ref={tenantTableScrollRef}
onScroll={handleTenantTableScroll}
data-testid="tenant-table-scroll"
>
<Table className="min-w-[1180px]"> <Table className="min-w-[1180px]">
<TableHeader className={sortableTableHeaderClassName}> <TableHeader className={sortableTableHeaderClassName}>
<TableRow> <TableRow>
@@ -844,7 +1026,18 @@ function TenantListPage() {
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody
className={
shouldVirtualizeTenants ? "relative block" : undefined
}
style={
shouldVirtualizeTenants
? {
height: `${tenantRowVirtualizer.getTotalSize()}px`,
}
: undefined
}
>
{query.isLoading && ( {query.isLoading && (
<TableRow> <TableRow>
<TableCell colSpan={9}> <TableCell colSpan={9}>
@@ -865,102 +1058,26 @@ function TenantListPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{tenants.map((tenant) => ( {shouldVirtualizeTenants
<TableRow key={tenant.id}> ? virtualTenantRows.map((virtualRow) => {
<TableCell className="text-center"> const tenant = tenants[virtualRow.index];
{isSeedTenant(tenant) ? ( if (!tenant) {
<span className="inline-block h-4 w-4" /> return null;
) : ( }
<Checkbox return renderTenantRow(tenant, {
checked={selectedIds.includes(tenant.id)} virtualIndex: virtualRow.index,
onCheckedChange={(checked) => style: {
handleSelect(tenant, !!checked) position: "absolute",
} top: 0,
/> left: 0,
)} width: "100%",
</TableCell> display: "table",
<TableCell tableLayout: "fixed",
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground" transform: `translateY(${virtualRow.start}px)`,
data-testid={`tenant-internal-id-${tenant.id}`} },
> });
{tenant.id} })
</TableCell> : tenants.map((tenant) => renderTenantRow(tenant))}
<TableCell className="font-semibold">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/tenants/${tenant.id}`}
className="hover:underline text-primary cursor-pointer"
>
{tenant.name}
</Link>
{isSeedTenant(tenant) && (
<Badge
variant="secondary"
className="text-[10px]"
>
{t(
"ui.admin.tenants.seed_badge",
"초기 설정",
)}
</Badge>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant="outline"
className="text-[10px] font-mono"
>
{tenant.type}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{tenant.slug}
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
>
{t(
`ui.common.status.${tenant.status}`,
tenant.status,
)}
</Badge>
</TableCell>
<TableCell className="font-medium">
{tenant.recursiveMemberCount}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{tenant.createdAt
? new Date(tenant.createdAt).toLocaleString(
"ko-KR",
)
: "-"}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
disabled={
isSeedTenant(tenant) || deleteMutation.isPending
}
onClick={() =>
handleDelete(tenant.id, tenant.name)
}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} className="mr-2" />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@@ -601,6 +601,13 @@ function UserListPage() {
sortConfig={sortConfig} sortConfig={sortConfig}
sortKey="status" sortKey="status"
/> />
<SortableTableHead
className="whitespace-nowrap"
label={t("ui.admin.users.list.table.role", "ROLE")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="role"
/>
<SortableTableHead <SortableTableHead
className="whitespace-nowrap" className="whitespace-nowrap"
label={t( label={t(
@@ -638,7 +645,7 @@ function UserListPage() {
{query.isLoading && ( {query.isLoading && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6 + userSchema.length} colSpan={7 + userSchema.length}
className="h-24 text-center" className="h-24 text-center"
> >
{t("msg.common.loading", "로딩 중...")} {t("msg.common.loading", "로딩 중...")}
@@ -648,7 +655,7 @@ function UserListPage() {
{!query.isLoading && items.length === 0 && ( {!query.isLoading && items.length === 0 && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6 + userSchema.length} colSpan={7 + userSchema.length}
className="h-24 text-center" className="h-24 text-center"
> >
{t( {t(

View File

@@ -14,6 +14,7 @@ unknown_error = "unknown error"
actions = "Actions" actions = "Actions"
add = "Add" add = "Add"
all = "All" all = "All"
apply = "Apply"
admin_only = "Admin Only" admin_only = "Admin Only"
apply = "Apply" apply = "Apply"
approve = "Approve" approve = "Approve"

View File

@@ -14,6 +14,7 @@ unknown_error = "알 수 없는 오류"
actions = "액션" actions = "액션"
add = "추가" add = "추가"
all = "전체" all = "전체"
apply = "적용"
admin_only = "관리자 전용" admin_only = "관리자 전용"
apply = "적용" apply = "적용"
approve = "승인" approve = "승인"

View File

@@ -14,6 +14,7 @@ unknown_error = ""
actions = "" actions = ""
add = "" add = ""
all = "" all = ""
apply = ""
admin_only = "" admin_only = ""
apply = "" apply = ""
approve = "" approve = ""

View File

@@ -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 commonTableClass = "w-full caption-bottom text-sm";
export const commonTableHeaderClass = "[&_tr]:border-b"; export const commonTableHeaderClass = "[&_tr]:border-b";
export const commonTableBodyClass = "[&_tr:last-child]:border-0"; export const commonTableBodyClass = "[&_tr:last-child]:border-0";

View File

@@ -17,11 +17,7 @@ const Table = React.forwardRef<
React.HTMLAttributes<HTMLTableElement> React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className={commonTableWrapperClass}> <div className={commonTableWrapperClass}>
<table <table ref={ref} className={cn(commonTableClass, className)} {...props} />
ref={ref}
className={cn(commonTableClass, className)}
{...props}
/>
</div> </div>
)); ));
Table.displayName = "Table"; Table.displayName = "Table";
@@ -30,7 +26,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn(commonTableHeaderClass, className)} {...props} /> <thead
ref={ref}
className={cn(commonTableHeaderClass, className)}
{...props}
/>
)); ));
TableHeader.displayName = "TableHeader"; TableHeader.displayName = "TableHeader";
@@ -38,11 +38,7 @@ const TableBody = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tbody <tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
ref={ref}
className={cn(commonTableBodyClass, className)}
{...props}
/>
)); ));
TableBody.displayName = "TableBody"; TableBody.displayName = "TableBody";
@@ -62,11 +58,7 @@ const TableRow = React.forwardRef<
HTMLTableRowElement, HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement> React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tr <tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
ref={ref}
className={cn(commonTableRowClass, className)}
{...props}
/>
)); ));
TableRow.displayName = "TableRow"; TableRow.displayName = "TableRow";
@@ -74,11 +66,7 @@ const TableHead = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement> React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<th <th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
ref={ref}
className={cn(commonTableHeadClass, className)}
{...props}
/>
)); ));
TableHead.displayName = "TableHead"; TableHead.displayName = "TableHead";
@@ -86,11 +74,7 @@ const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement> React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
ref={ref}
className={cn(commonTableCellClass, className)}
{...props}
/>
)); ));
TableCell.displayName = "TableCell"; TableCell.displayName = "TableCell";

View File

@@ -288,154 +288,164 @@ function AuditLogsPage() {
<div className={commonTableViewportClass}> <div className={commonTableViewportClass}>
<Table className="table-fixed"> <Table className="table-fixed">
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm"> <TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow> <TableRow>
<TableHead className="w-[190px]"> <TableHead className="w-[190px]">
{t("ui.dev.audit.table.time", "Time")} {t("ui.dev.audit.table.time", "Time")}
</TableHead> </TableHead>
<TableHead className="w-[180px]"> <TableHead className="w-[180px]">
{t("ui.dev.audit.table.actor", "Actor")} {t("ui.dev.audit.table.actor", "Actor")}
</TableHead> </TableHead>
<TableHead className="w-[180px]"> <TableHead className="w-[180px]">
{t("ui.dev.audit.table.action", "Action")} {t("ui.dev.audit.table.action", "Action")}
</TableHead> </TableHead>
<TableHead className="w-[260px]"> <TableHead className="w-[260px]">
{t("ui.dev.audit.table.target", "Target")} {t("ui.dev.audit.table.target", "Target")}
</TableHead> </TableHead>
<TableHead className="w-[120px]"> <TableHead className="w-[120px]">
{t("ui.dev.audit.table.status", "Status")} {t("ui.dev.audit.table.status", "Status")}
</TableHead> </TableHead>
<TableHead className="w-[80px]" /> <TableHead className="w-[80px]" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{query.isLoading && logs.length === 0 ? ( {query.isLoading && logs.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={6}
className="py-8 text-center text-muted-foreground" className="py-8 text-center text-muted-foreground"
> >
{t("msg.dev.audit.loading", "Loading audit logs...")} {t("msg.dev.audit.loading", "Loading audit logs...")}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : logs.length === 0 ? ( ) : logs.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={6}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
{t("msg.dev.audit.empty", "No audit logs found.")} {t("msg.dev.audit.empty", "No audit logs found.")}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
logs.map((row, index) => { logs.map((row, index) => {
const details = parseDetails(row.details); const details = parseDetails(row.details);
const actionLabel = details.action || row.event_type; const actionLabel = details.action || row.event_type;
const targetValue = details.target_id || "-"; const targetValue = details.target_id || "-";
const rowKey = `${row.event_id}-${row.timestamp}-${index}`; const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]); const expanded = Boolean(expandedRows[rowKey]);
return ( return (
<React.Fragment key={rowKey}> <React.Fragment key={rowKey}>
<TableRow> <TableRow>
<TableCell className="text-xs text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">
{formatDateTime(row.timestamp)} {formatDateTime(row.timestamp)}
</TableCell> </TableCell>
<TableCell className="font-mono text-xs"> <TableCell className="font-mono text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{row.user_id || "-"}</span> <span>{row.user_id || "-"}</span>
{row.user_id ? ( {row.user_id ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(row.user_id)}
>
<Copy className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
<TableCell className="text-xs">
{actionLabel}
</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
<span className="break-all">
{targetValue}
</span>
{targetValue !== "-" ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(targetValue)}
>
<Copy className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
<TableCell>
<Badge
variant={
row.status === "success"
? "success"
: "warning"
}
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
className="h-7 w-7 text-muted-foreground" onClick={() =>
onClick={() => handleCopy(row.user_id)} setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}))
}
> >
<Copy className="h-3 w-3" /> {expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button> </Button>
) : null} </TableCell>
</div> </TableRow>
</TableCell> {expanded ? (
<TableCell className="text-xs"> <TableRow className="bg-card/20">
{actionLabel} <TableCell
</TableCell> colSpan={6}
<TableCell className="font-mono text-xs"> className="text-xs text-muted-foreground"
<div className="flex items-center gap-2">
<span className="break-all">{targetValue}</span>
{targetValue !== "-" ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(targetValue)}
> >
<Copy className="h-3 w-3" /> <div className="grid gap-3 md:grid-cols-2">
</Button> <div className="space-y-1">
) : null} <div>
</div> Request ID:{" "}
</TableCell> {formatValue(details.request_id)}
<TableCell> </div>
<Badge <div>
variant={ Method: {formatValue(details.method)}
row.status === "success" ? "success" : "warning" </div>
} <div>
> Path: {formatValue(details.path)}
{row.status} </div>
</Badge> <div>
</TableCell> Tenant: {formatValue(details.tenant_id)}
<TableCell className="text-right"> </div>
<Button </div>
variant="ghost" <div className="space-y-1 break-all">
size="sm" <div>
onClick={() => Before: {formatValue(details.before)}
setExpandedRows((prev) => ({ </div>
...prev, <div>
[rowKey]: !expanded, After: {formatValue(details.after)}
})) </div>
} <div>
> Error: {formatValue(details.error)}
{expanded ? ( </div>
<ChevronUp className="h-4 w-4" /> </div>
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</TableCell>
</TableRow>
{expanded ? (
<TableRow className="bg-card/20">
<TableCell
colSpan={6}
className="text-xs text-muted-foreground"
>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<div>
Request ID:{" "}
{formatValue(details.request_id)}
</div> </div>
<div> </TableCell>
Method: {formatValue(details.method)} </TableRow>
</div> ) : null}
<div>Path: {formatValue(details.path)}</div> </React.Fragment>
<div> );
Tenant: {formatValue(details.tenant_id)} })
</div> )}
</div> </TableBody>
<div className="space-y-1 break-all">
<div>
Before: {formatValue(details.before)}
</div>
<div>After: {formatValue(details.after)}</div>
<div>Error: {formatValue(details.error)}</div>
</div>
</div>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
);
})
)}
</TableBody>
</Table> </Table>
</div> </div>
</div> </div>

View File

@@ -449,7 +449,10 @@ function ClientConsentsPage() {
{t("ui.dev.clients.consents.table.status", "Status")} {t("ui.dev.clients.consents.table.status", "Status")}
</TableHead> </TableHead>
<TableHead> <TableHead>
{t("ui.dev.clients.consents.table.scopes", "Granted Scopes")} {t(
"ui.dev.clients.consents.table.scopes",
"Granted Scopes",
)}
</TableHead> </TableHead>
<TableHead> <TableHead>
{t( {t(
@@ -553,7 +556,10 @@ function ClientConsentsPage() {
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{row.status === "revoked" && row.deletedAt ? ( {row.status === "revoked" && row.deletedAt ? (
<span className="text-destructive font-medium"> <span className="text-destructive font-medium">
{t("ui.dev.clients.consents.revoked_at", "Revoked: ")} {t(
"ui.dev.clients.consents.revoked_at",
"Revoked: ",
)}
{new Date(row.deletedAt).toLocaleString()} {new Date(row.deletedAt).toLocaleString()}
</span> </span>
) : row.authenticatedAt ? ( ) : row.authenticatedAt ? (

View File

@@ -1,12 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import { BookOpenText, Filter, Plus, Search, X } from "lucide-react";
BookOpenText,
Filter,
Plus,
Search,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
@@ -128,12 +122,11 @@ function ClientsPage() {
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [sortConfig, setSortConfig] = useState<SortConfig<ClientSortKey> | null>( const [sortConfig, setSortConfig] =
{ useState<SortConfig<ClientSortKey> | null>({
key: "createdAt", key: "createdAt",
direction: "desc", direction: "desc",
}, });
);
const clients = data?.items || []; const clients = data?.items || [];
const clientSortResolvers = useMemo< const clientSortResolvers = useMemo<
@@ -460,7 +453,10 @@ function ClientsPage() {
<TableHeader className={sortableTableHeaderClassName}> <TableHeader className={sortableTableHeaderClassName}>
<TableRow> <TableRow>
<SortableTableHead <SortableTableHead
label={t("ui.dev.clients.table.application", "애플리케이션")} label={t(
"ui.dev.clients.table.application",
"애플리케이션",
)}
onSort={requestSort} onSort={requestSort}
sortConfig={sortConfig} sortConfig={sortConfig}
sortKey="application" sortKey="application"
@@ -587,7 +583,10 @@ function ClientsPage() {
t("ui.dev.clients.untitled", "Untitled")} t("ui.dev.clients.untitled", "Untitled")}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("ui.dev.clients.tenant_scoped", "Tenant-scoped")} {t(
"ui.dev.clients.tenant_scoped",
"Tenant-scoped",
)}
</p> </p>
</div> </div>
</Link> </Link>
@@ -625,7 +624,9 @@ function ClientsPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant={client.status === "active" ? "info" : "muted"} variant={
client.status === "active" ? "info" : "muted"
}
className="px-3 py-1 text-xs uppercase" className="px-3 py-1 text-xs uppercase"
> >
{client.status === "active" {client.status === "active"

View File

@@ -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." notice_suffix = "Rotate the key immediately if you think it has been exposed."
[msg.admin.api_keys.list] [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?" delete_confirm = "Are you sure you want to delete this API key?"
empty = "No API keys have been issued yet." empty = "No API keys have been issued yet."
fetch_error = "Failed to load the API key list." 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" audit_events_24h = "24h Audit Events"
oidc_clients = "OIDC Clients" oidc_clients = "OIDC Clients"
policy_gate = "Policy Gate Status" policy_gate = "Policy Gate Status"
total_users = "Total Users"
total_tenants = "Total Tenants" total_tenants = "Total Tenants"
[msg.admin.tenants] [msg.admin.tenants]
@@ -197,6 +201,7 @@ delete_confirm = "Delete Tenant \\\\\\\"{{name}}\\\\\\\"?"
delete_success = "Tenant deleted." delete_success = "Tenant deleted."
empty = "No tenants have been registered yet." empty = "No tenants have been registered yet."
fetch_error = "Failed to load the tenant list." fetch_error = "Failed to load the tenant list."
export_error = "Failed to export tenants."
import_empty = "There are no tenant rows to import." import_empty = "There are no tenant rows to import."
import_error = "Failed to import tenants." import_error = "Failed to import tenants."
import_result = "Created {{created}}, updated {{updated}}, failed {{failed}}" import_result = "Created {{created}}, updated {{updated}}, failed {{failed}}"
@@ -283,6 +288,8 @@ move_success = "{{count}} users moved successfully."
parsed_count = "Parsed {{count}} rows." parsed_count = "Parsed {{count}} rows."
schema_incompatible = "Fields not in target schema may be lost:" schema_incompatible = "Fields not in target schema may be lost:"
schema_missing = "Missing required fields for target tenant:" schema_missing = "Missing required fields for target tenant:"
status_placeholder = "Select status"
permission_placeholder = "Select permission"
update_success = "User info updated successfully." update_success = "User info updated successfully."
[msg.admin.users.create] [msg.admin.users.create]
@@ -970,6 +977,10 @@ title = "API Key Created"
[ui.admin.api_keys.list] [ui.admin.api_keys.list]
add = "Add" add = "Add"
edit_scopes = "Edit Scopes"
rotate_secret = "Rotate Secret"
rotate_secret_done = "Secret Rotated"
save_scopes = "Save Scopes"
title = "API Key Management" title = "API Key Management"
[ui.admin.api_keys.list.breadcrumb] [ui.admin.api_keys.list.breadcrumb]
@@ -1116,6 +1127,7 @@ view_audit_logs = "View Audit Logs"
audit_events_24h = "24h Events" audit_events_24h = "24h Events"
oidc_clients = "OIDC Clients" oidc_clients = "OIDC Clients"
policy_gate = "Policy Gate" policy_gate = "Policy Gate"
total_users = "Total Users"
total_tenants = "Total Tenants" total_tenants = "Total Tenants"
[ui.admin.profile] [ui.admin.profile]
@@ -1378,6 +1390,7 @@ add = "Add"
add_dialog_desc = "Select a tenant to add as a sub-tenant." add_dialog_desc = "Select a tenant to add as a sub-tenant."
add_dialog_title = "Add Sub-tenant" add_dialog_title = "Add Sub-tenant"
add_existing = "Add Existing Tenant" add_existing = "Add Existing Tenant"
export = "Subtree CSV"
manage = "Manage" manage = "Manage"
no_candidates = "No available tenants to add." no_candidates = "No available tenants to add."
search_placeholder = "Search..." search_placeholder = "Search..."
@@ -1399,6 +1412,7 @@ slug = "SLUG"
status = "STATUS" status = "STATUS"
type = "TYPE" type = "TYPE"
updated = "UPDATED" updated = "UPDATED"
created = "CREATED"
[ui.admin.users] [ui.admin.users]
@@ -1416,6 +1430,8 @@ selected_count = "{{count}} users selected"
start_upload = "Start Upload" start_upload = "Start Upload"
tenant_resolution = "Tenant mapping" tenant_resolution = "Tenant mapping"
title = "Bulk Actions" title = "Bulk Actions"
status_placeholder = "Select status"
permission_placeholder = "Select permission"
[ui.admin.users.create] [ui.admin.users.create]
back = "Back" back = "Back"
@@ -2332,6 +2348,7 @@ title = "User Info"
[ui.dev.profile.org] [ui.dev.profile.org]
company_code = "Company Code" company_code = "Company Code"
tenant = "Tenant" tenant = "Tenant"
tenant_slug = "Tenant Slug"
title = "Organization Info" title = "Organization Info"
[ui.dev.profile.role] [ui.dev.profile.role]
@@ -2514,7 +2531,7 @@ department = "Department"
email = "Email" email = "Email"
name = "Name" name = "Name"
tenant = "Tenant" tenant = "Tenant"
tenant_slug = "Tenant slug" tenant_slug = "Tenant Slug"
[ui.userfront.profile.password] [ui.userfront.profile.password]
change = "Change" change = "Change"

View File

@@ -588,6 +588,9 @@ notice_emphasis = "지금 한 번만"
notice_suffix = "표시됩니다." notice_suffix = "표시됩니다."
[msg.admin.api_keys.list] [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}}\\\\\\\"를 삭제할까요?" delete_confirm = "API 키 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?"
empty = "등록된 API 키가 없습니다." empty = "등록된 API 키가 없습니다."
fetch_error = "API 키 목록 조회에 실패했습니다." fetch_error = "API 키 목록 조회에 실패했습니다."
@@ -685,6 +688,7 @@ description = "주요 운영 화면으로 바로 이동합니다."
audit_events_24h = "최근 24시간 감사 로그" audit_events_24h = "최근 24시간 감사 로그"
oidc_clients = "등록된 OIDC 클라이언트" oidc_clients = "등록된 OIDC 클라이언트"
policy_gate = "정책 가이트 상태" policy_gate = "정책 가이트 상태"
total_users = "전체 사용자 수"
total_tenants = "전체 테넌트 수" total_tenants = "전체 테넌트 수"
[msg.admin.tenants] [msg.admin.tenants]
@@ -694,6 +698,7 @@ delete_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?"
delete_success = "테넌트가 삭제되었습니다." delete_success = "테넌트가 삭제되었습니다."
empty = "아직 등록된 테넌트가 없습니다." empty = "아직 등록된 테넌트가 없습니다."
fetch_error = "테넌트 목록 조회에 실패했습니다." fetch_error = "테넌트 목록 조회에 실패했습니다."
export_error = "테넌트 내보내기에 실패했습니다."
import_empty = "임포트 파일에 테넌트 행이 없습니다." import_empty = "임포트 파일에 테넌트 행이 없습니다."
import_error = "테넌트 임포트에 실패했습니다: {{error}}" import_error = "테넌트 임포트에 실패했습니다: {{error}}"
import_result = "{{count}}개의 테넌트 행을 처리했습니다." import_result = "{{count}}개의 테넌트 행을 처리했습니다."
@@ -775,6 +780,8 @@ move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니
parsed_count = "{{count}}행의 데이터가 파싱되었습니다." parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
schema_incompatible = "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:" schema_incompatible = "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:"
schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:" schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:"
status_placeholder = "상태 선택"
permission_placeholder = "권한 선택"
update_success = "사용자 정보가 일괄 업데이트되었습니다." update_success = "사용자 정보가 일괄 업데이트되었습니다."
[msg.admin.users.create] [msg.admin.users.create]
@@ -1460,6 +1467,10 @@ title = "API 키 생성 완료"
[ui.admin.api_keys.list] [ui.admin.api_keys.list]
add = "API 키 생성" add = "API 키 생성"
edit_scopes = "권한 수정"
rotate_secret = "Secret 재발급"
rotate_secret_done = "Secret 재발급 완료"
save_scopes = "권한 저장"
title = "API 키 관리 (M2M)" title = "API 키 관리 (M2M)"
[ui.admin.api_keys.list.breadcrumb] [ui.admin.api_keys.list.breadcrumb]
@@ -1606,6 +1617,7 @@ view_audit_logs = "감사 로그 보기"
audit_events_24h = "24시간 이벤트" audit_events_24h = "24시간 이벤트"
oidc_clients = "OIDC 클라이언트" oidc_clients = "OIDC 클라이언트"
policy_gate = "정책 게이트" policy_gate = "정책 게이트"
total_users = "전체 사용자 수"
total_tenants = "전체 테넌트 수" total_tenants = "전체 테넌트 수"
[ui.admin.profile] [ui.admin.profile]
@@ -1841,6 +1853,7 @@ add = "하위 테넌트 추가"
add_dialog_desc = "하위 테넌트로 추가할 테넌트를 선택하세요." add_dialog_desc = "하위 테넌트로 추가할 테넌트를 선택하세요."
add_dialog_title = "하위 테넌트 추가" add_dialog_title = "하위 테넌트 추가"
add_existing = "기존 테넌트 추가" add_existing = "기존 테넌트 추가"
export = "하위 조직 CSV"
manage = "관리" manage = "관리"
no_candidates = "추가 가능한 테넌트가 없습니다." no_candidates = "추가 가능한 테넌트가 없습니다."
search_placeholder = "검색..." search_placeholder = "검색..."
@@ -1862,6 +1875,8 @@ slug = "SLUG"
status = "STATUS" status = "STATUS"
type = "유형" type = "유형"
updated = "UPDATED" updated = "UPDATED"
created = "CREATED"
created = "CREATED"
[ui.admin.users] [ui.admin.users]
@@ -1879,6 +1894,8 @@ selected_count = "{{count}}명 선택됨"
start_upload = "업로드 시작" start_upload = "업로드 시작"
tenant_resolution = "테넌트 매핑" tenant_resolution = "테넌트 매핑"
title = "일괄 작업" title = "일괄 작업"
status_placeholder = "상태 선택"
permission_placeholder = "권한 선택"
[ui.admin.users.create] [ui.admin.users.create]
back = "목록으로 돌아가기" back = "목록으로 돌아가기"
@@ -2757,6 +2774,7 @@ title = "사용자 정보"
[ui.dev.profile.org] [ui.dev.profile.org]
company_code = "회사 코드" company_code = "회사 코드"
tenant = "테넌트" tenant = "테넌트"
tenant_slug = "테넌트 Slug"
title = "조직 정보" title = "조직 정보"
[ui.dev.profile.role] [ui.dev.profile.role]
@@ -2938,7 +2956,7 @@ department = "소속"
email = "이메일" email = "이메일"
name = "이름" name = "이름"
tenant = "소속 테넌트" tenant = "소속 테넌트"
tenant_slug = "테넌트 slug" tenant_slug = "테넌트 Slug"
[ui.userfront.profile.password] [ui.userfront.profile.password]
change = "비밀번호 변경" change = "비밀번호 변경"

View File

@@ -451,6 +451,9 @@ notice_emphasis = ""
notice_suffix = "" notice_suffix = ""
[msg.admin.api_keys.list] [msg.admin.api_keys.list]
edit_scopes_desc = ""
rotate_confirm = ""
rotate_secret_notice = ""
delete_confirm = "" delete_confirm = ""
empty = "" empty = ""
fetch_error = "" fetch_error = ""
@@ -548,6 +551,7 @@ description = ""
audit_events_24h = "" audit_events_24h = ""
oidc_clients = "" oidc_clients = ""
policy_gate = "" policy_gate = ""
total_users = ""
total_tenants = "" total_tenants = ""
[msg.admin.tenants] [msg.admin.tenants]
@@ -557,6 +561,7 @@ delete_confirm = ""
delete_success = "" delete_success = ""
empty = "" empty = ""
fetch_error = "" fetch_error = ""
export_error = ""
import_empty = "" import_empty = ""
import_error = "" import_error = ""
import_result = "" import_result = ""
@@ -638,6 +643,8 @@ move_success = ""
parsed_count = "" parsed_count = ""
schema_incompatible = "" schema_incompatible = ""
schema_missing = "" schema_missing = ""
status_placeholder = ""
permission_placeholder = ""
update_success = "" update_success = ""
[msg.admin.users.create] [msg.admin.users.create]
@@ -1323,6 +1330,10 @@ title = ""
[ui.admin.api_keys.list] [ui.admin.api_keys.list]
add = "" add = ""
edit_scopes = ""
rotate_secret = ""
rotate_secret_done = ""
save_scopes = ""
title = "" title = ""
[ui.admin.api_keys.list.breadcrumb] [ui.admin.api_keys.list.breadcrumb]
@@ -1469,6 +1480,7 @@ view_audit_logs = ""
audit_events_24h = "" audit_events_24h = ""
oidc_clients = "" oidc_clients = ""
policy_gate = "" policy_gate = ""
total_users = ""
total_tenants = "" total_tenants = ""
[ui.admin.profile] [ui.admin.profile]
@@ -1487,6 +1499,9 @@ seed_badge = ""
title = "" title = ""
view_org_chart = "" view_org_chart = ""
[ui.admin.tenants.sub]
export = ""
[ui.admin.tenants.view] [ui.admin.tenants.view]
hierarchy = "" hierarchy = ""
list = "" list = ""
@@ -1740,6 +1755,7 @@ slug = ""
status = "" status = ""
type = "" type = ""
updated = "" updated = ""
created = ""
[ui.admin.users] [ui.admin.users]
@@ -1757,6 +1773,8 @@ selected_count = ""
start_upload = "" start_upload = ""
tenant_resolution = "" tenant_resolution = ""
title = "" title = ""
status_placeholder = ""
permission_placeholder = ""
[ui.admin.users.create] [ui.admin.users.create]
back = "" back = ""
@@ -2636,6 +2654,7 @@ title = ""
[ui.dev.profile.org] [ui.dev.profile.org]
company_code = "" company_code = ""
tenant = "" tenant = ""
tenant_slug = ""
title = "" title = ""
[ui.dev.profile.role] [ui.dev.profile.role]

View File

@@ -17,11 +17,7 @@ const Table = React.forwardRef<
React.HTMLAttributes<HTMLTableElement> React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className={commonTableWrapperClass}> <div className={commonTableWrapperClass}>
<table <table ref={ref} className={cn(commonTableClass, className)} {...props} />
ref={ref}
className={cn(commonTableClass, className)}
{...props}
/>
</div> </div>
)); ));
Table.displayName = "Table"; Table.displayName = "Table";
@@ -30,7 +26,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn(commonTableHeaderClass, className)} {...props} /> <thead
ref={ref}
className={cn(commonTableHeaderClass, className)}
{...props}
/>
)); ));
TableHeader.displayName = "TableHeader"; TableHeader.displayName = "TableHeader";
@@ -38,11 +38,7 @@ const TableBody = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tbody <tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
ref={ref}
className={cn(commonTableBodyClass, className)}
{...props}
/>
)); ));
TableBody.displayName = "TableBody"; TableBody.displayName = "TableBody";
@@ -62,11 +58,7 @@ const TableRow = React.forwardRef<
HTMLTableRowElement, HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement> React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tr <tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
ref={ref}
className={cn(commonTableRowClass, className)}
{...props}
/>
)); ));
TableRow.displayName = "TableRow"; TableRow.displayName = "TableRow";
@@ -74,11 +66,7 @@ const TableHead = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement> React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<th <th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
ref={ref}
className={cn(commonTableHeadClass, className)}
{...props}
/>
)); ));
TableHead.displayName = "TableHead"; TableHead.displayName = "TableHead";
@@ -86,11 +74,7 @@ const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement> React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
ref={ref}
className={cn(commonTableCellClass, className)}
{...props}
/>
)); ));
TableCell.displayName = "TableCell"; TableCell.displayName = "TableCell";

View File

@@ -17,7 +17,6 @@ mkdir -p reports
rm -rf adminfront/node_modules rm -rf adminfront/node_modules
tmp_dir="$(mktemp -d /tmp/baron-sso-adminfront-tests.XXXXXX)" tmp_dir="$(mktemp -d /tmp/baron-sso-adminfront-tests.XXXXXX)"
playwright_browsers_path="$tmp_dir/ms-playwright"
mkdir -p "$tmp_dir/scripts" mkdir -p "$tmp_dir/scripts"
cp "$repo_root/scripts/playwrightHostDeps.cjs" "$tmp_dir/scripts/" cp "$repo_root/scripts/playwrightHostDeps.cjs" "$tmp_dir/scripts/"
@@ -162,7 +161,7 @@ fi
set +e set +e
( (
cd "$tmp_dir/adminfront" cd "$tmp_dir/adminfront"
PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" "${playwright_install_cmd[@]}" "${playwright_install_cmd[@]}"
) 2>&1 | tee reports/adminfront-provision.log ) 2>&1 | tee reports/adminfront-provision.log
provision_exit_code=${PIPESTATUS[0]} provision_exit_code=${PIPESTATUS[0]}
set -e set -e
@@ -197,7 +196,7 @@ fi
echo "==> adminfront using PORT=$port" echo "==> adminfront using PORT=$port"
( (
cd "$tmp_dir/adminfront" 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[@]}" node ./node_modules/playwright/cli.js test "${playwright_project_args[@]}"
) 2>&1 | tee reports/adminfront-test.log ) 2>&1 | tee reports/adminfront-test.log
test_exit_code=${PIPESTATUS[0]} test_exit_code=${PIPESTATUS[0]}

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.0"
cli_config: cli_config:
dependency: transitive dependency: transitive
description: description:
@@ -276,6 +276,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -328,18 +336,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -661,26 +669,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.30.0" version: "1.26.3"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.7"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.16" version: "0.6.12"
toml: toml:
dependency: "direct main" dependency: "direct main"
description: description: