forked from baron/baron-sso
Merge branch 'dev' into feature/tenant-user-list-ui-improvement
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
169
common/core/components/sort/SortableTableHead.tsx
Normal file
169
common/core/components/sort/SortableTableHead.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
common/core/components/sort/index.ts
Normal file
1
common/core/components/sort/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./SortableTableHead";
|
||||
@@ -15,6 +15,7 @@ apply = "Apply"
|
||||
actions = "Actions"
|
||||
add = "Add"
|
||||
all = "All"
|
||||
apply = "Apply"
|
||||
admin_only = "Admin Only"
|
||||
apply = "Apply"
|
||||
approve = "Approve"
|
||||
|
||||
@@ -15,6 +15,7 @@ apply = "적용"
|
||||
actions = "액션"
|
||||
add = "추가"
|
||||
all = "전체"
|
||||
apply = "적용"
|
||||
admin_only = "관리자 전용"
|
||||
apply = "적용"
|
||||
approve = "승인"
|
||||
|
||||
@@ -15,6 +15,7 @@ apply = "Apply"
|
||||
actions = ""
|
||||
add = ""
|
||||
all = ""
|
||||
apply = ""
|
||||
admin_only = ""
|
||||
apply = ""
|
||||
approve = ""
|
||||
|
||||
15
common/ui/table.ts
Normal file
15
common/ui/table.ts
Normal 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";
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
28
devfront/src/features/clients/clientSecretPolicy.test.ts
Normal file
28
devfront/src/features/clients/clientSecretPolicy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
7
devfront/src/features/clients/clientSecretPolicy.ts
Normal file
7
devfront/src/features/clients/clientSecretPolicy.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
type ClientSecretPolicyTarget = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export function canDisplayClientSecret(client: ClientSecretPolicyTarget) {
|
||||
return client.type === "private";
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 = "프로필 정보를 불러오는 중..."
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "비밀번호 변경"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -821,7 +821,7 @@ department = "소속"
|
||||
email = "이메일"
|
||||
name = "이름"
|
||||
tenant = "소속 테넌트"
|
||||
tenant_slug = "테넌트 slug"
|
||||
tenant_slug = "테넌트 Slug"
|
||||
|
||||
[ui.userfront.profile.password]
|
||||
change = "비밀번호 변경"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user