1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/tenant-user-list-ui-improvement

This commit is contained in:
2026-05-14 11:23:57 +09:00
37 changed files with 1080 additions and 765 deletions

View File

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

View File

@@ -27,6 +27,10 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
import type { AuditLog } from "../../lib/adminApi";
import { fetchAuditLogs } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
@@ -254,8 +258,8 @@ function AuditLogsPage() {
))
)}
</div>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>

View File

@@ -29,10 +29,19 @@ import {
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import {
type SortConfig,
type SortResolverMap,
SortableTableHead,
sortableTableHeadBaseClassName,
sortableTableHeaderClassName,
} from "../../../../../common/core/components/sort";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../../common/ui/table";
import {
sortItems,
toggleSort,
type SortConfig,
type SortResolverMap,
} from "../../../../../common/core/utils";
import { RoleGuard } from "../../../components/auth/RoleGuard";
import { Badge } from "../../../components/ui/badge";
@@ -257,7 +266,10 @@ function TenantListPage() {
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
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 [importMessage, setImportMessage] = React.useState("");
const [previewRows, setPreviewRows] = React.useState<
@@ -444,9 +456,8 @@ function TenantListPage() {
}
if (
profile &&
profileRole === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) <= 1
(profile?.manageableTenants?.length ?? 0) <= 1
) {
return null;
}
@@ -487,17 +498,6 @@ function TenantListPage() {
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(
() => allTenants.filter((tenant) => !isSeedTenant(tenant)),
[allTenants],

View File

@@ -24,6 +24,15 @@ import {
sortItems,
toggleSort,
} 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 {
Card,
@@ -294,17 +303,6 @@ function UserListPage() {
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 totalPages = Math.ceil(total / limit);
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
@@ -603,12 +601,14 @@ function UserListPage() {
</div>
)}
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableHeader className={sortableTableHeaderClassName}>
<TableRow>
<TableHead className="w-12">
<TableHead
className={`${sortableTableHeadBaseClassName} w-12`}
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
@@ -689,34 +689,30 @@ function UserListPage() {
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<TableHead
<SortableTableHead
key={field.key}
className="whitespace-nowrap uppercase cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort(field.key)}
>
<div className="flex items-center">
{field.label}
{getSortIcon(field.key)}
</div>
</TableHead>
className="whitespace-nowrap"
label={field.label}
onSort={requestSort}
sortConfig={sortConfig}
sortKey={field.key}
/>
),
)}
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("createdAt")}
>
<div className="flex items-center">
{t("ui.admin.users.list.table.created", "CREATED")}
{getSortIcon("createdAt")}
</div>
</TableHead>
<SortableTableHead
className="whitespace-nowrap"
label={t("ui.admin.users.list.table.created", "CREATED")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="createdAt"
/>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell
colSpan={6 + userSchema.length}
colSpan={7 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")}
@@ -726,7 +722,7 @@ function UserListPage() {
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell
colSpan={6 + userSchema.length}
colSpan={7 + userSchema.length}
className="h-24 text-center"
>
{t(

View File

@@ -15,6 +15,13 @@
"moduleDetection": "force",
"noEmit": true,
"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 */
"strict": true,
@@ -24,6 +31,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"include": ["src", "../common/**/*.ts", "../common/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@@ -1,4 +1,5 @@
import react from "@vitejs/plugin-react";
import path from "node:path";
import { defineConfig } from "vite";
const buildOutDir =
@@ -6,6 +7,20 @@ const buildOutDir =
export default defineConfig({
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_"],
cacheDir:
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

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

View File

@@ -15,6 +15,7 @@ apply = "적용"
actions = "액션"
add = "추가"
all = "전체"
apply = "적용"
admin_only = "관리자 전용"
apply = "적용"
approve = "승인"

View File

@@ -15,6 +15,7 @@ apply = "Apply"
actions = ""
add = ""
all = ""
apply = ""
admin_only = ""
apply = ""
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 {
commonTableBodyClass,
commonTableCaptionClass,
commonTableCellClass,
commonTableClass,
commonTableFooterClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { cn } from "../../lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
<div className={commonTableWrapperClass}>
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
</div>
));
Table.displayName = "Table";
@@ -19,7 +26,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead
ref={ref}
className={cn(commonTableHeaderClass, className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
@@ -27,11 +38,7 @@ const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
));
TableBody.displayName = "TableBody";
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-muted/50 font-medium text-foreground", className)}
className={cn(commonTableFooterClass, className)}
{...props}
/>
));
@@ -51,14 +58,7 @@ const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
));
TableRow.displayName = "TableRow";
@@ -66,14 +66,7 @@ const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
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}
/>
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
));
TableHead.displayName = "TableHead";
@@ -81,11 +74,7 @@ const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-6 align-middle text-sm", className)}
{...props}
/>
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
));
TableCell.displayName = "TableCell";
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
className={cn(commonTableCaptionClass, className)}
{...props}
/>
));

