1
0
forked from baron/baron-sso

Merge pull request 'feature/df-dashboard' (#819) from feature/df-dashboard into dev

Reviewed-on: baron/baron-sso#819
This commit is contained in:
2026-05-14 11:14:07 +09:00
38 changed files with 1183 additions and 895 deletions

View File

@@ -1,16 +1,23 @@
import * as React from "react"; import * as React from "react";
import {
commonTableBodyClass,
commonTableCaptionClass,
commonTableCellClass,
commonTableClass,
commonTableFooterClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
const Table = React.forwardRef< const Table = React.forwardRef<
HTMLTableElement, HTMLTableElement,
React.HTMLAttributes<HTMLTableElement> React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className="relative w-full"> <div className={commonTableWrapperClass}>
<table <table ref={ref} className={cn(commonTableClass, className)} {...props} />
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div> </div>
)); ));
Table.displayName = "Table"; Table.displayName = "Table";
@@ -19,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("[&_tr]:border-b", className)} {...props} /> <thead
ref={ref}
className={cn(commonTableHeaderClass, className)}
{...props}
/>
)); ));
TableHeader.displayName = "TableHeader"; TableHeader.displayName = "TableHeader";
@@ -27,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("[&_tr:last-child]:border-0", className)}
{...props}
/>
)); ));
TableBody.displayName = "TableBody"; TableBody.displayName = "TableBody";
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tfoot <tfoot
ref={ref} ref={ref}
className={cn("bg-muted/50 font-medium text-foreground", className)} className={cn(commonTableFooterClass, className)}
{...props} {...props}
/> />
)); ));
@@ -51,14 +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(
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
)); ));
TableRow.displayName = "TableRow"; TableRow.displayName = "TableRow";
@@ -66,14 +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(
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-foreground align-middle sticky top-0 bg-inherit",
className,
)}
{...props}
/>
)); ));
TableHead.displayName = "TableHead"; TableHead.displayName = "TableHead";
@@ -81,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("p-6 align-middle text-sm", className)}
{...props}
/>
)); ));
TableCell.displayName = "TableCell"; TableCell.displayName = "TableCell";
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<caption <caption
ref={ref} ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn(commonTableCaptionClass, className)}
{...props} {...props}
/> />
)); ));

View File

@@ -27,6 +27,10 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../components/ui/table"; } from "../../components/ui/table";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
import type { AuditLog } from "../../lib/adminApi"; import type { AuditLog } from "../../lib/adminApi";
import { fetchAuditLogs } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
@@ -254,8 +258,8 @@ function AuditLogsPage() {
)) ))
)} )}
</div> </div>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className={commonTableShellClass}>
<div className="flex-1 overflow-auto relative custom-scrollbar"> <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>

View File

