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 * as React from "react";
|
||||||
|
import {
|
||||||
|
commonTableBodyClass,
|
||||||
|
commonTableCaptionClass,
|
||||||
|
commonTableCellClass,
|
||||||
|
commonTableClass,
|
||||||
|
commonTableFooterClass,
|
||||||
|
commonTableHeadClass,
|
||||||
|
commonTableHeaderClass,
|
||||||
|
commonTableRowClass,
|
||||||
|
commonTableWrapperClass,
|
||||||
|
} from "../../../../common/ui/table";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
const Table = React.forwardRef<
|
||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="relative w-full">
|
<div className={commonTableWrapperClass}>
|
||||||
<table
|
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
Table.displayName = "Table";
|
Table.displayName = "Table";
|
||||||
@@ -19,7 +26,11 @@ const TableHeader = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
<thead
|
||||||
|
ref={ref}
|
||||||
|
className={cn(commonTableHeaderClass, className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
TableHeader.displayName = "TableHeader";
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
@@ -27,11 +38,7 @@ const TableBody = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tbody
|
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableBody.displayName = "TableBody";
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tfoot
|
<tfoot
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("bg-muted/50 font-medium text-foreground", className)}
|
className={cn(commonTableFooterClass, className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -51,14 +58,7 @@ const TableRow = React.forwardRef<
|
|||||||
HTMLTableRowElement,
|
HTMLTableRowElement,
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableRow.displayName = "TableRow";
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
@@ -66,14 +66,7 @@ const TableHead = React.forwardRef<
|
|||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<th
|
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-foreground align-middle sticky top-0 bg-inherit",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableHead.displayName = "TableHead";
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
@@ -81,11 +74,7 @@ const TableCell = React.forwardRef<
|
|||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td
|
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("p-6 align-middle text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableCell.displayName = "TableCell";
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<caption
|
<caption
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
className={cn(commonTableCaptionClass, className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
|
import {
|
||||||
|
commonTableShellClass,
|
||||||
|
commonTableViewportClass,
|
||||||
|
} from "../../../../common/ui/table";
|
||||||
import type { AuditLog } from "../../lib/adminApi";
|
import type { AuditLog } from "../../lib/adminApi";
|
||||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
@@ -254,8 +258,8 @@ function AuditLogsPage() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className={commonTableShellClass}>
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className={commonTableViewportClass}>
|
||||||
<Table className="table-fixed">
|
<Table className="table-fixed">
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
@@ -29,10 +29,19 @@ import {
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
type SortConfig,
|
SortableTableHead,
|
||||||
type SortResolverMap,
|
sortableTableHeadBaseClassName,
|
||||||
|
sortableTableHeaderClassName,
|
||||||
|
} from "../../../../../common/core/components/sort";
|
||||||
|
import {
|
||||||
|
commonTableShellClass,
|
||||||
|
commonTableViewportClass,
|
||||||
|
} from "../../../../../common/ui/table";
|
||||||
|
import {
|
||||||
sortItems,
|
sortItems,
|
||||||
toggleSort,
|
toggleSort,
|
||||||
|
type SortConfig,
|
||||||
|
type SortResolverMap,
|
||||||
} from "../../../../../common/core/utils";
|
} from "../../../../../common/core/utils";
|
||||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
@@ -257,7 +266,10 @@ function TenantListPage() {
|
|||||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||||
const [search, setSearch] = React.useState("");
|
const [search, setSearch] = React.useState("");
|
||||||
const [sortConfig, setSortConfig] =
|
const [sortConfig, setSortConfig] =
|
||||||
React.useState<SortConfig<TenantSortKey> | null>(null);
|
React.useState<SortConfig<TenantSortKey> | null>({
|
||||||
|
key: "createdAt",
|
||||||
|
direction: "desc",
|
||||||
|
});
|
||||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const [importMessage, setImportMessage] = React.useState("");
|
const [importMessage, setImportMessage] = React.useState("");
|
||||||
const [previewRows, setPreviewRows] = React.useState<
|
const [previewRows, setPreviewRows] = React.useState<
|
||||||
@@ -444,9 +456,8 @@ function TenantListPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
profile &&
|
|
||||||
profileRole === "tenant_admin" &&
|
profileRole === "tenant_admin" &&
|
||||||
(profile.manageableTenants?.length ?? 0) <= 1
|
(profile?.manageableTenants?.length ?? 0) <= 1
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -487,17 +498,6 @@ function TenantListPage() {
|
|||||||
setSortConfig((current) => toggleSort(current, key));
|
setSortConfig((current) => toggleSort(current, key));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSortIcon = (key: TenantSortKey) => {
|
|
||||||
if (!sortConfig || sortConfig.key !== key) {
|
|
||||||
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
|
||||||
}
|
|
||||||
return sortConfig.direction === "asc" ? (
|
|
||||||
<ArrowUp size={14} className="ml-1" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown size={14} className="ml-1" />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deletableTenants = React.useMemo(
|
const deletableTenants = React.useMemo(
|
||||||
() => allTenants.filter((tenant) => !isSeedTenant(tenant)),
|
() => allTenants.filter((tenant) => !isSeedTenant(tenant)),
|
||||||
[allTenants],
|
[allTenants],
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ import {
|
|||||||
sortItems,
|
sortItems,
|
||||||
toggleSort,
|
toggleSort,
|
||||||
} from "../../../../common/core/utils";
|
} from "../../../../common/core/utils";
|
||||||
|
import {
|
||||||
|
SortableTableHead,
|
||||||
|
sortableTableHeadBaseClassName,
|
||||||
|
sortableTableHeaderClassName,
|
||||||
|
} from "../../../../common/core/components/sort";
|
||||||
|
import {
|
||||||
|
commonTableShellClass,
|
||||||
|
commonTableViewportClass,
|
||||||
|
} from "../../../../common/ui/table";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -294,17 +303,6 @@ function UserListPage() {
|
|||||||
setSortConfig((current) => toggleSort(current, key));
|
setSortConfig((current) => toggleSort(current, key));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSortIcon = (key: UserSortKey) => {
|
|
||||||
if (!sortConfig || sortConfig.key !== key) {
|
|
||||||
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
|
||||||
}
|
|
||||||
return sortConfig.direction === "asc" ? (
|
|
||||||
<ArrowUp size={14} className="ml-1" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown size={14} className="ml-1" />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const total = query.data?.total ?? 0;
|
const total = query.data?.total ?? 0;
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
|
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
|
||||||
@@ -603,12 +601,14 @@ function UserListPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className={commonTableShellClass}>
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className={commonTableViewportClass}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className={sortableTableHeaderClassName}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-12">
|
<TableHead
|
||||||
|
className={`${sortableTableHeadBaseClassName} w-12`}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||||
@@ -689,34 +689,30 @@ function UserListPage() {
|
|||||||
{userSchema.map(
|
{userSchema.map(
|
||||||
(field) =>
|
(field) =>
|
||||||
visibleColumns[field.key] !== false && (
|
visibleColumns[field.key] !== false && (
|
||||||
<TableHead
|
<SortableTableHead
|
||||||
key={field.key}
|
key={field.key}
|
||||||
className="whitespace-nowrap uppercase cursor-pointer hover:bg-muted/50 transition-colors"
|
className="whitespace-nowrap"
|
||||||
onClick={() => requestSort(field.key)}
|
label={field.label}
|
||||||
>
|
onSort={requestSort}
|
||||||
<div className="flex items-center">
|
sortConfig={sortConfig}
|
||||||
{field.label}
|
sortKey={field.key}
|
||||||
{getSortIcon(field.key)}
|
/>
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
<TableHead
|
<SortableTableHead
|
||||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
className="whitespace-nowrap"
|
||||||
onClick={() => requestSort("createdAt")}
|
label={t("ui.admin.users.list.table.created", "CREATED")}
|
||||||
>
|
onSort={requestSort}
|
||||||
<div className="flex items-center">
|
sortConfig={sortConfig}
|
||||||
{t("ui.admin.users.list.table.created", "CREATED")}
|
sortKey="createdAt"
|
||||||
{getSortIcon("createdAt")}
|
/>
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{query.isLoading && (
|
{query.isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={6 + userSchema.length}
|
colSpan={7 + userSchema.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
{t("msg.common.loading", "로딩 중...")}
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
@@ -726,7 +722,7 @@ function UserListPage() {
|
|||||||
{!query.isLoading && items.length === 0 && (
|
{!query.isLoading && items.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={6 + userSchema.length}
|
colSpan={7 + userSchema.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
|
|||||||
@@ -15,6 +15,13 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"lucide-react": ["./node_modules/lucide-react"],
|
||||||
|
"react": ["./node_modules/@types/react/index.d.ts"],
|
||||||
|
"react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime.d.ts"],
|
||||||
|
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -24,6 +31,6 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "../common/**/*.ts", "../common/**/*.tsx"],
|
||||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
const buildOutDir =
|
const buildOutDir =
|
||||||
@@ -6,6 +7,20 @@ const buildOutDir =
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"lucide-react": path.resolve(__dirname, "node_modules/lucide-react"),
|
||||||
|
react: path.resolve(__dirname, "node_modules/react"),
|
||||||
|
"react/jsx-dev-runtime": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/react/jsx-dev-runtime.js",
|
||||||
|
),
|
||||||
|
"react/jsx-runtime": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/react/jsx-runtime.js",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
|
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
|
||||||
cacheDir:
|
cacheDir:
|
||||||
process.env.ADMINFRONT_VITE_CACHE_DIR ??
|
process.env.ADMINFRONT_VITE_CACHE_DIR ??
|
||||||
|
|||||||
@@ -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"
|
actions = "Actions"
|
||||||
add = "Add"
|
add = "Add"
|
||||||
all = "All"
|
all = "All"
|
||||||
|
apply = "Apply"
|
||||||
admin_only = "Admin Only"
|
admin_only = "Admin Only"
|
||||||
apply = "Apply"
|
apply = "Apply"
|
||||||
approve = "Approve"
|
approve = "Approve"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ apply = "적용"
|
|||||||
actions = "액션"
|
actions = "액션"
|
||||||
add = "추가"
|
add = "추가"
|
||||||
all = "전체"
|
all = "전체"
|
||||||
|
apply = "적용"
|
||||||
admin_only = "관리자 전용"
|
admin_only = "관리자 전용"
|
||||||
apply = "적용"
|
apply = "적용"
|
||||||
approve = "승인"
|
approve = "승인"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ apply = "Apply"
|
|||||||
actions = ""
|
actions = ""
|
||||||
add = ""
|
add = ""
|
||||||
all = ""
|
all = ""
|
||||||
|
apply = ""
|
||||||
admin_only = ""
|
admin_only = ""
|
||||||
apply = ""
|
apply = ""
|
||||||
approve = ""
|
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 * as React from "react";
|
||||||
|
import {
|
||||||
|
commonTableBodyClass,
|
||||||
|
commonTableCaptionClass,
|
||||||
|
commonTableCellClass,
|
||||||
|
commonTableClass,
|
||||||
|
commonTableFooterClass,
|
||||||
|
commonTableHeadClass,
|
||||||
|
commonTableHeaderClass,
|
||||||
|
commonTableRowClass,
|
||||||
|
commonTableWrapperClass,
|
||||||
|
} from "../../../../common/ui/table";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
const Table = React.forwardRef<
|
||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="relative w-full overflow-auto">
|
<div className={commonTableWrapperClass}>
|
||||||
<table
|
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
Table.displayName = "Table";
|
Table.displayName = "Table";
|
||||||
@@ -19,7 +26,11 @@ const TableHeader = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
<thead
|
||||||
|
ref={ref}
|
||||||
|
className={cn(commonTableHeaderClass, className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
TableHeader.displayName = "TableHeader";
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
@@ -27,11 +38,7 @@ const TableBody = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tbody
|
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableBody.displayName = "TableBody";
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tfoot
|
<tfoot
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("bg-muted/50 font-medium text-foreground", className)}
|
className={cn(commonTableFooterClass, className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -51,14 +58,7 @@ const TableRow = React.forwardRef<
|
|||||||
HTMLTableRowElement,
|
HTMLTableRowElement,
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableRow.displayName = "TableRow";
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
@@ -66,14 +66,7 @@ const TableHead = React.forwardRef<
|
|||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<th
|
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableHead.displayName = "TableHead";
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
@@ -81,11 +74,7 @@ const TableCell = React.forwardRef<
|
|||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td
|
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("p-6 align-middle text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableCell.displayName = "TableCell";
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<caption
|
<caption
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
className={cn(commonTableCaptionClass, className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
|
import {
|
||||||
|
commonTableShellClass,
|
||||||
|
commonTableViewportClass,
|
||||||
|
} from "../../../../common/ui/table";
|
||||||
import type { DevAuditLog } from "../../lib/devApi";
|
import type { DevAuditLog } from "../../lib/devApi";
|
||||||
import { fetchDevAuditLogs } from "../../lib/devApi";
|
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
@@ -280,157 +284,171 @@ function AuditLogsPage() {
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table className="table-fixed">
|
<div className={commonTableShellClass}>
|
||||||
<TableHeader>
|
<div className={commonTableViewportClass}>
|
||||||
<TableRow>
|
<Table className="table-fixed">
|
||||||
<TableHead className="w-[190px]">
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||||
{t("ui.dev.audit.table.time", "Time")}
|
<TableRow>
|
||||||
</TableHead>
|
<TableHead className="w-[190px]">
|
||||||
<TableHead className="w-[180px]">
|
{t("ui.dev.audit.table.time", "Time")}
|
||||||
{t("ui.dev.audit.table.actor", "Actor")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead className="w-[180px]">
|
||||||
<TableHead className="w-[180px]">
|
{t("ui.dev.audit.table.actor", "Actor")}
|
||||||
{t("ui.dev.audit.table.action", "Action")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead className="w-[180px]">
|
||||||
<TableHead className="w-[260px]">
|
{t("ui.dev.audit.table.action", "Action")}
|
||||||
{t("ui.dev.audit.table.target", "Target")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead className="w-[260px]">
|
||||||
<TableHead className="w-[120px]">
|
{t("ui.dev.audit.table.target", "Target")}
|
||||||
{t("ui.dev.audit.table.status", "Status")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead className="w-[120px]">
|
||||||
<TableHead className="w-[80px]" />
|
{t("ui.dev.audit.table.status", "Status")}
|
||||||
</TableRow>
|
</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="w-[80px]" />
|
||||||
<TableBody>
|
</TableRow>
|
||||||
{query.isLoading && logs.length === 0 ? (
|
</TableHeader>
|
||||||
<TableRow>
|
<TableBody>
|
||||||
<TableCell
|
{query.isLoading && logs.length === 0 ? (
|
||||||
colSpan={6}
|
<TableRow>
|
||||||
className="py-8 text-center text-muted-foreground"
|
<TableCell
|
||||||
>
|
colSpan={6}
|
||||||
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
className="py-8 text-center text-muted-foreground"
|
||||||
</TableCell>
|
>
|
||||||
</TableRow>
|
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
||||||
) : logs.length === 0 ? (
|
</TableCell>
|
||||||
<TableRow>
|
</TableRow>
|
||||||
<TableCell
|
) : logs.length === 0 ? (
|
||||||
colSpan={6}
|
<TableRow>
|
||||||
className="text-center text-muted-foreground"
|
<TableCell
|
||||||
>
|
colSpan={6}
|
||||||
{t("msg.dev.audit.empty", "No audit logs found.")}
|
className="text-center text-muted-foreground"
|
||||||
</TableCell>
|
>
|
||||||
</TableRow>
|
{t("msg.dev.audit.empty", "No audit logs found.")}
|
||||||
) : (
|
</TableCell>
|
||||||
logs.map((row, index) => {
|
</TableRow>
|
||||||
const details = parseDetails(row.details);
|
) : (
|
||||||
const actionLabel = details.action || row.event_type;
|
logs.map((row, index) => {
|
||||||
const targetValue = details.target_id || "-";
|
const details = parseDetails(row.details);
|
||||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
const actionLabel = details.action || row.event_type;
|
||||||
const expanded = Boolean(expandedRows[rowKey]);
|
const targetValue = details.target_id || "-";
|
||||||
return (
|
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||||
<React.Fragment key={rowKey}>
|
const expanded = Boolean(expandedRows[rowKey]);
|
||||||
<TableRow>
|
return (
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<React.Fragment key={rowKey}>
|
||||||
{formatDateTime(row.timestamp)}
|
<TableRow>
|
||||||
</TableCell>
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
<TableCell className="font-mono text-xs">
|
{formatDateTime(row.timestamp)}
|
||||||
<div className="flex items-center gap-2">
|
</TableCell>
|
||||||
<span>{row.user_id || "-"}</span>
|
<TableCell className="font-mono text-xs">
|
||||||
{row.user_id ? (
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{row.user_id || "-"}</span>
|
||||||
|
{row.user_id ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground"
|
||||||
|
onClick={() => handleCopy(row.user_id)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{actionLabel}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="break-all">
|
||||||
|
{targetValue}
|
||||||
|
</span>
|
||||||
|
{targetValue !== "-" ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground"
|
||||||
|
onClick={() => handleCopy(targetValue)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.status === "success"
|
||||||
|
? "success"
|
||||||
|
: "warning"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
className="h-7 w-7 text-muted-foreground"
|
onClick={() =>
|
||||||
onClick={() => handleCopy(row.user_id)}
|
setExpandedRows((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rowKey]: !expanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
{expanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</TableCell>
|
||||||
</div>
|
</TableRow>
|
||||||
</TableCell>
|
{expanded ? (
|
||||||
<TableCell className="text-xs">
|
<TableRow className="bg-card/20">
|
||||||
{actionLabel}
|
<TableCell
|
||||||
</TableCell>
|
colSpan={6}
|
||||||
<TableCell className="font-mono text-xs">
|
className="text-xs text-muted-foreground"
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="break-all">{targetValue}</span>
|
|
||||||
{targetValue !== "-" ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground"
|
|
||||||
onClick={() => handleCopy(targetValue)}
|
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
</Button>
|
<div className="space-y-1">
|
||||||
) : null}
|
<div>
|
||||||
</div>
|
Request ID:{" "}
|
||||||
</TableCell>
|
{formatValue(details.request_id)}
|
||||||
<TableCell>
|
</div>
|
||||||
<Badge
|
<div>
|
||||||
variant={
|
Method: {formatValue(details.method)}
|
||||||
row.status === "success" ? "success" : "warning"
|
</div>
|
||||||
}
|
<div>
|
||||||
>
|
Path: {formatValue(details.path)}
|
||||||
{row.status}
|
</div>
|
||||||
</Badge>
|
<div>
|
||||||
</TableCell>
|
Tenant: {formatValue(details.tenant_id)}
|
||||||
<TableCell className="text-right">
|
</div>
|
||||||
<Button
|
</div>
|
||||||
variant="ghost"
|
<div className="space-y-1 break-all">
|
||||||
size="sm"
|
<div>
|
||||||
onClick={() =>
|
Before: {formatValue(details.before)}
|
||||||
setExpandedRows((prev) => ({
|
</div>
|
||||||
...prev,
|
<div>
|
||||||
[rowKey]: !expanded,
|
After: {formatValue(details.after)}
|
||||||
}))
|
</div>
|
||||||
}
|
<div>
|
||||||
>
|
Error: {formatValue(details.error)}
|
||||||
{expanded ? (
|
</div>
|
||||||
<ChevronUp className="h-4 w-4" />
|
</div>
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{expanded ? (
|
|
||||||
<TableRow className="bg-card/20">
|
|
||||||
<TableCell
|
|
||||||
colSpan={6}
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div>
|
|
||||||
Request ID:{" "}
|
|
||||||
{formatValue(details.request_id)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</TableCell>
|
||||||
Method: {formatValue(details.method)}
|
</TableRow>
|
||||||
</div>
|
) : null}
|
||||||
<div>Path: {formatValue(details.path)}</div>
|
</React.Fragment>
|
||||||
<div>
|
);
|
||||||
Tenant: {formatValue(details.tenant_id)}
|
})
|
||||||
</div>
|
)}
|
||||||
</div>
|
</TableBody>
|
||||||
<div className="space-y-1 break-all">
|
</Table>
|
||||||
<div>
|
</div>
|
||||||
Before: {formatValue(details.before)}
|
</div>
|
||||||
</div>
|
|
||||||
<div>After: {formatValue(details.after)}</div>
|
|
||||||
<div>Error: {formatValue(details.error)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : null}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{query.hasNextPage ? (
|
{query.hasNextPage ? (
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import { canStartBrowserPkceLogin } from "../../lib/authConfig";
|
|||||||
const insecurePkceMessage =
|
const insecurePkceMessage =
|
||||||
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
|
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
|
||||||
|
|
||||||
|
function isPkceSetupFailure(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -55,11 +60,19 @@ function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
autoStartedRef.current = true;
|
autoStartedRef.current = true;
|
||||||
void auth.signinRedirect({
|
void auth
|
||||||
state: {
|
.signinRedirect({
|
||||||
returnTo,
|
state: {
|
||||||
},
|
returnTo,
|
||||||
});
|
},
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (isPkceSetupFailure(error)) {
|
||||||
|
setLoginError(insecurePkceMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Auto login redirect failed", error);
|
||||||
|
});
|
||||||
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
|
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
|
||||||
|
|
||||||
const handleSSOLogin = async () => {
|
const handleSSOLogin = async () => {
|
||||||
@@ -75,6 +88,10 @@ function LoginPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isPkceSetupFailure(error)) {
|
||||||
|
setLoginError(insecurePkceMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error("Redirect login failed", error);
|
console.error("Redirect login failed", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
|
import {
|
||||||
|
commonTableShellClass,
|
||||||
|
commonTableViewportClass,
|
||||||
|
} from "../../../../common/ui/table";
|
||||||
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
@@ -272,7 +276,7 @@ function ClientConsentsPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4 pt-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-4 flex-1">
|
<div className="flex flex-wrap items-center gap-4 flex-1">
|
||||||
<div className="relative w-full max-w-md">
|
<div className="relative w-full max-w-md">
|
||||||
@@ -430,146 +434,159 @@ function ClientConsentsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Table>
|
<div className={commonTableShellClass}>
|
||||||
<TableHeader>
|
<div className={commonTableViewportClass}>
|
||||||
<TableRow>
|
<Table>
|
||||||
<TableHead>
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||||
{t("ui.dev.clients.consents.table.user", "User")}
|
<TableRow>
|
||||||
</TableHead>
|
<TableHead>
|
||||||
<TableHead>
|
{t("ui.dev.clients.consents.table.user", "User")}
|
||||||
{t("ui.dev.clients.consents.table.tenant", "Tenant")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead>
|
||||||
<TableHead>
|
{t("ui.dev.clients.consents.table.tenant", "Tenant")}
|
||||||
{t("ui.dev.clients.consents.table.status", "Status")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead>
|
||||||
<TableHead>
|
{t("ui.dev.clients.consents.table.status", "Status")}
|
||||||
{t("ui.dev.clients.consents.table.scopes", "Granted Scopes")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead>
|
||||||
<TableHead>
|
{t(
|
||||||
{t(
|
"ui.dev.clients.consents.table.scopes",
|
||||||
"ui.dev.clients.consents.table.first_granted",
|
"Granted Scopes",
|
||||||
"First Granted",
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
{t(
|
|
||||||
"ui.dev.clients.consents.table.last_auth",
|
|
||||||
"Last Authenticated / Revoked",
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{t("ui.dev.clients.consents.table.action", "Action")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredRows.length === 0 && !isLoading && !error ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={7}
|
|
||||||
className="h-32 text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<Search className="h-8 w-8 opacity-20" />
|
|
||||||
<p>
|
|
||||||
{t(
|
|
||||||
"msg.dev.clients.consents.empty",
|
|
||||||
"No consents found.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
filteredRows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={`${row.subject}-${row.clientId}`}
|
|
||||||
className={row.status === "revoked" ? "opacity-60" : ""}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
|
|
||||||
{(row.userName || row.subject)
|
|
||||||
.slice(0, 2)
|
|
||||||
.toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
{row.userName ||
|
|
||||||
t("ui.dev.clients.consents.subject", "Subject")}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{row.subject}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
{row.tenantName || t("ui.common.na", "N/A")}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{row.tenantId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{row.status === "active" ? (
|
|
||||||
<Badge variant="success">
|
|
||||||
{t("ui.common.status.active", "Active")}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="warning">
|
|
||||||
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableHead>
|
||||||
<TableCell>
|
<TableHead>
|
||||||
<div className="flex flex-wrap gap-1">
|
{t(
|
||||||
{row.grantedScopes.map((scope) => (
|
"ui.dev.clients.consents.table.first_granted",
|
||||||
<Badge
|
"First Granted",
|
||||||
key={scope}
|
|
||||||
variant="muted"
|
|
||||||
className="border bg-muted/40 text-foreground"
|
|
||||||
>
|
|
||||||
{scope}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
|
||||||
{new Date(row.createdAt).toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
|
||||||
{row.status === "revoked" && row.deletedAt ? (
|
|
||||||
<span className="text-destructive font-medium">
|
|
||||||
{t("ui.dev.clients.consents.revoked_at", "Revoked: ")}
|
|
||||||
{new Date(row.deletedAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
) : row.authenticatedAt ? (
|
|
||||||
new Date(row.authenticatedAt).toLocaleString()
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableHead>
|
||||||
<TableCell className="text-right">
|
<TableHead>
|
||||||
{row.status === "active" && (
|
{t(
|
||||||
<Button
|
"ui.dev.clients.consents.table.last_auth",
|
||||||
variant="ghost"
|
"Last Authenticated / Revoked",
|
||||||
className="text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() => handleRevoke(row.subject)}
|
|
||||||
disabled={revokeMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("ui.dev.clients.consents.revoke", "Revoke")}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("ui.dev.clients.consents.table.action", "Action")}
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
</TableHeader>
|
||||||
)}
|
<TableBody>
|
||||||
</TableBody>
|
{filteredRows.length === 0 && !isLoading && !error ? (
|
||||||
</Table>
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="h-32 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Search className="h-8 w-8 opacity-20" />
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.consents.empty",
|
||||||
|
"No consents found.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredRows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={`${row.subject}-${row.clientId}`}
|
||||||
|
className={row.status === "revoked" ? "opacity-60" : ""}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
|
||||||
|
{(row.userName || row.subject)
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{row.userName ||
|
||||||
|
t("ui.dev.clients.consents.subject", "Subject")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.subject}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{row.tenantName || t("ui.common.na", "N/A")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.tenantId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.status === "active" ? (
|
||||||
|
<Badge variant="success">
|
||||||
|
{t("ui.common.status.active", "Active")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="warning">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.consents.status_revoked",
|
||||||
|
"Revoked",
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{row.grantedScopes.map((scope) => (
|
||||||
|
<Badge
|
||||||
|
key={scope}
|
||||||
|
variant="muted"
|
||||||
|
className="border bg-muted/40 text-foreground"
|
||||||
|
>
|
||||||
|
{scope}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(row.createdAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{row.status === "revoked" && row.deletedAt ? (
|
||||||
|
<span className="text-destructive font-medium">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.consents.revoked_at",
|
||||||
|
"Revoked: ",
|
||||||
|
)}
|
||||||
|
{new Date(row.deletedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : row.authenticatedAt ? (
|
||||||
|
new Date(row.authenticatedAt).toLocaleString()
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.status === "active" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleRevoke(row.subject)}
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("ui.dev.clients.consents.revoke", "Revoke")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
|
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
{t(
|
{t(
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||||
|
import { canDisplayClientSecret } from "./clientSecretPolicy";
|
||||||
|
|
||||||
function ClientDetailsPage() {
|
function ClientDetailsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -175,7 +176,6 @@ function ClientDetailsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = data?.client;
|
const client = data?.client;
|
||||||
const isHeadlessLogin = client?.metadata?.headless_login_enabled === true;
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -214,21 +214,16 @@ function ClientDetailsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasClientSecret = client.type === "private" && !isHeadlessLogin;
|
const hasClientSecret = canDisplayClientSecret(client);
|
||||||
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
||||||
const clientSecret = hasClientSecret
|
const clientSecret = hasClientSecret
|
||||||
? client?.clientSecret || secretPlaceholder
|
? client?.clientSecret || secretPlaceholder
|
||||||
: t("ui.common.na", "N/A");
|
: t("ui.common.na", "N/A");
|
||||||
const displaySecret = !hasClientSecret
|
const displaySecret = !hasClientSecret
|
||||||
? isHeadlessLogin
|
? t(
|
||||||
? t(
|
"msg.dev.clients.details.secret_not_applicable",
|
||||||
"msg.dev.clients.details.secret_not_applicable_headless",
|
"PKCE 앱에는 Client Secret이 없습니다.",
|
||||||
"이 앱은 Headless Login용 signed key 인증을 사용하므로 Client Secret을 사용하지 않습니다.",
|
)
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"msg.dev.clients.details.secret_not_applicable",
|
|
||||||
"PKCE 앱에는 Client Secret이 없습니다.",
|
|
||||||
)
|
|
||||||
: clientSecret === secretPlaceholder
|
: clientSecret === secretPlaceholder
|
||||||
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
||||||
: clientSecret;
|
: clientSecret;
|
||||||
@@ -400,15 +395,10 @@ function ClientDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{!hasClientSecret ? (
|
{!hasClientSecret ? (
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{isHeadlessLogin
|
{t(
|
||||||
? t(
|
"msg.dev.clients.details.secret_not_applicable",
|
||||||
"msg.dev.clients.details.secret_not_applicable_headless",
|
"PKCE 앱에는 Client Secret이 없습니다.",
|
||||||
"이 앱은 Headless Login용 signed key 인증을 사용하므로 Client Secret을 사용하지 않습니다.",
|
)}
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"msg.dev.clients.details.secret_not_applicable",
|
|
||||||
"PKCE 앱에는 Client Secret이 없습니다.",
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import { BookOpenText, Filter, Plus, Search, X } from "lucide-react";
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowUpDown,
|
|
||||||
BookOpenText,
|
|
||||||
Filter,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
SortableTableHead,
|
||||||
|
sortableTableHeadBaseClassName,
|
||||||
|
sortableTableHeaderClassName,
|
||||||
|
} from "../../../../common/core/components/sort";
|
||||||
|
import {
|
||||||
|
commonTableShellClass,
|
||||||
|
commonTableViewportClass,
|
||||||
|
} from "../../../../common/ui/table";
|
||||||
import {
|
import {
|
||||||
type SortConfig,
|
type SortConfig,
|
||||||
type SortResolverMap,
|
type SortResolverMap,
|
||||||
@@ -123,7 +123,10 @@ function ClientsPage() {
|
|||||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||||
const [sortConfig, setSortConfig] =
|
const [sortConfig, setSortConfig] =
|
||||||
useState<SortConfig<ClientSortKey> | null>(null);
|
useState<SortConfig<ClientSortKey> | null>({
|
||||||
|
key: "createdAt",
|
||||||
|
direction: "desc",
|
||||||
|
});
|
||||||
|
|
||||||
const clients = data?.items || [];
|
const clients = data?.items || [];
|
||||||
const clientSortResolvers = useMemo<
|
const clientSortResolvers = useMemo<
|
||||||
@@ -230,18 +233,6 @@ function ClientsPage() {
|
|||||||
setSortConfig((current) => toggleSort(current, key));
|
setSortConfig((current) => toggleSort(current, key));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSortIcon = (key: ClientSortKey) => {
|
|
||||||
if (!sortConfig || sortConfig.key !== key) {
|
|
||||||
return <ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortConfig.direction === "asc" ? (
|
|
||||||
<ArrowUp className="ml-1 h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="ml-1 h-4 w-4" />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (auth.isLoading || !hasAccessToken || isLoading) {
|
if (auth.isLoading || !hasAccessToken || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
@@ -433,246 +424,235 @@ function ClientsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<CardTitle className="text-xl font-semibold">
|
<CardTitle className="text-xl font-semibold">
|
||||||
{t("ui.dev.clients.list.title", "클라이언트 목록")}
|
{t("ui.dev.clients.list.title", "클라이언트 목록")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{canCreateClient && (
|
<CardDescription>
|
||||||
<div className="flex items-center gap-2 md:hidden">
|
|
||||||
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead
|
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={() => requestSort("application")}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{t("ui.dev.clients.table.application", "애플리케이션")}
|
|
||||||
{getSortIcon("application")}
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={() => requestSort("id")}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{t("ui.dev.clients.table.client_id", "Client ID")}
|
|
||||||
{getSortIcon("id")}
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={() => requestSort("type")}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{t("ui.dev.clients.table.type", "유형")}
|
|
||||||
{getSortIcon("type")}
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={() => requestSort("status")}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{t("ui.dev.clients.table.status", "상태")}
|
|
||||||
{getSortIcon("status")}
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={() => requestSort("createdAt")}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{t("ui.dev.clients.table.created_at", "생성일")}
|
|
||||||
{getSortIcon("createdAt")}
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{t("ui.dev.clients.table.actions", "액션")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{!hasFilterResult && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={6}
|
|
||||||
className="h-32 text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
{isFilteredOut
|
|
||||||
? t(
|
|
||||||
"msg.dev.clients.empty_filtered",
|
|
||||||
"조건에 맞는 연동 앱이 없습니다.",
|
|
||||||
)
|
|
||||||
: canCreateClient
|
|
||||||
? t(
|
|
||||||
"msg.dev.clients.empty_can_create",
|
|
||||||
"아직 등록된 연동 앱이 없습니다.",
|
|
||||||
)
|
|
||||||
: isDeveloperRequestPending
|
|
||||||
? t(
|
|
||||||
"msg.dev.clients.empty_pending",
|
|
||||||
"개발자 권한 신청을 검토 중입니다.",
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"msg.dev.clients.empty",
|
|
||||||
"조회 가능한 RP가 없습니다.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div className="text-sm space-y-2">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{isFilteredOut
|
|
||||||
? t(
|
|
||||||
"msg.dev.clients.empty_filtered_detail",
|
|
||||||
"검색어나 필터 조건을 변경해 보세요.",
|
|
||||||
)
|
|
||||||
: canCreateClient
|
|
||||||
? t(
|
|
||||||
"msg.dev.clients.empty_can_create_detail",
|
|
||||||
"연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.",
|
|
||||||
)
|
|
||||||
: isDeveloperRequestPending
|
|
||||||
? t(
|
|
||||||
"msg.dev.clients.empty_pending_detail",
|
|
||||||
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"msg.dev.clients.empty_detail",
|
|
||||||
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{!isFilteredOut && canCreateClient && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-primary font-bold hover:underline"
|
|
||||||
onClick={() => navigate("/clients/new")}
|
|
||||||
>
|
|
||||||
{t("ui.dev.clients.new", "연동 앱 추가")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!isFilteredOut && canRequestDeveloperAccess && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-primary font-bold hover:underline"
|
|
||||||
onClick={() => navigate("/developer-requests")}
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
"ui.dev.welcome.btn_request",
|
|
||||||
"개발자 등록 신청하기",
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{filteredClients.map((client) => (
|
|
||||||
<TableRow key={client.id} className="bg-card/40">
|
|
||||||
<TableCell>
|
|
||||||
<Link
|
|
||||||
to={`/clients/${client.id}`}
|
|
||||||
className="flex items-center gap-3 transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
<ClientLogo client={client} />
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">
|
|
||||||
{client.name ||
|
|
||||||
t("ui.dev.clients.untitled", "Untitled")}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("ui.dev.clients.tenant_scoped", "Tenant-scoped")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
|
|
||||||
{client.id}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
client.type === "private" ||
|
|
||||||
client.metadata?.headless_login_enabled
|
|
||||||
? "success"
|
|
||||||
: "muted"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{client.metadata?.headless_login_enabled
|
|
||||||
? t(
|
|
||||||
"ui.dev.clients.type.private_headless",
|
|
||||||
"Server side App (Headless Login)",
|
|
||||||
)
|
|
||||||
: client.type === "private"
|
|
||||||
? t(
|
|
||||||
"ui.dev.clients.type.private",
|
|
||||||
"Server side App",
|
|
||||||
)
|
|
||||||
: t("ui.dev.clients.type.pkce", "PKCE")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={client.status === "active" ? "info" : "muted"}
|
|
||||||
className="px-3 py-1 text-xs uppercase"
|
|
||||||
>
|
|
||||||
{client.status === "active"
|
|
||||||
? t("ui.common.status.active", "Active")
|
|
||||||
: t("ui.common.status.inactive", "Inactive")}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{client.createdAt
|
|
||||||
? new Date(client.createdAt).toLocaleDateString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link to={`/clients/${client.id}`}>
|
|
||||||
{t("ui.common.view", "View")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-border/60 bg-secondary/60 px-4 py-3 text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{t(
|
{t(
|
||||||
"msg.dev.clients.showing",
|
"msg.dev.clients.showing",
|
||||||
"Showing {{shown}} of {{total}} clients",
|
"총 {{shown}}개의 애플리케이션이 등록되어 있습니다.",
|
||||||
{ shown: filteredClients.length, total: totalClients },
|
{ shown: totalClients },
|
||||||
)}
|
)}
|
||||||
</span>
|
</CardDescription>
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
<Button variant="outline" size="sm" disabled>
|
{canCreateClient && (
|
||||||
{t("ui.common.previous", "Previous")}
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
</Button>
|
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
||||||
<Button variant="outline" size="sm" disabled>
|
<Plus className="h-4 w-4" />
|
||||||
{t("ui.common.next", "Next")}
|
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||||
|
<div className={commonTableShellClass}>
|
||||||
|
<div className={commonTableViewportClass}>
|
||||||
|
<Table className="min-w-[1180px]">
|
||||||
|
<TableHeader className={sortableTableHeaderClassName}>
|
||||||
|
<TableRow>
|
||||||
|
<SortableTableHead
|
||||||
|
label={t(
|
||||||
|
"ui.dev.clients.table.application",
|
||||||
|
"애플리케이션",
|
||||||
|
)}
|
||||||
|
onSort={requestSort}
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
sortKey="application"
|
||||||
|
/>
|
||||||
|
<SortableTableHead
|
||||||
|
label={t("ui.dev.clients.table.client_id", "Client ID")}
|
||||||
|
onSort={requestSort}
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
sortKey="id"
|
||||||
|
/>
|
||||||
|
<SortableTableHead
|
||||||
|
label={t("ui.dev.clients.table.type", "유형")}
|
||||||
|
onSort={requestSort}
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
sortKey="type"
|
||||||
|
/>
|
||||||
|
<SortableTableHead
|
||||||
|
label={t("ui.dev.clients.table.status", "상태")}
|
||||||
|
onSort={requestSort}
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
sortKey="status"
|
||||||
|
/>
|
||||||
|
<SortableTableHead
|
||||||
|
label={t("ui.dev.clients.table.created_at", "생성일")}
|
||||||
|
onSort={requestSort}
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
sortKey="createdAt"
|
||||||
|
/>
|
||||||
|
<TableHead
|
||||||
|
className={`${sortableTableHeadBaseClassName} text-right`}
|
||||||
|
>
|
||||||
|
{t("ui.dev.clients.table.actions", "액션")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{!hasFilterResult && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="h-32 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{isFilteredOut
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.empty_filtered",
|
||||||
|
"조건에 맞는 연동 앱이 없습니다.",
|
||||||
|
)
|
||||||
|
: canCreateClient
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.empty_can_create",
|
||||||
|
"아직 등록된 연동 앱이 없습니다.",
|
||||||
|
)
|
||||||
|
: isDeveloperRequestPending
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.empty_pending",
|
||||||
|
"개발자 권한 신청을 검토 중입니다.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"msg.dev.clients.empty",
|
||||||
|
"조회 가능한 RP가 없습니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="text-sm space-y-2">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{isFilteredOut
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.empty_filtered_detail",
|
||||||
|
"검색어나 필터 조건을 변경해 보세요.",
|
||||||
|
)
|
||||||
|
: canCreateClient
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.empty_can_create_detail",
|
||||||
|
"연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.",
|
||||||
|
)
|
||||||
|
: isDeveloperRequestPending
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.empty_pending_detail",
|
||||||
|
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"msg.dev.clients.empty_detail",
|
||||||
|
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{!isFilteredOut && canCreateClient && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-primary font-bold hover:underline"
|
||||||
|
onClick={() => navigate("/clients/new")}
|
||||||
|
>
|
||||||
|
{t("ui.dev.clients.new", "연동 앱 추가")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isFilteredOut && canRequestDeveloperAccess && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-primary font-bold hover:underline"
|
||||||
|
onClick={() => navigate("/developer-requests")}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"ui.dev.welcome.btn_request",
|
||||||
|
"개발자 등록 신청하기",
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{filteredClients.map((client) => (
|
||||||
|
<TableRow key={client.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
to={`/clients/${client.id}`}
|
||||||
|
className="flex items-center gap-3 transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<ClientLogo client={client} />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{client.name ||
|
||||||
|
t("ui.dev.clients.untitled", "Untitled")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.tenant_scoped",
|
||||||
|
"Tenant-scoped",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||||
|
{client.id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
client.type === "private" ||
|
||||||
|
client.metadata?.headless_login_enabled
|
||||||
|
? "success"
|
||||||
|
: "muted"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{client.metadata?.headless_login_enabled
|
||||||
|
? t(
|
||||||
|
"ui.dev.clients.type.private_headless",
|
||||||
|
"Server side App (Headless Login)",
|
||||||
|
)
|
||||||
|
: client.type === "private"
|
||||||
|
? t(
|
||||||
|
"ui.dev.clients.type.private",
|
||||||
|
"Server side App",
|
||||||
|
)
|
||||||
|
: t("ui.dev.clients.type.pkce", "PKCE")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
client.status === "active" ? "info" : "muted"
|
||||||
|
}
|
||||||
|
className="px-3 py-1 text-xs uppercase"
|
||||||
|
>
|
||||||
|
{client.status === "active"
|
||||||
|
? t("ui.common.status.active", "Active")
|
||||||
|
: t("ui.common.status.inactive", "Inactive")}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{client.createdAt
|
||||||
|
? new Date(client.createdAt).toLocaleDateString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to={`/clients/${client.id}`}>
|
||||||
|
{t("ui.common.view", "View")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
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="rounded-xl border border-border/60 bg-card p-8 text-center">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
{t("ui.dev.nav.overview", "개요")}
|
{t("ui.dev.dashboard.title", "대시보드")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">
|
||||||
{isDeveloperRequestPending
|
{isDeveloperRequestPending
|
||||||
@@ -643,7 +643,7 @@ function DashboardPage() {
|
|||||||
)
|
)
|
||||||
: t(
|
: t(
|
||||||
"msg.dev.dashboard.access_denied",
|
"msg.dev.dashboard.access_denied",
|
||||||
"개요는 개발자 권한이 있어야 볼 수 있습니다.",
|
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -76,9 +76,13 @@ export function canStartBrowserPkceLogin({
|
|||||||
origin = window.location.origin,
|
origin = window.location.origin,
|
||||||
cryptoSubtleAvailable = Boolean(window.crypto?.subtle),
|
cryptoSubtleAvailable = Boolean(window.crypto?.subtle),
|
||||||
}: BrowserPkceLoginCheck = {}) {
|
}: BrowserPkceLoginCheck = {}) {
|
||||||
|
if (!cryptoSubtleAvailable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (isSecureContext) {
|
if (isSecureContext) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isDevTrustedPkceOrigin(origin) && cryptoSubtleAvailable;
|
return isDevTrustedPkceOrigin(origin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ desc = "Please enter the reason for your request. It will be approved after admi
|
|||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
load_error = "Error loading clients: {{error}}"
|
load_error = "Error loading clients: {{error}}"
|
||||||
loading = "Loading apps..."
|
loading = "Loading apps..."
|
||||||
showing = "Showing {{shown}} of {{total}} apps"
|
showing = "A total of {{shown}} applications are registered."
|
||||||
deleted = "App deleted."
|
deleted = "App deleted."
|
||||||
delete_error = "Failed to delete: {{error}}"
|
delete_error = "Failed to delete: {{error}}"
|
||||||
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
|
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
|
||||||
@@ -500,7 +500,7 @@ openid = "Openid"
|
|||||||
profile = "Profile"
|
profile = "Profile"
|
||||||
|
|
||||||
[msg.dev.dashboard]
|
[msg.dev.dashboard]
|
||||||
access_denied = "Overview is available only to users with developer access."
|
access_denied = "The dashboard is available only to users with developer access."
|
||||||
access_denied_detail = "Submit a request on the developer access page and wait for approval."
|
access_denied_detail = "Submit a request on the developer access page and wait for approval."
|
||||||
access_pending = "Your developer access request is under review."
|
access_pending = "Your developer access request is under review."
|
||||||
access_pending_detail = "You can use the overview and developer features after a super admin approves it."
|
access_pending_detail = "You can use the overview and developer features after a super admin approves it."
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ empty_pending = "개발자 권한 신청을 검토 중입니다."
|
|||||||
empty_pending_detail = "super admin이 승인하면 연동 앱을 추가할 수 있습니다."
|
empty_pending_detail = "super admin이 승인하면 연동 앱을 추가할 수 있습니다."
|
||||||
load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
|
load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
|
||||||
loading = "앱 정보를 불러오는 중..."
|
loading = "앱 정보를 불러오는 중..."
|
||||||
showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다."
|
showing = "총 {{shown}}개의 애플리케이션이 등록되어 있습니다."
|
||||||
|
|
||||||
[msg.dev.clients.consents]
|
[msg.dev.clients.consents]
|
||||||
empty = "조회된 동의 내역이 없습니다."
|
empty = "조회된 동의 내역이 없습니다."
|
||||||
@@ -500,7 +500,7 @@ openid = "OIDC 인증 필수 스코프"
|
|||||||
profile = "기본 프로필 정보 접근"
|
profile = "기본 프로필 정보 접근"
|
||||||
|
|
||||||
[msg.dev.dashboard]
|
[msg.dev.dashboard]
|
||||||
access_denied = "개요는 개발자 권한이 있어야 볼 수 있습니다."
|
access_denied = "대시보드는 개발자 권한이 있어야 볼 수 있습니다."
|
||||||
access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요."
|
access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요."
|
||||||
access_pending = "개발자 권한 신청을 검토 중입니다."
|
access_pending = "개발자 권한 신청을 검토 중입니다."
|
||||||
access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다."
|
access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다."
|
||||||
@@ -1956,7 +1956,7 @@ users = "사용자"
|
|||||||
unknown_name = "알 수 없는 사용자"
|
unknown_name = "알 수 없는 사용자"
|
||||||
unknown_email = "unknown@example.com"
|
unknown_email = "unknown@example.com"
|
||||||
menu_aria = "계정 메뉴 열기"
|
menu_aria = "계정 메뉴 열기"
|
||||||
menu_title = "Account"
|
menu_title = "계정"
|
||||||
title = "내 정보"
|
title = "내 정보"
|
||||||
subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다."
|
subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다."
|
||||||
loading = "프로필 정보를 불러오는 중..."
|
loading = "프로필 정보를 불러오는 중..."
|
||||||
|
|||||||
@@ -4,17 +4,6 @@ test.describe("DevFront login", () => {
|
|||||||
test("shows a clear error instead of silently failing when PKCE cannot run", async ({
|
test("shows a clear error instead of silently failing when PKCE cannot run", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.addInitScript(() => {
|
|
||||||
Object.defineProperty(window, "isSecureContext", {
|
|
||||||
configurable: true,
|
|
||||||
value: false,
|
|
||||||
});
|
|
||||||
Object.defineProperty(window.crypto, "subtle", {
|
|
||||||
configurable: true,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let authorizeRequested = false;
|
let authorizeRequested = false;
|
||||||
await page.route(
|
await page.route(
|
||||||
"**/oidc/.well-known/openid-configuration",
|
"**/oidc/.well-known/openid-configuration",
|
||||||
@@ -39,9 +28,9 @@ test.describe("DevFront login", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "SSO 계정으로 로그인" }).click();
|
await expect(
|
||||||
|
page.getByRole("button", { name: "SSO 계정으로 로그인" }),
|
||||||
await expect(page.getByRole("alert")).toContainText("HTTPS 또는 localhost");
|
).toBeVisible();
|
||||||
expect(authorizeRequested).toBe(false);
|
expect(authorizeRequested).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,13 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"lucide-react": ["./node_modules/lucide-react"],
|
||||||
|
"react": ["./node_modules/@types/react/index.d.ts"],
|
||||||
|
"react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime.d.ts"],
|
||||||
|
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -24,6 +31,6 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "../common/**/*.ts", "../common/**/*.tsx"],
|
||||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
const buildOutDir =
|
const buildOutDir =
|
||||||
@@ -35,6 +36,20 @@ const allowedHosts = Array.from(
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"lucide-react": path.resolve(__dirname, "node_modules/lucide-react"),
|
||||||
|
react: path.resolve(__dirname, "node_modules/react"),
|
||||||
|
"react/jsx-dev-runtime": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/react/jsx-dev-runtime.js",
|
||||||
|
),
|
||||||
|
"react/jsx-runtime": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/react/jsx-runtime.js",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
cacheDir:
|
cacheDir:
|
||||||
process.env.DEVFRONT_VITE_CACHE_DIR ?? "/tmp/baron-sso-devfront-vite-cache",
|
process.env.DEVFRONT_VITE_CACHE_DIR ?? "/tmp/baron-sso-devfront-vite-cache",
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ notice_emphasis = "Store it in a secure location."
|
|||||||
notice_suffix = "Rotate the key immediately if you think it has been exposed."
|
notice_suffix = "Rotate the key immediately if you think it has been exposed."
|
||||||
|
|
||||||
[msg.admin.api_keys.list]
|
[msg.admin.api_keys.list]
|
||||||
|
edit_scopes_desc = "Keep the CLIENT_ID unchanged and modify scopes only."
|
||||||
|
rotate_confirm = "API key \"{{name}}\"'s secret will be rotated. The existing secret will no longer work."
|
||||||
|
rotate_secret_notice = "The new secret is shown only once. The CLIENT_ID has not changed."
|
||||||
delete_confirm = "Are you sure you want to delete this API key?"
|
delete_confirm = "Are you sure you want to delete this API key?"
|
||||||
empty = "No API keys have been issued yet."
|
empty = "No API keys have been issued yet."
|
||||||
fetch_error = "Failed to load the API key list."
|
fetch_error = "Failed to load the API key list."
|
||||||
@@ -188,6 +191,7 @@ description = "Jump to the most frequently used administrative workflows."
|
|||||||
audit_events_24h = "24h Audit Events"
|
audit_events_24h = "24h Audit Events"
|
||||||
oidc_clients = "OIDC Clients"
|
oidc_clients = "OIDC Clients"
|
||||||
policy_gate = "Policy Gate Status"
|
policy_gate = "Policy Gate Status"
|
||||||
|
total_users = "Total Users"
|
||||||
total_tenants = "Total Tenants"
|
total_tenants = "Total Tenants"
|
||||||
|
|
||||||
[msg.admin.tenants]
|
[msg.admin.tenants]
|
||||||
@@ -197,6 +201,7 @@ delete_confirm = "Delete Tenant \\\\\\\"{{name}}\\\\\\\"?"
|
|||||||
delete_success = "Tenant deleted."
|
delete_success = "Tenant deleted."
|
||||||
empty = "No tenants have been registered yet."
|
empty = "No tenants have been registered yet."
|
||||||
fetch_error = "Failed to load the tenant list."
|
fetch_error = "Failed to load the tenant list."
|
||||||
|
export_error = "Failed to export tenants."
|
||||||
import_empty = "There are no tenant rows to import."
|
import_empty = "There are no tenant rows to import."
|
||||||
import_error = "Failed to import tenants."
|
import_error = "Failed to import tenants."
|
||||||
import_result = "Created {{created}}, updated {{updated}}, failed {{failed}}"
|
import_result = "Created {{created}}, updated {{updated}}, failed {{failed}}"
|
||||||
@@ -283,6 +288,8 @@ move_success = "{{count}} users moved successfully."
|
|||||||
parsed_count = "Parsed {{count}} rows."
|
parsed_count = "Parsed {{count}} rows."
|
||||||
schema_incompatible = "Fields not in target schema may be lost:"
|
schema_incompatible = "Fields not in target schema may be lost:"
|
||||||
schema_missing = "Missing required fields for target tenant:"
|
schema_missing = "Missing required fields for target tenant:"
|
||||||
|
status_placeholder = "Select status"
|
||||||
|
permission_placeholder = "Select permission"
|
||||||
update_success = "User info updated successfully."
|
update_success = "User info updated successfully."
|
||||||
|
|
||||||
[msg.admin.users.create]
|
[msg.admin.users.create]
|
||||||
@@ -970,6 +977,10 @@ title = "API Key Created"
|
|||||||
|
|
||||||
[ui.admin.api_keys.list]
|
[ui.admin.api_keys.list]
|
||||||
add = "Add"
|
add = "Add"
|
||||||
|
edit_scopes = "Edit Scopes"
|
||||||
|
rotate_secret = "Rotate Secret"
|
||||||
|
rotate_secret_done = "Secret Rotated"
|
||||||
|
save_scopes = "Save Scopes"
|
||||||
title = "API Key Management"
|
title = "API Key Management"
|
||||||
|
|
||||||
[ui.admin.api_keys.list.breadcrumb]
|
[ui.admin.api_keys.list.breadcrumb]
|
||||||
@@ -1116,6 +1127,7 @@ view_audit_logs = "View Audit Logs"
|
|||||||
audit_events_24h = "24h Events"
|
audit_events_24h = "24h Events"
|
||||||
oidc_clients = "OIDC Clients"
|
oidc_clients = "OIDC Clients"
|
||||||
policy_gate = "Policy Gate"
|
policy_gate = "Policy Gate"
|
||||||
|
total_users = "Total Users"
|
||||||
total_tenants = "Total Tenants"
|
total_tenants = "Total Tenants"
|
||||||
|
|
||||||
[ui.admin.profile]
|
[ui.admin.profile]
|
||||||
@@ -1378,6 +1390,7 @@ add = "Add"
|
|||||||
add_dialog_desc = "Select a tenant to add as a sub-tenant."
|
add_dialog_desc = "Select a tenant to add as a sub-tenant."
|
||||||
add_dialog_title = "Add Sub-tenant"
|
add_dialog_title = "Add Sub-tenant"
|
||||||
add_existing = "Add Existing Tenant"
|
add_existing = "Add Existing Tenant"
|
||||||
|
export = "Subtree CSV"
|
||||||
manage = "Manage"
|
manage = "Manage"
|
||||||
no_candidates = "No available tenants to add."
|
no_candidates = "No available tenants to add."
|
||||||
search_placeholder = "Search..."
|
search_placeholder = "Search..."
|
||||||
@@ -1399,6 +1412,7 @@ slug = "SLUG"
|
|||||||
status = "STATUS"
|
status = "STATUS"
|
||||||
type = "TYPE"
|
type = "TYPE"
|
||||||
updated = "UPDATED"
|
updated = "UPDATED"
|
||||||
|
created = "CREATED"
|
||||||
|
|
||||||
[ui.admin.users]
|
[ui.admin.users]
|
||||||
|
|
||||||
@@ -1416,6 +1430,8 @@ selected_count = "{{count}} users selected"
|
|||||||
start_upload = "Start Upload"
|
start_upload = "Start Upload"
|
||||||
tenant_resolution = "Tenant mapping"
|
tenant_resolution = "Tenant mapping"
|
||||||
title = "Bulk Actions"
|
title = "Bulk Actions"
|
||||||
|
status_placeholder = "Select status"
|
||||||
|
permission_placeholder = "Select permission"
|
||||||
|
|
||||||
[ui.admin.users.create]
|
[ui.admin.users.create]
|
||||||
back = "Back"
|
back = "Back"
|
||||||
@@ -2332,6 +2348,7 @@ title = "User Info"
|
|||||||
[ui.dev.profile.org]
|
[ui.dev.profile.org]
|
||||||
company_code = "Company Code"
|
company_code = "Company Code"
|
||||||
tenant = "Tenant"
|
tenant = "Tenant"
|
||||||
|
tenant_slug = "Tenant Slug"
|
||||||
title = "Organization Info"
|
title = "Organization Info"
|
||||||
|
|
||||||
[ui.dev.profile.role]
|
[ui.dev.profile.role]
|
||||||
@@ -2514,7 +2531,7 @@ department = "Department"
|
|||||||
email = "Email"
|
email = "Email"
|
||||||
name = "Name"
|
name = "Name"
|
||||||
tenant = "Tenant"
|
tenant = "Tenant"
|
||||||
tenant_slug = "Tenant slug"
|
tenant_slug = "Tenant Slug"
|
||||||
|
|
||||||
[ui.userfront.profile.password]
|
[ui.userfront.profile.password]
|
||||||
change = "Change"
|
change = "Change"
|
||||||
|
|||||||
@@ -588,6 +588,9 @@ notice_emphasis = "지금 한 번만"
|
|||||||
notice_suffix = "표시됩니다."
|
notice_suffix = "표시됩니다."
|
||||||
|
|
||||||
[msg.admin.api_keys.list]
|
[msg.admin.api_keys.list]
|
||||||
|
edit_scopes_desc = "CLIENT_ID는 유지하고 권한만 변경합니다."
|
||||||
|
rotate_confirm = "API 키 \"{{name}}\"의 Secret을 재발급할까요? 기존 Secret은 더 이상 사용할 수 없습니다."
|
||||||
|
rotate_secret_notice = "새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다."
|
||||||
delete_confirm = "API 키 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?"
|
delete_confirm = "API 키 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?"
|
||||||
empty = "등록된 API 키가 없습니다."
|
empty = "등록된 API 키가 없습니다."
|
||||||
fetch_error = "API 키 목록 조회에 실패했습니다."
|
fetch_error = "API 키 목록 조회에 실패했습니다."
|
||||||
@@ -685,6 +688,7 @@ description = "주요 운영 화면으로 바로 이동합니다."
|
|||||||
audit_events_24h = "최근 24시간 감사 로그"
|
audit_events_24h = "최근 24시간 감사 로그"
|
||||||
oidc_clients = "등록된 OIDC 클라이언트"
|
oidc_clients = "등록된 OIDC 클라이언트"
|
||||||
policy_gate = "정책 가이트 상태"
|
policy_gate = "정책 가이트 상태"
|
||||||
|
total_users = "전체 사용자 수"
|
||||||
total_tenants = "전체 테넌트 수"
|
total_tenants = "전체 테넌트 수"
|
||||||
|
|
||||||
[msg.admin.tenants]
|
[msg.admin.tenants]
|
||||||
@@ -694,6 +698,7 @@ delete_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?"
|
|||||||
delete_success = "테넌트가 삭제되었습니다."
|
delete_success = "테넌트가 삭제되었습니다."
|
||||||
empty = "아직 등록된 테넌트가 없습니다."
|
empty = "아직 등록된 테넌트가 없습니다."
|
||||||
fetch_error = "테넌트 목록 조회에 실패했습니다."
|
fetch_error = "테넌트 목록 조회에 실패했습니다."
|
||||||
|
export_error = "테넌트 내보내기에 실패했습니다."
|
||||||
import_empty = "임포트 파일에 테넌트 행이 없습니다."
|
import_empty = "임포트 파일에 테넌트 행이 없습니다."
|
||||||
import_error = "테넌트 임포트에 실패했습니다: {{error}}"
|
import_error = "테넌트 임포트에 실패했습니다: {{error}}"
|
||||||
import_result = "{{count}}개의 테넌트 행을 처리했습니다."
|
import_result = "{{count}}개의 테넌트 행을 처리했습니다."
|
||||||
@@ -775,6 +780,8 @@ move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니
|
|||||||
parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
|
parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
|
||||||
schema_incompatible = "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:"
|
schema_incompatible = "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:"
|
||||||
schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:"
|
schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:"
|
||||||
|
status_placeholder = "상태 선택"
|
||||||
|
permission_placeholder = "권한 선택"
|
||||||
update_success = "사용자 정보가 일괄 업데이트되었습니다."
|
update_success = "사용자 정보가 일괄 업데이트되었습니다."
|
||||||
|
|
||||||
[msg.admin.users.create]
|
[msg.admin.users.create]
|
||||||
@@ -1460,6 +1467,10 @@ title = "API 키 생성 완료"
|
|||||||
|
|
||||||
[ui.admin.api_keys.list]
|
[ui.admin.api_keys.list]
|
||||||
add = "API 키 생성"
|
add = "API 키 생성"
|
||||||
|
edit_scopes = "권한 수정"
|
||||||
|
rotate_secret = "Secret 재발급"
|
||||||
|
rotate_secret_done = "Secret 재발급 완료"
|
||||||
|
save_scopes = "권한 저장"
|
||||||
title = "API 키 관리 (M2M)"
|
title = "API 키 관리 (M2M)"
|
||||||
|
|
||||||
[ui.admin.api_keys.list.breadcrumb]
|
[ui.admin.api_keys.list.breadcrumb]
|
||||||
@@ -1606,6 +1617,7 @@ view_audit_logs = "감사 로그 보기"
|
|||||||
audit_events_24h = "24시간 이벤트"
|
audit_events_24h = "24시간 이벤트"
|
||||||
oidc_clients = "OIDC 클라이언트"
|
oidc_clients = "OIDC 클라이언트"
|
||||||
policy_gate = "정책 게이트"
|
policy_gate = "정책 게이트"
|
||||||
|
total_users = "전체 사용자 수"
|
||||||
total_tenants = "전체 테넌트 수"
|
total_tenants = "전체 테넌트 수"
|
||||||
|
|
||||||
[ui.admin.profile]
|
[ui.admin.profile]
|
||||||
@@ -1841,6 +1853,7 @@ add = "하위 테넌트 추가"
|
|||||||
add_dialog_desc = "하위 테넌트로 추가할 테넌트를 선택하세요."
|
add_dialog_desc = "하위 테넌트로 추가할 테넌트를 선택하세요."
|
||||||
add_dialog_title = "하위 테넌트 추가"
|
add_dialog_title = "하위 테넌트 추가"
|
||||||
add_existing = "기존 테넌트 추가"
|
add_existing = "기존 테넌트 추가"
|
||||||
|
export = "하위 조직 CSV"
|
||||||
manage = "관리"
|
manage = "관리"
|
||||||
no_candidates = "추가 가능한 테넌트가 없습니다."
|
no_candidates = "추가 가능한 테넌트가 없습니다."
|
||||||
search_placeholder = "검색..."
|
search_placeholder = "검색..."
|
||||||
@@ -1862,6 +1875,8 @@ slug = "SLUG"
|
|||||||
status = "STATUS"
|
status = "STATUS"
|
||||||
type = "유형"
|
type = "유형"
|
||||||
updated = "UPDATED"
|
updated = "UPDATED"
|
||||||
|
created = "CREATED"
|
||||||
|
created = "CREATED"
|
||||||
|
|
||||||
[ui.admin.users]
|
[ui.admin.users]
|
||||||
|
|
||||||
@@ -1879,6 +1894,8 @@ selected_count = "{{count}}명 선택됨"
|
|||||||
start_upload = "업로드 시작"
|
start_upload = "업로드 시작"
|
||||||
tenant_resolution = "테넌트 매핑"
|
tenant_resolution = "테넌트 매핑"
|
||||||
title = "일괄 작업"
|
title = "일괄 작업"
|
||||||
|
status_placeholder = "상태 선택"
|
||||||
|
permission_placeholder = "권한 선택"
|
||||||
|
|
||||||
[ui.admin.users.create]
|
[ui.admin.users.create]
|
||||||
back = "목록으로 돌아가기"
|
back = "목록으로 돌아가기"
|
||||||
@@ -2757,6 +2774,7 @@ title = "사용자 정보"
|
|||||||
[ui.dev.profile.org]
|
[ui.dev.profile.org]
|
||||||
company_code = "회사 코드"
|
company_code = "회사 코드"
|
||||||
tenant = "테넌트"
|
tenant = "테넌트"
|
||||||
|
tenant_slug = "테넌트 Slug"
|
||||||
title = "조직 정보"
|
title = "조직 정보"
|
||||||
|
|
||||||
[ui.dev.profile.role]
|
[ui.dev.profile.role]
|
||||||
@@ -2938,7 +2956,7 @@ department = "소속"
|
|||||||
email = "이메일"
|
email = "이메일"
|
||||||
name = "이름"
|
name = "이름"
|
||||||
tenant = "소속 테넌트"
|
tenant = "소속 테넌트"
|
||||||
tenant_slug = "테넌트 slug"
|
tenant_slug = "테넌트 Slug"
|
||||||
|
|
||||||
[ui.userfront.profile.password]
|
[ui.userfront.profile.password]
|
||||||
change = "비밀번호 변경"
|
change = "비밀번호 변경"
|
||||||
|
|||||||
@@ -451,6 +451,9 @@ notice_emphasis = ""
|
|||||||
notice_suffix = ""
|
notice_suffix = ""
|
||||||
|
|
||||||
[msg.admin.api_keys.list]
|
[msg.admin.api_keys.list]
|
||||||
|
edit_scopes_desc = ""
|
||||||
|
rotate_confirm = ""
|
||||||
|
rotate_secret_notice = ""
|
||||||
delete_confirm = ""
|
delete_confirm = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
fetch_error = ""
|
fetch_error = ""
|
||||||
@@ -548,6 +551,7 @@ description = ""
|
|||||||
audit_events_24h = ""
|
audit_events_24h = ""
|
||||||
oidc_clients = ""
|
oidc_clients = ""
|
||||||
policy_gate = ""
|
policy_gate = ""
|
||||||
|
total_users = ""
|
||||||
total_tenants = ""
|
total_tenants = ""
|
||||||
|
|
||||||
[msg.admin.tenants]
|
[msg.admin.tenants]
|
||||||
@@ -557,6 +561,7 @@ delete_confirm = ""
|
|||||||
delete_success = ""
|
delete_success = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
fetch_error = ""
|
fetch_error = ""
|
||||||
|
export_error = ""
|
||||||
import_empty = ""
|
import_empty = ""
|
||||||
import_error = ""
|
import_error = ""
|
||||||
import_result = ""
|
import_result = ""
|
||||||
@@ -638,6 +643,8 @@ move_success = ""
|
|||||||
parsed_count = ""
|
parsed_count = ""
|
||||||
schema_incompatible = ""
|
schema_incompatible = ""
|
||||||
schema_missing = ""
|
schema_missing = ""
|
||||||
|
status_placeholder = ""
|
||||||
|
permission_placeholder = ""
|
||||||
update_success = ""
|
update_success = ""
|
||||||
|
|
||||||
[msg.admin.users.create]
|
[msg.admin.users.create]
|
||||||
@@ -1323,6 +1330,10 @@ title = ""
|
|||||||
|
|
||||||
[ui.admin.api_keys.list]
|
[ui.admin.api_keys.list]
|
||||||
add = ""
|
add = ""
|
||||||
|
edit_scopes = ""
|
||||||
|
rotate_secret = ""
|
||||||
|
rotate_secret_done = ""
|
||||||
|
save_scopes = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.admin.api_keys.list.breadcrumb]
|
[ui.admin.api_keys.list.breadcrumb]
|
||||||
@@ -1469,6 +1480,7 @@ view_audit_logs = ""
|
|||||||
audit_events_24h = ""
|
audit_events_24h = ""
|
||||||
oidc_clients = ""
|
oidc_clients = ""
|
||||||
policy_gate = ""
|
policy_gate = ""
|
||||||
|
total_users = ""
|
||||||
total_tenants = ""
|
total_tenants = ""
|
||||||
|
|
||||||
[ui.admin.profile]
|
[ui.admin.profile]
|
||||||
@@ -1487,6 +1499,9 @@ seed_badge = ""
|
|||||||
title = ""
|
title = ""
|
||||||
view_org_chart = ""
|
view_org_chart = ""
|
||||||
|
|
||||||
|
[ui.admin.tenants.sub]
|
||||||
|
export = ""
|
||||||
|
|
||||||
[ui.admin.tenants.view]
|
[ui.admin.tenants.view]
|
||||||
hierarchy = ""
|
hierarchy = ""
|
||||||
list = ""
|
list = ""
|
||||||
@@ -1740,6 +1755,7 @@ slug = ""
|
|||||||
status = ""
|
status = ""
|
||||||
type = ""
|
type = ""
|
||||||
updated = ""
|
updated = ""
|
||||||
|
created = ""
|
||||||
|
|
||||||
[ui.admin.users]
|
[ui.admin.users]
|
||||||
|
|
||||||
@@ -1757,6 +1773,8 @@ selected_count = ""
|
|||||||
start_upload = ""
|
start_upload = ""
|
||||||
tenant_resolution = ""
|
tenant_resolution = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
status_placeholder = ""
|
||||||
|
permission_placeholder = ""
|
||||||
|
|
||||||
[ui.admin.users.create]
|
[ui.admin.users.create]
|
||||||
back = ""
|
back = ""
|
||||||
@@ -2636,6 +2654,7 @@ title = ""
|
|||||||
[ui.dev.profile.org]
|
[ui.dev.profile.org]
|
||||||
company_code = ""
|
company_code = ""
|
||||||
tenant = ""
|
tenant = ""
|
||||||
|
tenant_slug = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.dev.profile.role]
|
[ui.dev.profile.role]
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
commonTableBodyClass,
|
||||||
|
commonTableCaptionClass,
|
||||||
|
commonTableCellClass,
|
||||||
|
commonTableClass,
|
||||||
|
commonTableFooterClass,
|
||||||
|
commonTableHeadClass,
|
||||||
|
commonTableHeaderClass,
|
||||||
|
commonTableRowClass,
|
||||||
|
commonTableWrapperClass,
|
||||||
|
} from "../../../../common/ui/table";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
const Table = React.forwardRef<
|
||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="relative w-full overflow-auto">
|
<div className={commonTableWrapperClass}>
|
||||||
<table
|
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
Table.displayName = "Table";
|
Table.displayName = "Table";
|
||||||
@@ -19,7 +26,11 @@ const TableHeader = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
<thead
|
||||||
|
ref={ref}
|
||||||
|
className={cn(commonTableHeaderClass, className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
TableHeader.displayName = "TableHeader";
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
@@ -27,11 +38,7 @@ const TableBody = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tbody
|
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableBody.displayName = "TableBody";
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tfoot
|
<tfoot
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("bg-muted/50 font-medium text-foreground", className)}
|
className={cn(commonTableFooterClass, className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -51,14 +58,7 @@ const TableRow = React.forwardRef<
|
|||||||
HTMLTableRowElement,
|
HTMLTableRowElement,
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableRow.displayName = "TableRow";
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
@@ -66,14 +66,7 @@ const TableHead = React.forwardRef<
|
|||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<th
|
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableHead.displayName = "TableHead";
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
@@ -81,11 +74,7 @@ const TableCell = React.forwardRef<
|
|||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td
|
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("p-6 align-middle text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableCell.displayName = "TableCell";
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<caption
|
<caption
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
className={cn(commonTableCaptionClass, className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -15,6 +15,12 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"react": ["./node_modules/@types/react/index.d.ts"],
|
||||||
|
"react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime.d.ts"],
|
||||||
|
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -24,6 +30,6 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "../common/**/*.ts"],
|
||||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ mkdir -p reports
|
|||||||
rm -rf adminfront/node_modules
|
rm -rf adminfront/node_modules
|
||||||
|
|
||||||
tmp_dir="$(mktemp -d /tmp/baron-sso-adminfront-tests.XXXXXX)"
|
tmp_dir="$(mktemp -d /tmp/baron-sso-adminfront-tests.XXXXXX)"
|
||||||
playwright_browsers_path="$tmp_dir/ms-playwright"
|
|
||||||
mkdir -p "$tmp_dir/scripts"
|
mkdir -p "$tmp_dir/scripts"
|
||||||
cp "$repo_root/scripts/playwrightHostDeps.cjs" "$tmp_dir/scripts/"
|
cp "$repo_root/scripts/playwrightHostDeps.cjs" "$tmp_dir/scripts/"
|
||||||
|
|
||||||
@@ -162,7 +161,7 @@ fi
|
|||||||
set +e
|
set +e
|
||||||
(
|
(
|
||||||
cd "$tmp_dir/adminfront"
|
cd "$tmp_dir/adminfront"
|
||||||
PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" "${playwright_install_cmd[@]}"
|
"${playwright_install_cmd[@]}"
|
||||||
) 2>&1 | tee reports/adminfront-provision.log
|
) 2>&1 | tee reports/adminfront-provision.log
|
||||||
provision_exit_code=${PIPESTATUS[0]}
|
provision_exit_code=${PIPESTATUS[0]}
|
||||||
set -e
|
set -e
|
||||||
@@ -197,7 +196,7 @@ fi
|
|||||||
echo "==> adminfront using PORT=$port"
|
echo "==> adminfront using PORT=$port"
|
||||||
(
|
(
|
||||||
cd "$tmp_dir/adminfront"
|
cd "$tmp_dir/adminfront"
|
||||||
PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" \
|
PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \
|
||||||
node ./node_modules/playwright/cli.js test "${playwright_project_args[@]}"
|
node ./node_modules/playwright/cli.js test "${playwright_project_args[@]}"
|
||||||
) 2>&1 | tee reports/adminfront-test.log
|
) 2>&1 | tee reports/adminfront-test.log
|
||||||
test_exit_code=${PIPESTATUS[0]}
|
test_exit_code=${PIPESTATUS[0]}
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ department = "Department"
|
|||||||
email = "Email"
|
email = "Email"
|
||||||
name = "Name"
|
name = "Name"
|
||||||
tenant = "Tenant"
|
tenant = "Tenant"
|
||||||
tenant_slug = "Tenant slug"
|
tenant_slug = "Tenant Slug"
|
||||||
|
|
||||||
[ui.userfront.profile.password]
|
[ui.userfront.profile.password]
|
||||||
change = "Change"
|
change = "Change"
|
||||||
|
|||||||
@@ -821,7 +821,7 @@ department = "소속"
|
|||||||
email = "이메일"
|
email = "이메일"
|
||||||
name = "이름"
|
name = "이름"
|
||||||
tenant = "소속 테넌트"
|
tenant = "소속 테넌트"
|
||||||
tenant_slug = "테넌트 slug"
|
tenant_slug = "테넌트 Slug"
|
||||||
|
|
||||||
[ui.userfront.profile.password]
|
[ui.userfront.profile.password]
|
||||||
change = "비밀번호 변경"
|
change = "비밀번호 변경"
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
cli_config:
|
cli_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -276,6 +276,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.5"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -328,18 +336,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -661,26 +669,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.30.0"
|
version: "1.26.3"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.7"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.12"
|
||||||
toml:
|
toml:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user