View File

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

View File

@@ -16,6 +16,11 @@ import { canStartBrowserPkceLogin } from "../../lib/authConfig";
const insecurePkceMessage =
"이 주소에서는 브라우저 보안 정책 때문에 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() {
const auth = useAuth();
const navigate = useNavigate();
@@ -55,11 +60,19 @@ function LoginPage() {
}
autoStartedRef.current = true;
void auth.signinRedirect({
state: {
returnTo,
},
});
void auth
.signinRedirect({
state: {
returnTo,
},
})
.catch((error) => {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Auto login redirect failed", error);
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = async () => {
@@ -75,6 +88,10 @@ function LoginPage() {
},
});
} catch (error) {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Redirect login failed", error);
}
};

View File

@@ -28,6 +28,10 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
@@ -272,7 +276,7 @@ function ClientConsentsPage() {
</header>
<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 gap-4 flex-1">
<div className="relative w-full max-w-md">
@@ -430,146 +434,159 @@ function ClientConsentsPage() {
</CardContent>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.dev.clients.consents.table.user", "User")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.tenant", "Tenant")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.status", "Status")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.scopes", "Granted Scopes")}
</TableHead>
<TableHead>
{t(
"ui.dev.clients.consents.table.first_granted",
"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>
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.dev.clients.consents.table.user", "User")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.tenant", "Tenant")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.status", "Status")}
</TableHead>
<TableHead>
{t(
"ui.dev.clients.consents.table.scopes",
"Granted Scopes",
)}
</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()
) : (
"-"
</TableHead>
<TableHead>
{t(
"ui.dev.clients.consents.table.first_granted",
"First Granted",
)}
</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>
</TableHead>
<TableHead>
{t(
"ui.dev.clients.consents.table.last_auth",
"Last Authenticated / Revoked",
)}
</TableCell>
</TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.consents.table.action", "Action")}
</TableHead>
</TableRow>
))
)}
</TableBody>
</Table>
</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>
<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">
<p>
{t(

View File

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

View File

@@ -1,18 +1,18 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
BookOpenText,
Filter,
Plus,
Search,
X,
} from "lucide-react";
import { BookOpenText, Filter, Plus, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate } from "react-router-dom";
import {
SortableTableHead,
sortableTableHeadBaseClassName,
sortableTableHeaderClassName,
} from "../../../../common/core/components/sort";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
import {
type SortConfig,
type SortResolverMap,
@@ -123,7 +123,10 @@ function ClientsPage() {
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [sortConfig, setSortConfig] =
useState<SortConfig<ClientSortKey> | null>(null);
useState<SortConfig<ClientSortKey> | null>({
key: "createdAt",
direction: "desc",
});
const clients = data?.items || [];
const clientSortResolvers = useMemo<
@@ -230,18 +233,6 @@ function ClientsPage() {
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) {
return (
<div className="p-8 text-center">
@@ -433,246 +424,235 @@ function ClientsPage() {
</Card>
<Card className="glass-panel">
<CardHeader className="pb-0">
<div className="flex items-center justify-between">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.list.title", "클라이언트 목록")}
</CardTitle>
{canCreateClient && (
<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>
<CardDescription>
{t(
"msg.dev.clients.showing",
"Showing {{shown}} of {{total}} clients",
{ shown: filteredClients.length, total: totalClients },
" {{shown}}개의 애플리케이션이 등록되어 있습니다.",
{ shown: totalClients },
)}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled>
{t("ui.common.previous", "Previous")}
</Button>
<Button variant="outline" size="sm" disabled>
{t("ui.common.next", "Next")}
</CardDescription>
</div>
{canCreateClient && (
<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>
)}
</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>
</CardContent>
</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="space-y-3">
<h2 className="text-2xl font-semibold tracking-tight">
{t("ui.dev.nav.overview", "개요")}
{t("ui.dev.dashboard.title", "대시보드")}
</h2>
<p className="font-medium text-foreground">
{isDeveloperRequestPending
@@ -643,7 +643,7 @@ function DashboardPage() {
)
: t(
"msg.dev.dashboard.access_denied",
"개요는 개발자 권한이 있어야 볼 수 있습니다.",
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
)}
</p>
<p className="text-sm text-muted-foreground">

View File

@@ -76,9 +76,13 @@ export function canStartBrowserPkceLogin({
origin = window.location.origin,
cryptoSubtleAvailable = Boolean(window.crypto?.subtle),
}: BrowserPkceLoginCheck = {}) {
if (!cryptoSubtleAvailable) {
return false;
}
if (isSecureContext) {
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]
load_error = "Error loading clients: {{error}}"
loading = "Loading apps..."
showing = "Showing {{shown}} of {{total}} apps"
showing = "A total of {{shown}} applications are registered."
deleted = "App deleted."
delete_error = "Failed to delete: {{error}}"
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
@@ -500,7 +500,7 @@ openid = "Openid"
profile = "Profile"
[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_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."

View File

@@ -342,7 +342,7 @@ empty_pending = "개발자 권한 신청을 검토 중입니다."
empty_pending_detail = "super admin이 승인하면 연동 앱을 추가할 수 있습니다."
load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
loading = "앱 정보를 불러오는 중..."
showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다."
showing = "총 {{shown}}개의 애플리케이션이 등록되어 있습니다."
[msg.dev.clients.consents]
empty = "조회된 동의 내역이 없습니다."
@@ -500,7 +500,7 @@ openid = "OIDC 인증 필수 스코프"
profile = "기본 프로필 정보 접근"
[msg.dev.dashboard]
access_denied = "개요는 개발자 권한이 있어야 볼 수 있습니다."
access_denied = "대시보드는 개발자 권한이 있어야 볼 수 있습니다."
access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요."
access_pending = "개발자 권한 신청을 검토 중입니다."
access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다."
@@ -1956,7 +1956,7 @@ users = "사용자"
unknown_name = "알 수 없는 사용자"
unknown_email = "unknown@example.com"
menu_aria = "계정 메뉴 열기"
menu_title = "Account"
menu_title = "계정"
title = "내 정보"
subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다."
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 ({
page,
}) => {
await page.addInitScript(() => {
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: false,
});
Object.defineProperty(window.crypto, "subtle", {
configurable: true,
value: undefined,
});
});
let authorizeRequested = false;
await page.route(
"**/oidc/.well-known/openid-configuration",
@@ -39,9 +28,9 @@ test.describe("DevFront login", () => {
});
await page.goto("/login");
await page.getByRole("button", { name: "SSO 계정으로 로그인" }).click();
await expect(page.getByRole("alert")).toContainText("HTTPS 또는 localhost");
await expect(
page.getByRole("button", { name: "SSO 계정으로 로그인" }),
).toBeVisible();
expect(authorizeRequested).toBe(false);
});
});

View File

@@ -15,6 +15,13 @@
"moduleDetection": "force",
"noEmit": true,
"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 */
"strict": true,
@@ -24,6 +31,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"include": ["src", "../common/**/*.ts", "../common/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@@ -1,4 +1,5 @@
import react from "@vitejs/plugin-react";
import path from "node:path";
import { defineConfig } from "vite";
const buildOutDir =
@@ -35,6 +36,20 @@ const allowedHosts = Array.from(
export default defineConfig({
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:
process.env.DEVFRONT_VITE_CACHE_DIR ?? "/tmp/baron-sso-devfront-vite-cache",
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."
[msg.admin.api_keys.list]
edit_scopes_desc = "Keep the CLIENT_ID unchanged and modify scopes only."
rotate_confirm = "API key \"{{name}}\"'s secret will be rotated. The existing secret will no longer work."
rotate_secret_notice = "The new secret is shown only once. The CLIENT_ID has not changed."
delete_confirm = "Are you sure you want to delete this API key?"
empty = "No API keys have been issued yet."
fetch_error = "Failed to load the API key list."
@@ -188,6 +191,7 @@ description = "Jump to the most frequently used administrative workflows."
audit_events_24h = "24h Audit Events"
oidc_clients = "OIDC Clients"
policy_gate = "Policy Gate Status"
total_users = "Total Users"
total_tenants = "Total Tenants"
[msg.admin.tenants]
@@ -197,6 +201,7 @@ delete_confirm = "Delete Tenant \\\\\\\"{{name}}\\\\\\\"?"
delete_success = "Tenant deleted."
empty = "No tenants have been registered yet."
fetch_error = "Failed to load the tenant list."
export_error = "Failed to export tenants."
import_empty = "There are no tenant rows to import."
import_error = "Failed to import tenants."
import_result = "Created {{created}}, updated {{updated}}, failed {{failed}}"
@@ -283,6 +288,8 @@ move_success = "{{count}} users moved successfully."
parsed_count = "Parsed {{count}} rows."
schema_incompatible = "Fields not in target schema may be lost:"
schema_missing = "Missing required fields for target tenant:"
status_placeholder = "Select status"
permission_placeholder = "Select permission"
update_success = "User info updated successfully."
[msg.admin.users.create]
@@ -970,6 +977,10 @@ title = "API Key Created"
[ui.admin.api_keys.list]
add = "Add"
edit_scopes = "Edit Scopes"
rotate_secret = "Rotate Secret"
rotate_secret_done = "Secret Rotated"
save_scopes = "Save Scopes"
title = "API Key Management"
[ui.admin.api_keys.list.breadcrumb]
@@ -1116,6 +1127,7 @@ view_audit_logs = "View Audit Logs"
audit_events_24h = "24h Events"
oidc_clients = "OIDC Clients"
policy_gate = "Policy Gate"
total_users = "Total Users"
total_tenants = "Total Tenants"
[ui.admin.profile]
@@ -1378,6 +1390,7 @@ add = "Add"
add_dialog_desc = "Select a tenant to add as a sub-tenant."
add_dialog_title = "Add Sub-tenant"
add_existing = "Add Existing Tenant"
export = "Subtree CSV"
manage = "Manage"
no_candidates = "No available tenants to add."
search_placeholder = "Search..."
@@ -1399,6 +1412,7 @@ slug = "SLUG"
status = "STATUS"
type = "TYPE"
updated = "UPDATED"
created = "CREATED"
[ui.admin.users]
@@ -1416,6 +1430,8 @@ selected_count = "{{count}} users selected"
start_upload = "Start Upload"
tenant_resolution = "Tenant mapping"
title = "Bulk Actions"
status_placeholder = "Select status"
permission_placeholder = "Select permission"
[ui.admin.users.create]
back = "Back"
@@ -2332,6 +2348,7 @@ title = "User Info"
[ui.dev.profile.org]
company_code = "Company Code"
tenant = "Tenant"
tenant_slug = "Tenant Slug"
title = "Organization Info"
[ui.dev.profile.role]
@@ -2514,7 +2531,7 @@ department = "Department"
email = "Email"
name = "Name"
tenant = "Tenant"
tenant_slug = "Tenant slug"
tenant_slug = "Tenant Slug"
[ui.userfront.profile.password]
change = "Change"

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,12 @@
"moduleDetection": "force",
"noEmit": true,
"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 */
"strict": true,
@@ -24,6 +30,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"include": ["src", "../common/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

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

View File

@@ -599,7 +599,7 @@ department = "Department"
email = "Email"
name = "Name"
tenant = "Tenant"
tenant_slug = "Tenant slug"
tenant_slug = "Tenant Slug"
[ui.userfront.profile.password]
change = "Change"

View File

@@ -821,7 +821,7 @@ department = "소속"
email = "이메일"
name = "이름"
tenant = "소속 테넌트"
tenant_slug = "테넌트 slug"
tenant_slug = "테넌트 Slug"
[ui.userfront.profile.password]
change = "비밀번호 변경"

View File

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