@@ -23,10 +23,19 @@ import {
import * as React from "react"; import * as React from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { import {
type SortConfig, SortableTableHead,
type SortResolverMap, sortableTableHeadBaseClassName,
sortableTableHeaderClassName,
} from "../../../../../common/core/components/sort";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../../common/ui/table";
import {
sortItems, sortItems,
toggleSort, toggleSort,
type SortConfig,
type SortResolverMap,
} from "../../../../../common/core/utils"; } from "../../../../../common/core/utils";
import { RoleGuard } from "../../../components/auth/RoleGuard"; import { RoleGuard } from "../../../components/auth/RoleGuard";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
@@ -241,7 +250,10 @@ function TenantListPage() {
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] = const [sortConfig, setSortConfig] =
React.useState<SortConfig<TenantSortKey> | null>(null); React.useState<SortConfig<TenantSortKey> | null>({
key: "createdAt",
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<
@@ -374,9 +386,8 @@ function TenantListPage() {
} }
if ( if (
profile &&
profileRole === "tenant_admin" && profileRole === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) <= 1 (profile?.manageableTenants?.length ?? 0) <= 1
) { ) {
return null; return null;
} }
@@ -424,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) {
@@ -513,17 +517,6 @@ function TenantListPage() {
setSortConfig((current) => toggleSort(current, key)); setSortConfig((current) => toggleSort(current, key));
}; };
const getSortIcon = (key: TenantSortKey) => {
if (!sortConfig || sortConfig.key !== key) {
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
}
return sortConfig.direction === "asc" ? (
<ArrowUp size={14} className="ml-1" />
) : (
<ArrowDown size={14} className="ml-1" />
);
};
const deletableTenants = React.useMemo( const deletableTenants = React.useMemo(
() => tenants.filter((tenant) => !isSeedTenant(tenant)), () => tenants.filter((tenant) => !isSeedTenant(tenant)),
[tenants], [tenants],
@@ -766,8 +759,8 @@ function TenantListPage() {
{tenant.recursiveMemberCount} {tenant.recursiveMemberCount}
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap text-xs"> <TableCell className="whitespace-nowrap text-xs">
{tenant.updatedAt {tenant.createdAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR") ? new Date(tenant.createdAt).toLocaleString("ko-KR")
: "-"} : "-"}
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -955,17 +948,19 @@ function TenantListPage() {
value="list" value="list"
className="flex-1 flex flex-col min-h-0 m-0" className="flex-1 flex flex-col min-h-0 m-0"
> >
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className={commonTableShellClass}>
<div <div
className={commonTableViewportClass}
ref={tenantTableScrollRef} ref={tenantTableScrollRef}
className="flex-1 overflow-auto relative custom-scrollbar"
data-testid="tenant-table-scroll"
onScroll={handleTenantTableScroll} onScroll={handleTenantTableScroll}
data-testid="tenant-table-scroll"
> >
<Table className="min-w-[1180px]"> <Table className="min-w-[1180px]">
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm"> <TableHeader className={sortableTableHeaderClassName}>
<TableRow> <TableRow>
<TableHead className="w-[48px] whitespace-nowrap"> <TableHead
className={`${sortableTableHeadBaseClassName} w-[48px] whitespace-nowrap`}
>
<Checkbox <Checkbox
checked={ checked={
tenants.length > 0 && tenants.length > 0 &&
@@ -977,70 +972,58 @@ function TenantListPage() {
} }
/> />
</TableHead> </TableHead>
<SortableTableHead
className="min-w-[220px] whitespace-nowrap"
label={t("ui.admin.tenants.table.id", "ID")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="id"
/>
<SortableTableHead
className="whitespace-nowrap"
label={t("ui.admin.tenants.table.name", "NAME")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="name"
/>
<SortableTableHead
className="whitespace-nowrap"
label={t("ui.admin.tenants.table.type", "TYPE")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="type"
/>
<SortableTableHead
className="whitespace-nowrap"
label={t("ui.admin.tenants.table.slug", "SLUG")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="slug"
/>
<SortableTableHead
className="whitespace-nowrap"
label={t("ui.admin.tenants.table.status", "STATUS")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="status"
/>
<SortableTableHead
className="whitespace-nowrap"
label={t("ui.admin.tenants.table.members", "MEMBERS")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="recursiveMemberCount"
/>
<SortableTableHead
className="whitespace-nowrap"
label={t("ui.admin.tenants.table.created", "CREATED")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="createdAt"
/>
<TableHead <TableHead
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap" className={`${sortableTableHeadBaseClassName} whitespace-nowrap`}
onClick={() => requestSort("id")}
> >
<div className="flex items-center">
{t("ui.admin.tenants.table.id", "ID")}
{getSortIcon("id")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
onClick={() => requestSort("name")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.name", "NAME")}
{getSortIcon("name")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
onClick={() => requestSort("type")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.type", "TYPE")}
{getSortIcon("type")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
onClick={() => requestSort("slug")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.slug", "SLUG")}
{getSortIcon("slug")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
onClick={() => requestSort("status")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.status", "STATUS")}
{getSortIcon("status")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
onClick={() => requestSort("recursiveMemberCount")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.members", "MEMBERS")}
{getSortIcon("recursiveMemberCount")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
onClick={() => requestSort("updatedAt")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.updated", "UPDATED")}
{getSortIcon("updatedAt")}
</div>
</TableHead>
<TableHead className="whitespace-nowrap">
{t("ui.common.actions", "액션")} {t("ui.common.actions", "액션")}
</TableHead> </TableHead>
</TableRow> </TableRow>

View File

@@ -1,9 +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 {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
FileDown, FileDown,
@@ -22,6 +19,15 @@ import {
sortItems, sortItems,
toggleSort, toggleSort,
} from "../../../../common/core/utils"; } from "../../../../common/core/utils";
import {
SortableTableHead,
sortableTableHeadBaseClassName,
sortableTableHeaderClassName,
} from "../../../../common/core/components/sort";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
Card, Card,
@@ -285,17 +291,6 @@ function UserListPage() {
setSortConfig((current) => toggleSort(current, key)); setSortConfig((current) => toggleSort(current, key));
}; };
const getSortIcon = (key: UserSortKey) => {
if (!sortConfig || sortConfig.key !== key) {
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
}
return sortConfig.direction === "asc" ? (
<ArrowUp size={14} className="ml-1" />
) : (
<ArrowDown size={14} className="ml-1" />
);
};
const total = query.data?.total ?? 0; const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit); const totalPages = Math.ceil(total / limit);
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role); const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
@@ -564,12 +559,14 @@ function UserListPage() {
</div> </div>
)} )}
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className={commonTableShellClass}>
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div className={commonTableViewportClass}>
<Table> <Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm"> <TableHeader className={sortableTableHeaderClassName}>
<TableRow> <TableRow>
<TableHead className="w-12"> <TableHead
className={`${sortableTableHeadBaseClassName} w-12`}
>
<input <input
type="checkbox" type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer" className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
@@ -580,89 +577,75 @@ function UserListPage() {
onChange={toggleSelectAll} onChange={toggleSelectAll}
/> />
</TableHead> </TableHead>
<TableHead <SortableTableHead
className="min-w-[220px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors" className="min-w-[220px] whitespace-nowrap"
onClick={() => requestSort("id")} label={t("ui.admin.users.list.table.id", "ID")}
> onSort={requestSort}
<div className="flex items-center"> sortConfig={sortConfig}
{t("ui.admin.users.list.table.id", "ID")} sortKey="id"
{getSortIcon("id")} />
</div> <SortableTableHead
</TableHead> className="min-w-[200px] whitespace-nowrap"
<TableHead label={t(
className="min-w-[200px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors" "ui.admin.users.list.table.name_email",
onClick={() => requestSort("name_email")} "이름 / 이메일 / 전화번호",
> )}
<div className="flex items-center"> onSort={requestSort}
{t( sortConfig={sortConfig}
"ui.admin.users.list.table.name_email", sortKey="name_email"
"이름 / 이메일 / 전화번호", />
)} <SortableTableHead
{getSortIcon("name_email")} className="whitespace-nowrap"
</div> label={t("ui.admin.users.list.table.status", "STATUS")}
</TableHead> onSort={requestSort}
<TableHead sortConfig={sortConfig}
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors" sortKey="status"
onClick={() => requestSort("status")} />
> <SortableTableHead
<div className="flex items-center"> className="whitespace-nowrap"
{t("ui.admin.users.list.table.status", "STATUS")} label={t("ui.admin.users.list.table.role", "ROLE")}
{getSortIcon("status")} onSort={requestSort}
</div> sortConfig={sortConfig}
</TableHead> sortKey="role"
<TableHead />
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors" <SortableTableHead
onClick={() => requestSort("role")} className="whitespace-nowrap"
> label={t(
<div className="flex items-center"> "ui.admin.users.list.table.tenant_dept",
{t("ui.admin.users.list.table.role", "ROLE")} "TENANT / DEPT",
{getSortIcon("role")} )}
</div> onSort={requestSort}
</TableHead> sortConfig={sortConfig}
<TableHead sortKey="tenant_dept"
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors" />
onClick={() => requestSort("tenant_dept")}
>
<div className="flex items-center">
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
{getSortIcon("tenant_dept")}
</div>
</TableHead>
{/* Dynamic Columns from Schema */} {/* Dynamic Columns from Schema */}
{userSchema.map( {userSchema.map(
(field) => (field) =>
visibleColumns[field.key] !== false && ( visibleColumns[field.key] !== false && (
<TableHead <SortableTableHead
key={field.key} key={field.key}
className="whitespace-nowrap uppercase cursor-pointer hover:bg-muted/50 transition-colors" className="whitespace-nowrap"
onClick={() => requestSort(field.key)} label={field.label}
> onSort={requestSort}
<div className="flex items-center"> sortConfig={sortConfig}
{field.label} sortKey={field.key}
{getSortIcon(field.key)} />
</div>
</TableHead>
), ),
)} )}
<TableHead <SortableTableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors" className="whitespace-nowrap"
onClick={() => requestSort("createdAt")} label={t("ui.admin.users.list.table.created", "CREATED")}
> onSort={requestSort}
<div className="flex items-center"> sortConfig={sortConfig}
{t("ui.admin.users.list.table.created", "CREATED")} sortKey="createdAt"
{getSortIcon("createdAt")} />
</div>
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{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", "로딩 중...")}
@@ -672,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

@@ -15,6 +15,13 @@
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"lucide-react": ["./node_modules/lucide-react"],
"react": ["./node_modules/@types/react/index.d.ts"],
"react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime.d.ts"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"]
},
/* Linting */ /* Linting */
"strict": true, "strict": true,
@@ -24,6 +31,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"], "include": ["src", "../common/**/*.ts", "../common/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
} }

View File

@@ -1,4 +1,5 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "node:path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
const buildOutDir = const buildOutDir =
@@ -6,6 +7,20 @@ const buildOutDir =
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
"lucide-react": path.resolve(__dirname, "node_modules/lucide-react"),
react: path.resolve(__dirname, "node_modules/react"),
"react/jsx-dev-runtime": path.resolve(
__dirname,
"node_modules/react/jsx-dev-runtime.js",
),
"react/jsx-runtime": path.resolve(
__dirname,
"node_modules/react/jsx-runtime.js",
),
},
},
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"], envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
cacheDir: cacheDir:
process.env.ADMINFRONT_VITE_CACHE_DIR ?? process.env.ADMINFRONT_VITE_CACHE_DIR ??

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,169 @@
import type { ReactNode, ThHTMLAttributes } from "react";
import type { SortConfig } from "../../utils";
import { commonTableHeadClass } from "../../../ui/table";
export const sortableTableHeadBaseClassName =
commonTableHeadClass;
export const sortableTableHeaderClassName =
"sticky top-0 z-10 bg-secondary shadow-sm";
function SortAscendingIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12 5-5 5" />
<path d="m12 5 5 5" />
<path d="M12 19V5" />
</svg>
);
}
function SortDescendingIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12 19-5-5" />
<path d="m12 19 5-5" />
<path d="M12 5v14" />
</svg>
);
}
function SortIdleIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="ml-1 h-3.5 w-3.5 opacity-50"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
);
}
type SortableTableHeadAlign = "left" | "center" | "right";
function alignClassName(align: SortableTableHeadAlign) {
switch (align) {
case "center":
return "text-center";
case "right":
return "text-right";
default:
return "text-left";
}
}
function buttonAlignClassName(align: SortableTableHeadAlign) {
switch (align) {
case "center":
return "justify-center";
case "right":
return "justify-end";
default:
return "justify-start";
}
}
function sortAriaValue(
isActive: boolean,
direction: "asc" | "desc" | null,
): ThHTMLAttributes<HTMLTableCellElement>["aria-sort"] {
if (!isActive || direction === null) {
return "none";
}
return direction === "asc" ? "ascending" : "descending";
}
type SortableTableHeadProps<Key extends string> = Omit<
ThHTMLAttributes<HTMLTableCellElement>,
"children"
> & {
align?: SortableTableHeadAlign;
contentClassName?: string;
disabled?: boolean;
label: ReactNode;
onSort: (key: Key) => void;
sortConfig: SortConfig<Key> | null;
sortKey: Key;
};
export function SortableTableHead<Key extends string>({
align = "left",
className = "",
contentClassName = "",
disabled = false,
label,
onSort,
sortConfig,
sortKey,
...props
}: SortableTableHeadProps<Key>) {
const isActive = sortConfig?.key === sortKey;
const direction = isActive ? sortConfig?.direction ?? null : null;
return (
<th
aria-sort={sortAriaValue(isActive, direction)}
className={[
sortableTableHeadBaseClassName,
alignClassName(align),
disabled ? "" : "transition-colors hover:bg-muted/50",
className,
]
.filter(Boolean)
.join(" ")}
{...props}
>
<button
type="button"
onClick={() => onSort(sortKey)}
disabled={disabled}
className={[
"flex w-full items-center font-inherit",
buttonAlignClassName(align),
disabled ? "cursor-default opacity-70" : "cursor-pointer",
contentClassName,
]
.filter(Boolean)
.join(" ")}
>
<span>{label}</span>
{direction === "asc" ? (
<span className="ml-1 inline-flex">
<SortAscendingIcon />
</span>
) : direction === "desc" ? (
<span className="ml-1 inline-flex">
<SortDescendingIcon />
</span>
) : (
<SortIdleIcon />
)}
</button>
</th>
);
}

View File

@@ -0,0 +1 @@
export * from "./SortableTableHead";

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 = ""

15
common/ui/table.ts Normal file
View File

@@ -0,0 +1,15 @@
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";
export const commonTableFooterClass = "bg-muted/50 font-medium text-foreground";
export const commonTableRowClass =
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted";
export const commonTableHeadClass =
"h-12 px-6 text-left text-xs font-sans font-bold uppercase tracking-[0.08em] text-foreground align-middle";
export const commonTableCellClass = "p-6 align-middle text-sm";
export const commonTableCaptionClass = "mt-4 text-sm text-muted-foreground";
export const commonTableShellClass =
"flex-1 rounded-md border overflow-hidden flex flex-col";
export const commonTableViewportClass =
"flex-1 overflow-auto relative custom-scrollbar";

View File

@@ -1,16 +1,23 @@
import * as React from "react"; import * as React from "react";
import {
commonTableBodyClass,
commonTableCaptionClass,
commonTableCellClass,
commonTableClass,
commonTableFooterClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
const Table = React.forwardRef< const Table = React.forwardRef<
HTMLTableElement, HTMLTableElement,
React.HTMLAttributes<HTMLTableElement> React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto"> <div className={commonTableWrapperClass}>
<table <table ref={ref} className={cn(commonTableClass, className)} {...props} />
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div> </div>
)); ));
Table.displayName = "Table"; Table.displayName = "Table";
@@ -19,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("[&_tr]:border-b", className)} {...props} /> <thead
ref={ref}
className={cn(commonTableHeaderClass, className)}
{...props}
/>
)); ));
TableHeader.displayName = "TableHeader"; TableHeader.displayName = "TableHeader";
@@ -27,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("[&_tr:last-child]:border-0", className)}
{...props}
/>
)); ));
TableBody.displayName = "TableBody"; TableBody.displayName = "TableBody";
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tfoot <tfoot
ref={ref} ref={ref}
className={cn("bg-muted/50 font-medium text-foreground", className)} className={cn(commonTableFooterClass, className)}
{...props} {...props}
/> />
)); ));
@@ -51,14 +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(
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
)); ));
TableRow.displayName = "TableRow"; TableRow.displayName = "TableRow";
@@ -66,14 +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(
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
className,
)}
{...props}
/>
)); ));
TableHead.displayName = "TableHead"; TableHead.displayName = "TableHead";
@@ -81,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("p-6 align-middle text-sm", className)}
{...props}
/>
)); ));
TableCell.displayName = "TableCell"; TableCell.displayName = "TableCell";
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<caption <caption
ref={ref} ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn(commonTableCaptionClass, className)}
{...props} {...props}
/> />
)); ));

View File

@@ -28,6 +28,10 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../components/ui/table"; } from "../../components/ui/table";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
import type { DevAuditLog } from "../../lib/devApi"; import type { DevAuditLog } from "../../lib/devApi";
import { fetchDevAuditLogs } from "../../lib/devApi"; import { fetchDevAuditLogs } from "../../lib/devApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
@@ -280,157 +284,171 @@ function AuditLogsPage() {
: "" : ""
} }
> >
<Table className="table-fixed"> <div className={commonTableShellClass}>
<TableHeader> <div className={commonTableViewportClass}>
<TableRow> <Table className="table-fixed">
<TableHead className="w-[190px]"> <TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
{t("ui.dev.audit.table.time", "Time")} <TableRow>
</TableHead> <TableHead className="w-[190px]">
<TableHead className="w-[180px]"> {t("ui.dev.audit.table.time", "Time")}
{t("ui.dev.audit.table.actor", "Actor")} </TableHead>
</TableHead> <TableHead className="w-[180px]">
<TableHead className="w-[180px]"> {t("ui.dev.audit.table.actor", "Actor")}
{t("ui.dev.audit.table.action", "Action")} </TableHead>
</TableHead> <TableHead className="w-[180px]">
<TableHead className="w-[260px]"> {t("ui.dev.audit.table.action", "Action")}
{t("ui.dev.audit.table.target", "Target")} </TableHead>
</TableHead> <TableHead className="w-[260px]">
<TableHead className="w-[120px]"> {t("ui.dev.audit.table.target", "Target")}
{t("ui.dev.audit.table.status", "Status")} </TableHead>
</TableHead> <TableHead className="w-[120px]">
<TableHead className="w-[80px]" /> {t("ui.dev.audit.table.status", "Status")}
</TableRow> </TableHead>
</TableHeader> <TableHead className="w-[80px]" />
<TableBody> </TableRow>
{query.isLoading && logs.length === 0 ? ( </TableHeader>
<TableRow> <TableBody>
<TableCell {query.isLoading && logs.length === 0 ? (
colSpan={6} <TableRow>
className="py-8 text-center text-muted-foreground" <TableCell
> colSpan={6}
{t("msg.dev.audit.loading", "Loading audit logs...")} className="py-8 text-center text-muted-foreground"
</TableCell> >
</TableRow> {t("msg.dev.audit.loading", "Loading audit logs...")}
) : logs.length === 0 ? ( </TableCell>
<TableRow> </TableRow>
<TableCell ) : logs.length === 0 ? (
colSpan={6} <TableRow>
className="text-center text-muted-foreground" <TableCell
> colSpan={6}
{t("msg.dev.audit.empty", "No audit logs found.")} className="text-center text-muted-foreground"
</TableCell> >
</TableRow> {t("msg.dev.audit.empty", "No audit logs found.")}
) : ( </TableCell>
logs.map((row, index) => { </TableRow>
const details = parseDetails(row.details); ) : (
const actionLabel = details.action || row.event_type; logs.map((row, index) => {
const targetValue = details.target_id || "-"; const details = parseDetails(row.details);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`; const actionLabel = details.action || row.event_type;
const expanded = Boolean(expandedRows[rowKey]); const targetValue = details.target_id || "-";
return ( const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
<React.Fragment key={rowKey}> const expanded = Boolean(expandedRows[rowKey]);
<TableRow> return (
<TableCell className="text-xs text-muted-foreground"> <React.Fragment key={rowKey}>
{formatDateTime(row.timestamp)} <TableRow>
</TableCell> <TableCell className="text-xs text-muted-foreground">
<TableCell className="font-mono text-xs"> {formatDateTime(row.timestamp)}
<div className="flex items-center gap-2"> </TableCell>
<span>{row.user_id || "-"}</span> <TableCell className="font-mono text-xs">
{row.user_id ? ( <div className="flex items-center gap-2">
<span>{row.user_id || "-"}</span>
{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"> </Table>
<div> </div>
Before: {formatValue(details.before)} </div>
</div>
<div>After: {formatValue(details.after)}</div>
<div>Error: {formatValue(details.error)}</div>
</div>
</div>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div> </div>
{query.hasNextPage ? ( {query.hasNextPage ? (

View File

@@ -16,6 +16,11 @@ import { canStartBrowserPkceLogin } from "../../lib/authConfig";
const insecurePkceMessage = const insecurePkceMessage =
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요."; "이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
function isPkceSetupFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message);
}
function LoginPage() { function LoginPage() {
const auth = useAuth(); const auth = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -55,11 +60,19 @@ function LoginPage() {
} }
autoStartedRef.current = true; autoStartedRef.current = true;
void auth.signinRedirect({ void auth
state: { .signinRedirect({
returnTo, state: {
}, returnTo,
}); },
})
.catch((error) => {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Auto login redirect failed", error);
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]); }, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = async () => { const handleSSOLogin = async () => {
@@ -75,6 +88,10 @@ function LoginPage() {
}, },
}); });
} catch (error) { } catch (error) {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Redirect login failed", error); console.error("Redirect login failed", error);
} }
}; };

View File

@@ -28,6 +28,10 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../components/ui/table"; } from "../../components/ui/table";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi"; import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
@@ -272,7 +276,7 @@ function ClientConsentsPage() {
</header> </header>
<Card className="glass-panel"> <Card className="glass-panel">
<CardContent className="space-y-4"> <CardContent className="space-y-4 pt-6">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-4 flex-1"> <div className="flex flex-wrap items-center gap-4 flex-1">
<div className="relative w-full max-w-md"> <div className="relative w-full max-w-md">
@@ -430,146 +434,159 @@ function ClientConsentsPage() {
</CardContent> </CardContent>
)} )}
<Table> <div className={commonTableShellClass}>
<TableHeader> <div className={commonTableViewportClass}>
<TableRow> <Table>
<TableHead> <TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
{t("ui.dev.clients.consents.table.user", "User")} <TableRow>
</TableHead> <TableHead>
<TableHead> {t("ui.dev.clients.consents.table.user", "User")}
{t("ui.dev.clients.consents.table.tenant", "Tenant")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.dev.clients.consents.table.tenant", "Tenant")}
{t("ui.dev.clients.consents.table.status", "Status")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.dev.clients.consents.table.status", "Status")}
{t("ui.dev.clients.consents.table.scopes", "Granted Scopes")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t(
{t( "ui.dev.clients.consents.table.scopes",
"ui.dev.clients.consents.table.first_granted", "Granted Scopes",
"First Granted",
)}
</TableHead>
<TableHead>
{t(
"ui.dev.clients.consents.table.last_auth",
"Last Authenticated / Revoked",
)}
</TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.consents.table.action", "Action")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.length === 0 && !isLoading && !error ? (
<TableRow>
<TableCell
colSpan={7}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.dev.clients.consents.empty",
"No consents found.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
filteredRows.map((row) => (
<TableRow
key={`${row.subject}-${row.clientId}`}
className={row.status === "revoked" ? "opacity-60" : ""}
>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{(row.userName || row.subject)
.slice(0, 2)
.toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.userName ||
t("ui.dev.clients.consents.subject", "Subject")}
</span>
<span className="text-xs text-muted-foreground">
{row.subject}
</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.tenantName || t("ui.common.na", "N/A")}
</span>
<span className="text-xs text-muted-foreground">
{row.tenantId}
</span>
</div>
</TableCell>
<TableCell>
{row.status === "active" ? (
<Badge variant="success">
{t("ui.common.status.active", "Active")}
</Badge>
) : (
<Badge variant="warning">
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
</Badge>
)} )}
</TableCell> </TableHead>
<TableCell> <TableHead>
<div className="flex flex-wrap gap-1"> {t(
{row.grantedScopes.map((scope) => ( "ui.dev.clients.consents.table.first_granted",
<Badge "First Granted",
key={scope}
variant="muted"
className="border bg-muted/40 text-foreground"
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(row.createdAt).toLocaleString()}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.status === "revoked" && row.deletedAt ? (
<span className="text-destructive font-medium">
{t("ui.dev.clients.consents.revoked_at", "Revoked: ")}
{new Date(row.deletedAt).toLocaleString()}
</span>
) : row.authenticatedAt ? (
new Date(row.authenticatedAt).toLocaleString()
) : (
"-"
)} )}
</TableCell> </TableHead>
<TableCell className="text-right"> <TableHead>
{row.status === "active" && ( {t(
<Button "ui.dev.clients.consents.table.last_auth",
variant="ghost" "Last Authenticated / Revoked",
className="text-destructive hover:bg-destructive/10"
onClick={() => handleRevoke(row.subject)}
disabled={revokeMutation.isPending}
>
{t("ui.dev.clients.consents.revoke", "Revoke")}
</Button>
)} )}
</TableCell> </TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.consents.table.action", "Action")}
</TableHead>
</TableRow> </TableRow>
)) </TableHeader>
)} <TableBody>
</TableBody> {filteredRows.length === 0 && !isLoading && !error ? (
</Table> <TableRow>
<TableCell
colSpan={7}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.dev.clients.consents.empty",
"No consents found.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
filteredRows.map((row) => (
<TableRow
key={`${row.subject}-${row.clientId}`}
className={row.status === "revoked" ? "opacity-60" : ""}
>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{(row.userName || row.subject)
.slice(0, 2)
.toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.userName ||
t("ui.dev.clients.consents.subject", "Subject")}
</span>
<span className="text-xs text-muted-foreground">
{row.subject}
</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.tenantName || t("ui.common.na", "N/A")}
</span>
<span className="text-xs text-muted-foreground">
{row.tenantId}
</span>
</div>
</TableCell>
<TableCell>
{row.status === "active" ? (
<Badge variant="success">
{t("ui.common.status.active", "Active")}
</Badge>
) : (
<Badge variant="warning">
{t(
"ui.dev.clients.consents.status_revoked",
"Revoked",
)}
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.grantedScopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="border bg-muted/40 text-foreground"
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(row.createdAt).toLocaleString()}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.status === "revoked" && row.deletedAt ? (
<span className="text-destructive font-medium">
{t(
"ui.dev.clients.consents.revoked_at",
"Revoked: ",
)}
{new Date(row.deletedAt).toLocaleString()}
</span>
) : row.authenticatedAt ? (
new Date(row.authenticatedAt).toLocaleString()
) : (
"-"
)}
</TableCell>
<TableCell className="text-right">
{row.status === "active" && (
<Button
variant="ghost"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleRevoke(row.subject)}
disabled={revokeMutation.isPending}
>
{t("ui.dev.clients.consents.revoke", "Revoke")}
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground"> <CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
<p> <p>
{t( {t(

View File

@@ -39,6 +39,7 @@ import {
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { ClientDetailTabs } from "./ClientDetailTabs"; import { ClientDetailTabs } from "./ClientDetailTabs";
import { canDisplayClientSecret } from "./clientSecretPolicy";
function ClientDetailsPage() { function ClientDetailsPage() {
const params = useParams(); const params = useParams();
@@ -175,7 +176,6 @@ function ClientDetailsPage() {
} }
const client = data?.client; const client = data?.client;
const isHeadlessLogin = client?.metadata?.headless_login_enabled === true;
if (!client) { if (!client) {
return null; return null;
} }
@@ -214,21 +214,16 @@ function ClientDetailsPage() {
}, },
]; ];
const hasClientSecret = client.type === "private" && !isHeadlessLogin; const hasClientSecret = canDisplayClientSecret(client);
const secretPlaceholder = "SECRET_NOT_AVAILABLE"; const secretPlaceholder = "SECRET_NOT_AVAILABLE";
const clientSecret = hasClientSecret const clientSecret = hasClientSecret
? client?.clientSecret || secretPlaceholder ? client?.clientSecret || secretPlaceholder
: t("ui.common.na", "N/A"); : t("ui.common.na", "N/A");
const displaySecret = !hasClientSecret const displaySecret = !hasClientSecret
? isHeadlessLogin ? t(
? t( "msg.dev.clients.details.secret_not_applicable",
"msg.dev.clients.details.secret_not_applicable_headless", "PKCE 앱에는 Client Secret이 없습니다.",
"이 앱은 Headless Login용 signed key 인증을 사용하므로 Client Secret을 사용하지 않습니다.", )
)
: t(
"msg.dev.clients.details.secret_not_applicable",
"PKCE 앱에는 Client Secret이 없습니다.",
)
: clientSecret === secretPlaceholder : clientSecret === secretPlaceholder
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE") ? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
: clientSecret; : clientSecret;
@@ -400,15 +395,10 @@ function ClientDetailsPage() {
</div> </div>
{!hasClientSecret ? ( {!hasClientSecret ? (
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{isHeadlessLogin {t(
? t( "msg.dev.clients.details.secret_not_applicable",
"msg.dev.clients.details.secret_not_applicable_headless", "PKCE 앱에는 Client Secret이 없습니다.",
"이 앱은 Headless Login용 signed key 인증을 사용하므로 Client Secret을 사용하지 않습니다.", )}
)
: t(
"msg.dev.clients.details.secret_not_applicable",
"PKCE 앱에는 Client Secret이 없습니다.",
)}
</p> </p>
) : null} ) : null}
</div> </div>

View File

@@ -1,18 +1,18 @@
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";
ArrowDown,
ArrowUp,
ArrowUpDown,
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";
import {
SortableTableHead,
sortableTableHeadBaseClassName,
sortableTableHeaderClassName,
} from "../../../../common/core/components/sort";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
import { import {
type SortConfig, type SortConfig,
type SortResolverMap, type SortResolverMap,
@@ -123,7 +123,10 @@ function ClientsPage() {
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [sortConfig, setSortConfig] = const [sortConfig, setSortConfig] =
useState<SortConfig<ClientSortKey> | null>(null); useState<SortConfig<ClientSortKey> | null>({
key: "createdAt",
direction: "desc",
});
const clients = data?.items || []; const clients = data?.items || [];
const clientSortResolvers = useMemo< const clientSortResolvers = useMemo<
@@ -230,18 +233,6 @@ function ClientsPage() {
setSortConfig((current) => toggleSort(current, key)); setSortConfig((current) => toggleSort(current, key));
}; };
const getSortIcon = (key: ClientSortKey) => {
if (!sortConfig || sortConfig.key !== key) {
return <ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />;
}
return sortConfig.direction === "asc" ? (
<ArrowUp className="ml-1 h-4 w-4" />
) : (
<ArrowDown className="ml-1 h-4 w-4" />
);
};
if (auth.isLoading || !hasAccessToken || isLoading) { if (auth.isLoading || !hasAccessToken || isLoading) {
return ( return (
<div className="p-8 text-center"> <div className="p-8 text-center">
@@ -433,246 +424,235 @@ function ClientsPage() {
</Card> </Card>
<Card className="glass-panel"> <Card className="glass-panel">
<CardHeader className="pb-0"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div className="flex items-center justify-between"> <div>
<CardTitle className="text-xl font-semibold"> <CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.list.title", "클라이언트 목록")} {t("ui.dev.clients.list.title", "클라이언트 목록")}
</CardTitle> </CardTitle>
{canCreateClient && ( <CardDescription>
<div className="flex items-center gap-2 md:hidden">
<Button size="sm" onClick={() => navigate("/clients/new")}>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("application")}
>
<div className="flex items-center">
{t("ui.dev.clients.table.application", "애플리케이션")}
{getSortIcon("application")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("id")}
>
<div className="flex items-center">
{t("ui.dev.clients.table.client_id", "Client ID")}
{getSortIcon("id")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("type")}
>
<div className="flex items-center">
{t("ui.dev.clients.table.type", "유형")}
{getSortIcon("type")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("status")}
>
<div className="flex items-center">
{t("ui.dev.clients.table.status", "상태")}
{getSortIcon("status")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("createdAt")}
>
<div className="flex items-center">
{t("ui.dev.clients.table.created_at", "생성일")}
{getSortIcon("createdAt")}
</div>
</TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!hasFilterResult && (
<TableRow>
<TableCell
colSpan={6}
className="h-32 text-center text-muted-foreground"
>
<div className="space-y-1">
<p className="font-medium text-foreground">
{isFilteredOut
? t(
"msg.dev.clients.empty_filtered",
"조건에 맞는 연동 앱이 없습니다.",
)
: canCreateClient
? t(
"msg.dev.clients.empty_can_create",
"아직 등록된 연동 앱이 없습니다.",
)
: isDeveloperRequestPending
? t(
"msg.dev.clients.empty_pending",
"개발자 권한 신청을 검토 중입니다.",
)
: t(
"msg.dev.clients.empty",
"조회 가능한 RP가 없습니다.",
)}
</p>
<div className="text-sm space-y-2">
<p className="text-muted-foreground">
{isFilteredOut
? t(
"msg.dev.clients.empty_filtered_detail",
"검색어나 필터 조건을 변경해 보세요.",
)
: canCreateClient
? t(
"msg.dev.clients.empty_can_create_detail",
"연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.",
)
: isDeveloperRequestPending
? t(
"msg.dev.clients.empty_pending_detail",
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
)
: t(
"msg.dev.clients.empty_detail",
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
)}
</p>
{!isFilteredOut && canCreateClient && (
<button
type="button"
className="text-primary font-bold hover:underline"
onClick={() => navigate("/clients/new")}
>
{t("ui.dev.clients.new", "연동 앱 추가")}
</button>
)}
{!isFilteredOut && canRequestDeveloperAccess && (
<button
type="button"
className="text-primary font-bold hover:underline"
onClick={() => navigate("/developer-requests")}
>
{t(
"ui.dev.welcome.btn_request",
"개발자 등록 신청하기",
)}
</button>
)}
</div>
</div>
</TableCell>
</TableRow>
)}
{filteredClients.map((client) => (
<TableRow key={client.id} className="bg-card/40">
<TableCell>
<Link
to={`/clients/${client.id}`}
className="flex items-center gap-3 transition-colors hover:text-primary"
>
<ClientLogo client={client} />
<div>
<p className="font-semibold">
{client.name ||
t("ui.dev.clients.untitled", "Untitled")}
</p>
<p className="text-xs text-muted-foreground">
{t("ui.dev.clients.tenant_scoped", "Tenant-scoped")}
</p>
</div>
</Link>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{client.id}
</code>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={
client.type === "private" ||
client.metadata?.headless_login_enabled
? "success"
: "muted"
}
>
{client.metadata?.headless_login_enabled
? t(
"ui.dev.clients.type.private_headless",
"Server side App (Headless Login)",
)
: client.type === "private"
? t(
"ui.dev.clients.type.private",
"Server side App",
)
: t("ui.dev.clients.type.pkce", "PKCE")}
</Badge>
</div>
</TableCell>
<TableCell>
<Badge
variant={client.status === "active" ? "info" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{client.status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{client.createdAt
? new Date(client.createdAt).toLocaleDateString()
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to={`/clients/${client.id}`}>
{t("ui.common.view", "View")}
</Link>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 flex items-center justify-between rounded-xl border border-border/60 bg-secondary/60 px-4 py-3 text-sm text-muted-foreground">
<span>
{t( {t(
"msg.dev.clients.showing", "msg.dev.clients.showing",
"Showing {{shown}} of {{total}} clients", " {{shown}}개의 애플리케이션이 등록되어 있습니다.",
{ shown: filteredClients.length, total: totalClients }, { shown: totalClients },
)} )}
</span> </CardDescription>
<div className="flex gap-2"> </div>
<Button variant="outline" size="sm" disabled> {canCreateClient && (
{t("ui.common.previous", "Previous")} <div className="flex items-center gap-2 md:hidden">
</Button> <Button size="sm" onClick={() => navigate("/clients/new")}>
<Button variant="outline" size="sm" disabled> <Plus className="h-4 w-4" />
{t("ui.common.next", "Next")} {t("ui.dev.clients.new", "새 클라이언트")}
</Button> </Button>
</div> </div>
)}
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table className="min-w-[1180px]">
<TableHeader className={sortableTableHeaderClassName}>
<TableRow>
<SortableTableHead
label={t(
"ui.dev.clients.table.application",
"애플리케이션",
)}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="application"
/>
<SortableTableHead
label={t("ui.dev.clients.table.client_id", "Client ID")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="id"
/>
<SortableTableHead
label={t("ui.dev.clients.table.type", "유형")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="type"
/>
<SortableTableHead
label={t("ui.dev.clients.table.status", "상태")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="status"
/>
<SortableTableHead
label={t("ui.dev.clients.table.created_at", "생성일")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="createdAt"
/>
<TableHead
className={`${sortableTableHeadBaseClassName} text-right`}
>
{t("ui.dev.clients.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!hasFilterResult && (
<TableRow>
<TableCell
colSpan={6}
className="h-32 text-center text-muted-foreground"
>
<div className="space-y-1">
<p className="font-medium text-foreground">
{isFilteredOut
? t(
"msg.dev.clients.empty_filtered",
"조건에 맞는 연동 앱이 없습니다.",
)
: canCreateClient
? t(
"msg.dev.clients.empty_can_create",
"아직 등록된 연동 앱이 없습니다.",
)
: isDeveloperRequestPending
? t(
"msg.dev.clients.empty_pending",
"개발자 권한 신청을 검토 중입니다.",
)
: t(
"msg.dev.clients.empty",
"조회 가능한 RP가 없습니다.",
)}
</p>
<div className="text-sm space-y-2">
<p className="text-muted-foreground">
{isFilteredOut
? t(
"msg.dev.clients.empty_filtered_detail",
"검색어나 필터 조건을 변경해 보세요.",
)
: canCreateClient
? t(
"msg.dev.clients.empty_can_create_detail",
"연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.",
)
: isDeveloperRequestPending
? t(
"msg.dev.clients.empty_pending_detail",
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
)
: t(
"msg.dev.clients.empty_detail",
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
)}
</p>
{!isFilteredOut && canCreateClient && (
<button
type="button"
className="text-primary font-bold hover:underline"
onClick={() => navigate("/clients/new")}
>
{t("ui.dev.clients.new", "연동 앱 추가")}
</button>
)}
{!isFilteredOut && canRequestDeveloperAccess && (
<button
type="button"
className="text-primary font-bold hover:underline"
onClick={() => navigate("/developer-requests")}
>
{t(
"ui.dev.welcome.btn_request",
"개발자 등록 신청하기",
)}
</button>
)}
</div>
</div>
</TableCell>
</TableRow>
)}
{filteredClients.map((client) => (
<TableRow key={client.id}>
<TableCell>
<Link
to={`/clients/${client.id}`}
className="flex items-center gap-3 transition-colors hover:text-primary"
>
<ClientLogo client={client} />
<div>
<p className="font-semibold">
{client.name ||
t("ui.dev.clients.untitled", "Untitled")}
</p>
<p className="text-xs text-muted-foreground">
{t(
"ui.dev.clients.tenant_scoped",
"Tenant-scoped",
)}
</p>
</div>
</Link>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{client.id}
</code>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={
client.type === "private" ||
client.metadata?.headless_login_enabled
? "success"
: "muted"
}
>
{client.metadata?.headless_login_enabled
? t(
"ui.dev.clients.type.private_headless",
"Server side App (Headless Login)",
)
: client.type === "private"
? t(
"ui.dev.clients.type.private",
"Server side App",
)
: t("ui.dev.clients.type.pkce", "PKCE")}
</Badge>
</div>
</TableCell>
<TableCell>
<Badge
variant={
client.status === "active" ? "info" : "muted"
}
className="px-3 py-1 text-xs uppercase"
>
{client.status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{client.createdAt
? new Date(client.createdAt).toLocaleDateString()
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to={`/clients/${client.id}`}>
{t("ui.common.view", "View")}
</Link>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { canDisplayClientSecret } from "./clientSecretPolicy";
describe("client secret policy", () => {
it("allows client secret display for server-side apps", () => {
expect(
canDisplayClientSecret({
type: "private",
}),
).toBe(true);
});
it("still allows client secret display for server-side apps even when headless login is enabled in metadata", () => {
expect(
canDisplayClientSecret({
type: "private",
}),
).toBe(true);
});
it("does not allow client secret display for PKCE apps", () => {
expect(
canDisplayClientSecret({
type: "pkce",
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,7 @@
type ClientSecretPolicyTarget = {
type: string;
};
export function canDisplayClientSecret(client: ClientSecretPolicyTarget) {
return client.type === "private";
}

View File

@@ -633,7 +633,7 @@ function DashboardPage() {
<div className="rounded-xl border border-border/60 bg-card p-8 text-center"> <div className="rounded-xl border border-border/60 bg-card p-8 text-center">
<div className="space-y-3"> <div className="space-y-3">
<h2 className="text-2xl font-semibold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">
{t("ui.dev.nav.overview", "개요")} {t("ui.dev.dashboard.title", "대시보드")}
</h2> </h2>
<p className="font-medium text-foreground"> <p className="font-medium text-foreground">
{isDeveloperRequestPending {isDeveloperRequestPending
@@ -643,7 +643,7 @@ function DashboardPage() {
) )
: t( : t(
"msg.dev.dashboard.access_denied", "msg.dev.dashboard.access_denied",
"개요는 개발자 권한이 있어야 볼 수 있습니다.", "대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
)} )}
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -76,9 +76,13 @@ export function canStartBrowserPkceLogin({
origin = window.location.origin, origin = window.location.origin,
cryptoSubtleAvailable = Boolean(window.crypto?.subtle), cryptoSubtleAvailable = Boolean(window.crypto?.subtle),
}: BrowserPkceLoginCheck = {}) { }: BrowserPkceLoginCheck = {}) {
if (!cryptoSubtleAvailable) {
return false;
}
if (isSecureContext) { if (isSecureContext) {
return true; return true;
} }
return isDevTrustedPkceOrigin(origin) && cryptoSubtleAvailable; return isDevTrustedPkceOrigin(origin);
} }

View File

@@ -331,7 +331,7 @@ desc = "Please enter the reason for your request. It will be approved after admi
[msg.dev.clients] [msg.dev.clients]
load_error = "Error loading clients: {{error}}" load_error = "Error loading clients: {{error}}"
loading = "Loading apps..." loading = "Loading apps..."
showing = "Showing {{shown}} of {{total}} apps" showing = "A total of {{shown}} applications are registered."
deleted = "App deleted." deleted = "App deleted."
delete_error = "Failed to delete: {{error}}" delete_error = "Failed to delete: {{error}}"
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone." delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
@@ -500,7 +500,7 @@ openid = "Openid"
profile = "Profile" profile = "Profile"
[msg.dev.dashboard] [msg.dev.dashboard]
access_denied = "Overview is available only to users with developer access." access_denied = "The dashboard is available only to users with developer access."
access_denied_detail = "Submit a request on the developer access page and wait for approval." access_denied_detail = "Submit a request on the developer access page and wait for approval."
access_pending = "Your developer access request is under review." access_pending = "Your developer access request is under review."
access_pending_detail = "You can use the overview and developer features after a super admin approves it." access_pending_detail = "You can use the overview and developer features after a super admin approves it."

View File

@@ -342,7 +342,7 @@ empty_pending = "개발자 권한 신청을 검토 중입니다."
empty_pending_detail = "super admin이 승인하면 연동 앱을 추가할 수 있습니다." empty_pending_detail = "super admin이 승인하면 연동 앱을 추가할 수 있습니다."
load_error = "앱 정보를 불러오지 못했습니다: {{error}}" load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
loading = "앱 정보를 불러오는 중..." loading = "앱 정보를 불러오는 중..."
showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다." showing = "총 {{shown}}개의 애플리케이션이 등록되어 있습니다."
[msg.dev.clients.consents] [msg.dev.clients.consents]
empty = "조회된 동의 내역이 없습니다." empty = "조회된 동의 내역이 없습니다."
@@ -500,7 +500,7 @@ openid = "OIDC 인증 필수 스코프"
profile = "기본 프로필 정보 접근" profile = "기본 프로필 정보 접근"
[msg.dev.dashboard] [msg.dev.dashboard]
access_denied = "개요는 개발자 권한이 있어야 볼 수 있습니다." access_denied = "대시보드는 개발자 권한이 있어야 볼 수 있습니다."
access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요." access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요."
access_pending = "개발자 권한 신청을 검토 중입니다." access_pending = "개발자 권한 신청을 검토 중입니다."
access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다." access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다."
@@ -1956,7 +1956,7 @@ users = "사용자"
unknown_name = "알 수 없는 사용자" unknown_name = "알 수 없는 사용자"
unknown_email = "unknown@example.com" unknown_email = "unknown@example.com"
menu_aria = "계정 메뉴 열기" menu_aria = "계정 메뉴 열기"
menu_title = "Account" menu_title = "계정"
title = "내 정보" title = "내 정보"
subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다." subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다."
loading = "프로필 정보를 불러오는 중..." loading = "프로필 정보를 불러오는 중..."

View File

@@ -4,17 +4,6 @@ test.describe("DevFront login", () => {
test("shows a clear error instead of silently failing when PKCE cannot run", async ({ test("shows a clear error instead of silently failing when PKCE cannot run", async ({
page, page,
}) => { }) => {
await page.addInitScript(() => {
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: false,
});
Object.defineProperty(window.crypto, "subtle", {
configurable: true,
value: undefined,
});
});
let authorizeRequested = false; let authorizeRequested = false;
await page.route( await page.route(
"**/oidc/.well-known/openid-configuration", "**/oidc/.well-known/openid-configuration",
@@ -39,9 +28,9 @@ test.describe("DevFront login", () => {
}); });
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "SSO 계정으로 로그인" }).click(); await expect(
page.getByRole("button", { name: "SSO 계정으로 로그인" }),
await expect(page.getByRole("alert")).toContainText("HTTPS 또는 localhost"); ).toBeVisible();
expect(authorizeRequested).toBe(false); expect(authorizeRequested).toBe(false);
}); });
}); });

View File

@@ -15,6 +15,13 @@
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"lucide-react": ["./node_modules/lucide-react"],
"react": ["./node_modules/@types/react/index.d.ts"],
"react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime.d.ts"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"]
},
/* Linting */ /* Linting */
"strict": true, "strict": true,
@@ -24,6 +31,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"], "include": ["src", "../common/**/*.ts", "../common/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
} }

View File

@@ -1,4 +1,5 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "node:path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
const buildOutDir = const buildOutDir =
@@ -35,6 +36,20 @@ const allowedHosts = Array.from(
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
"lucide-react": path.resolve(__dirname, "node_modules/lucide-react"),
react: path.resolve(__dirname, "node_modules/react"),
"react/jsx-dev-runtime": path.resolve(
__dirname,
"node_modules/react/jsx-dev-runtime.js",
),
"react/jsx-runtime": path.resolve(
__dirname,
"node_modules/react/jsx-runtime.js",
),
},
},
cacheDir: cacheDir:
process.env.DEVFRONT_VITE_CACHE_DIR ?? "/tmp/baron-sso-devfront-vite-cache", process.env.DEVFRONT_VITE_CACHE_DIR ?? "/tmp/baron-sso-devfront-vite-cache",
build: { build: {

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

@@ -1,16 +1,23 @@
import * as React from "react"; import * as React from "react";
import {
commonTableBodyClass,
commonTableCaptionClass,
commonTableCellClass,
commonTableClass,
commonTableFooterClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
const Table = React.forwardRef< const Table = React.forwardRef<
HTMLTableElement, HTMLTableElement,
React.HTMLAttributes<HTMLTableElement> React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto"> <div className={commonTableWrapperClass}>
<table <table ref={ref} className={cn(commonTableClass, className)} {...props} />
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div> </div>
)); ));
Table.displayName = "Table"; Table.displayName = "Table";
@@ -19,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("[&_tr]:border-b", className)} {...props} /> <thead
ref={ref}
className={cn(commonTableHeaderClass, className)}
{...props}
/>
)); ));
TableHeader.displayName = "TableHeader"; TableHeader.displayName = "TableHeader";
@@ -27,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("[&_tr:last-child]:border-0", className)}
{...props}
/>
)); ));
TableBody.displayName = "TableBody"; TableBody.displayName = "TableBody";
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tfoot <tfoot
ref={ref} ref={ref}
className={cn("bg-muted/50 font-medium text-foreground", className)} className={cn(commonTableFooterClass, className)}
{...props} {...props}
/> />
)); ));
@@ -51,14 +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(
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
)); ));
TableRow.displayName = "TableRow"; TableRow.displayName = "TableRow";
@@ -66,14 +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(
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
className,
)}
{...props}
/>
)); ));
TableHead.displayName = "TableHead"; TableHead.displayName = "TableHead";
@@ -81,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("p-6 align-middle text-sm", className)}
{...props}
/>
)); ));
TableCell.displayName = "TableCell"; TableCell.displayName = "TableCell";
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<caption <caption
ref={ref} ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn(commonTableCaptionClass, className)}
{...props} {...props}
/> />
)); ));

View File

@@ -15,6 +15,12 @@
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"react": ["./node_modules/@types/react/index.d.ts"],
"react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime.d.ts"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"]
},
/* Linting */ /* Linting */
"strict": true, "strict": true,
@@ -24,6 +30,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"], "include": ["src", "../common/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
} }

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

@@ -599,7 +599,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"
@@ -692,3 +692,4 @@ toggle_label = "Show active sessions only"
[msg.userfront.audit.filter] [msg.userfront.audit.filter]
description = "Toggle to view only active sessions." description = "Toggle to view only active sessions."

View File

@@ -821,7 +821,7 @@ department = "소속"
email = "이메일" email = "이메일"
name = "이름" name = "이름"
tenant = "소속 테넌트" tenant = "소속 테넌트"
tenant_slug = "테넌트 slug" tenant_slug = "테넌트 Slug"
[ui.userfront.profile.password] [ui.userfront.profile.password]
change = "비밀번호 변경" change = "비밀번호 변경"
@@ -913,3 +913,4 @@ toggle_label = "활성 세션만 보기"
[msg.userfront.audit.filter] [msg.userfront.audit.filter]
description = "활성화된 세션만 보려면 토글을 켜주세요." description = "활성화된 세션만 보려면 토글을 켜주세요."

View File

@@ -885,3 +885,4 @@ toggle_label = ""
[msg.userfront.audit.filter] [msg.userfront.audit.filter]
description = "" description = ""

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: