diff --git a/adminfront/src/components/ui/table.tsx b/adminfront/src/components/ui/table.tsx index 62c33432..0b0022a5 100644 --- a/adminfront/src/components/ui/table.tsx +++ b/adminfront/src/components/ui/table.tsx @@ -1,16 +1,23 @@ import * as React from "react"; +import { + commonTableBodyClass, + commonTableCaptionClass, + commonTableCellClass, + commonTableClass, + commonTableFooterClass, + commonTableHeadClass, + commonTableHeaderClass, + commonTableRowClass, + commonTableWrapperClass, +} from "../../../../common/ui/table"; import { cn } from "../../lib/utils"; const Table = React.forwardRef< HTMLTableElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
- +
+
)); Table.displayName = "Table"; @@ -19,7 +26,11 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -27,11 +38,7 @@ const TableBody = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableBody.displayName = "TableBody"; @@ -41,7 +48,7 @@ const TableFooter = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -51,14 +58,7 @@ const TableRow = React.forwardRef< HTMLTableRowElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableRow.displayName = "TableRow"; @@ -66,14 +66,7 @@ const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
+ )); TableHead.displayName = "TableHead"; @@ -81,11 +74,7 @@ const TableCell = React.forwardRef< HTMLTableCellElement, React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableCell.displayName = "TableCell"; @@ -95,7 +84,7 @@ const TableCaption = React.forwardRef< >(({ className, ...props }, ref) => (
)); diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index a5b845de..7707ea03 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -27,6 +27,10 @@ import { TableHeader, TableRow, } from "../../components/ui/table"; +import { + commonTableShellClass, + commonTableViewportClass, +} from "../../../../common/ui/table"; import type { AuditLog } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; @@ -254,8 +258,8 @@ function AuditLogsPage() { )) )} -
-
+
+
diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 5aa51712..cfc211a6 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -29,10 +29,19 @@ import { import * as React from "react"; import { Link, useNavigate } from "react-router-dom"; import { - type SortConfig, - type SortResolverMap, + SortableTableHead, + sortableTableHeadBaseClassName, + sortableTableHeaderClassName, +} from "../../../../../common/core/components/sort"; +import { + commonTableShellClass, + commonTableViewportClass, +} from "../../../../../common/ui/table"; +import { sortItems, toggleSort, + type SortConfig, + type SortResolverMap, } from "../../../../../common/core/utils"; import { RoleGuard } from "../../../components/auth/RoleGuard"; import { Badge } from "../../../components/ui/badge"; @@ -257,7 +266,10 @@ function TenantListPage() { const [selectedIds, setSelectedIds] = React.useState([]); const [search, setSearch] = React.useState(""); const [sortConfig, setSortConfig] = - React.useState | null>(null); + React.useState | null>({ + key: "createdAt", + direction: "desc", + }); const fileInputRef = React.useRef(null); const [importMessage, setImportMessage] = React.useState(""); const [previewRows, setPreviewRows] = React.useState< @@ -444,9 +456,8 @@ function TenantListPage() { } if ( - profile && profileRole === "tenant_admin" && - (profile.manageableTenants?.length ?? 0) <= 1 + (profile?.manageableTenants?.length ?? 0) <= 1 ) { return null; } @@ -487,17 +498,6 @@ function TenantListPage() { setSortConfig((current) => toggleSort(current, key)); }; - const getSortIcon = (key: TenantSortKey) => { - if (!sortConfig || sortConfig.key !== key) { - return ; - } - return sortConfig.direction === "asc" ? ( - - ) : ( - - ); - }; - const deletableTenants = React.useMemo( () => allTenants.filter((tenant) => !isSeedTenant(tenant)), [allTenants], diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 3aa97196..dd6840f4 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -24,6 +24,15 @@ import { sortItems, toggleSort, } from "../../../../common/core/utils"; +import { + SortableTableHead, + sortableTableHeadBaseClassName, + sortableTableHeaderClassName, +} from "../../../../common/core/components/sort"; +import { + commonTableShellClass, + commonTableViewportClass, +} from "../../../../common/ui/table"; import { Button } from "../../components/ui/button"; import { Card, @@ -294,17 +303,6 @@ function UserListPage() { setSortConfig((current) => toggleSort(current, key)); }; - const getSortIcon = (key: UserSortKey) => { - if (!sortConfig || sortConfig.key !== key) { - return ; - } - return sortConfig.direction === "asc" ? ( - - ) : ( - - ); - }; - const total = query.data?.total ?? 0; const totalPages = Math.ceil(total / limit); const canPromoteSuperAdmin = isSuperAdminRole(profile?.role); @@ -603,12 +601,14 @@ function UserListPage() { )} -
-
+
+
- + - + visibleColumns[field.key] !== false && ( - requestSort(field.key)} - > -
- {field.label} - {getSortIcon(field.key)} -
-
+ className="whitespace-nowrap" + label={field.label} + onSort={requestSort} + sortConfig={sortConfig} + sortKey={field.key} + /> ), )} - requestSort("createdAt")} - > -
- {t("ui.admin.users.list.table.created", "CREATED")} - {getSortIcon("createdAt")} -
-
+
{query.isLoading && ( {t("msg.common.loading", "로딩 중...")} @@ -726,7 +722,7 @@ function UserListPage() { {!query.isLoading && items.length === 0 && ( {t( diff --git a/adminfront/tsconfig.app.json b/adminfront/tsconfig.app.json index 91425f60..b9a4130a 100644 --- a/adminfront/tsconfig.app.json +++ b/adminfront/tsconfig.app.json @@ -15,6 +15,13 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "lucide-react": ["./node_modules/lucide-react"], + "react": ["./node_modules/@types/react/index.d.ts"], + "react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime.d.ts"], + "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"] + }, /* Linting */ "strict": true, @@ -24,6 +31,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"], + "include": ["src", "../common/**/*.ts", "../common/**/*.tsx"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } diff --git a/adminfront/vite.config.ts b/adminfront/vite.config.ts index ee24ae9f..3307996f 100644 --- a/adminfront/vite.config.ts +++ b/adminfront/vite.config.ts @@ -1,4 +1,5 @@ import react from "@vitejs/plugin-react"; +import path from "node:path"; import { defineConfig } from "vite"; const buildOutDir = @@ -6,6 +7,20 @@ const buildOutDir = export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "lucide-react": path.resolve(__dirname, "node_modules/lucide-react"), + react: path.resolve(__dirname, "node_modules/react"), + "react/jsx-dev-runtime": path.resolve( + __dirname, + "node_modules/react/jsx-dev-runtime.js", + ), + "react/jsx-runtime": path.resolve( + __dirname, + "node_modules/react/jsx-runtime.js", + ), + }, + }, envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"], cacheDir: process.env.ADMINFRONT_VITE_CACHE_DIR ?? diff --git a/common/.gitkeep b/common/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/common/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/common/core/components/sort/SortableTableHead.tsx b/common/core/components/sort/SortableTableHead.tsx new file mode 100644 index 00000000..e9c89117 --- /dev/null +++ b/common/core/components/sort/SortableTableHead.tsx @@ -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 ( + + ); +} + +function SortDescendingIcon() { + return ( + + ); +} + +function SortIdleIcon() { + return ( + + ); +} + +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["aria-sort"] { + if (!isActive || direction === null) { + return "none"; + } + return direction === "asc" ? "ascending" : "descending"; +} + +type SortableTableHeadProps = Omit< + ThHTMLAttributes, + "children" +> & { + align?: SortableTableHeadAlign; + contentClassName?: string; + disabled?: boolean; + label: ReactNode; + onSort: (key: Key) => void; + sortConfig: SortConfig | null; + sortKey: Key; +}; + +export function SortableTableHead({ + align = "left", + className = "", + contentClassName = "", + disabled = false, + label, + onSort, + sortConfig, + sortKey, + ...props +}: SortableTableHeadProps) { + const isActive = sortConfig?.key === sortKey; + const direction = isActive ? sortConfig?.direction ?? null : null; + + return ( +
+ ); +} diff --git a/common/core/components/sort/index.ts b/common/core/components/sort/index.ts new file mode 100644 index 00000000..a9d87fb9 --- /dev/null +++ b/common/core/components/sort/index.ts @@ -0,0 +1 @@ +export * from "./SortableTableHead"; diff --git a/common/locales/en.toml b/common/locales/en.toml index 5e8ae0a4..b57b3042 100644 --- a/common/locales/en.toml +++ b/common/locales/en.toml @@ -15,6 +15,7 @@ apply = "Apply" actions = "Actions" add = "Add" all = "All" +apply = "Apply" admin_only = "Admin Only" apply = "Apply" approve = "Approve" diff --git a/common/locales/ko.toml b/common/locales/ko.toml index 2e003bd1..c77ac6d6 100644 --- a/common/locales/ko.toml +++ b/common/locales/ko.toml @@ -15,6 +15,7 @@ apply = "적용" actions = "액션" add = "추가" all = "전체" +apply = "적용" admin_only = "관리자 전용" apply = "적용" approve = "승인" diff --git a/common/locales/template.toml b/common/locales/template.toml index 310569b0..0c4f4955 100644 --- a/common/locales/template.toml +++ b/common/locales/template.toml @@ -15,6 +15,7 @@ apply = "Apply" actions = "" add = "" all = "" +apply = "" admin_only = "" apply = "" approve = "" diff --git a/common/ui/table.ts b/common/ui/table.ts new file mode 100644 index 00000000..feafc531 --- /dev/null +++ b/common/ui/table.ts @@ -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"; diff --git a/devfront/src/components/ui/table.tsx b/devfront/src/components/ui/table.tsx index b20952d6..0b0022a5 100644 --- a/devfront/src/components/ui/table.tsx +++ b/devfront/src/components/ui/table.tsx @@ -1,16 +1,23 @@ import * as React from "react"; +import { + commonTableBodyClass, + commonTableCaptionClass, + commonTableCellClass, + commonTableClass, + commonTableFooterClass, + commonTableHeadClass, + commonTableHeaderClass, + commonTableRowClass, + commonTableWrapperClass, +} from "../../../../common/ui/table"; import { cn } from "../../lib/utils"; const Table = React.forwardRef< HTMLTableElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
-
+ +
+
+
)); Table.displayName = "Table"; @@ -19,7 +26,11 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -27,11 +38,7 @@ const TableBody = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableBody.displayName = "TableBody"; @@ -41,7 +48,7 @@ const TableFooter = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -51,14 +58,7 @@ const TableRow = React.forwardRef< HTMLTableRowElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableRow.displayName = "TableRow"; @@ -66,14 +66,7 @@ const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
+ )); TableHead.displayName = "TableHead"; @@ -81,11 +74,7 @@ const TableCell = React.forwardRef< HTMLTableCellElement, React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableCell.displayName = "TableCell"; @@ -95,7 +84,7 @@ const TableCaption = React.forwardRef< >(({ className, ...props }, ref) => (
)); diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index 61dced27..4c3666ac 100644 --- a/devfront/src/features/audit/AuditLogsPage.tsx +++ b/devfront/src/features/audit/AuditLogsPage.tsx @@ -28,6 +28,10 @@ import { TableHeader, TableRow, } from "../../components/ui/table"; +import { + commonTableShellClass, + commonTableViewportClass, +} from "../../../../common/ui/table"; import type { DevAuditLog } from "../../lib/devApi"; import { fetchDevAuditLogs } from "../../lib/devApi"; import { t } from "../../lib/i18n"; @@ -280,157 +284,171 @@ function AuditLogsPage() { : "" } > - - - - - {t("ui.dev.audit.table.time", "Time")} - - - {t("ui.dev.audit.table.actor", "Actor")} - - - {t("ui.dev.audit.table.action", "Action")} - - - {t("ui.dev.audit.table.target", "Target")} - - - {t("ui.dev.audit.table.status", "Status")} - - - - - - {query.isLoading && logs.length === 0 ? ( - - - {t("msg.dev.audit.loading", "Loading audit logs...")} - - - ) : logs.length === 0 ? ( - - - {t("msg.dev.audit.empty", "No audit logs found.")} - - - ) : ( - logs.map((row, index) => { - const details = parseDetails(row.details); - const actionLabel = details.action || row.event_type; - const targetValue = details.target_id || "-"; - const rowKey = `${row.event_id}-${row.timestamp}-${index}`; - const expanded = Boolean(expandedRows[rowKey]); - return ( - - - - {formatDateTime(row.timestamp)} - - -
- {row.user_id || "-"} - {row.user_id ? ( +
+
+
+ + + + {t("ui.dev.audit.table.time", "Time")} + + + {t("ui.dev.audit.table.actor", "Actor")} + + + {t("ui.dev.audit.table.action", "Action")} + + + {t("ui.dev.audit.table.target", "Target")} + + + {t("ui.dev.audit.table.status", "Status")} + + + + + + {query.isLoading && logs.length === 0 ? ( + + + {t("msg.dev.audit.loading", "Loading audit logs...")} + + + ) : logs.length === 0 ? ( + + + {t("msg.dev.audit.empty", "No audit logs found.")} + + + ) : ( + logs.map((row, index) => { + const details = parseDetails(row.details); + const actionLabel = details.action || row.event_type; + const targetValue = details.target_id || "-"; + const rowKey = `${row.event_id}-${row.timestamp}-${index}`; + const expanded = Boolean(expandedRows[rowKey]); + return ( + + + + {formatDateTime(row.timestamp)} + + +
+ {row.user_id || "-"} + {row.user_id ? ( + + ) : null} +
+
+ + {actionLabel} + + +
+ + {targetValue} + + {targetValue !== "-" ? ( + + ) : null} +
+
+ + + {row.status} + + + - ) : null} - - - - {actionLabel} - - -
- {targetValue} - {targetValue !== "-" ? ( - - ) : null} -
-
- - - {row.status} - - - - - -
- {expanded ? ( - - -
-
-
- Request ID:{" "} - {formatValue(details.request_id)} +
+
+
+ Request ID:{" "} + {formatValue(details.request_id)} +
+
+ Method: {formatValue(details.method)} +
+
+ Path: {formatValue(details.path)} +
+
+ Tenant: {formatValue(details.tenant_id)} +
+
+
+
+ Before: {formatValue(details.before)} +
+
+ After: {formatValue(details.after)} +
+
+ Error: {formatValue(details.error)} +
+
-
- Method: {formatValue(details.method)} -
-
Path: {formatValue(details.path)}
-
- Tenant: {formatValue(details.tenant_id)} -
-
-
-
- Before: {formatValue(details.before)} -
-
After: {formatValue(details.after)}
-
Error: {formatValue(details.error)}
-
-
- - - ) : null} - - ); - }) - )} - -
+ + + ) : null} + + ); + }) + )} + +
+
+
{query.hasNextPage ? ( diff --git a/devfront/src/features/auth/LoginPage.tsx b/devfront/src/features/auth/LoginPage.tsx index 293f8eaa..03d80002 100644 --- a/devfront/src/features/auth/LoginPage.tsx +++ b/devfront/src/features/auth/LoginPage.tsx @@ -16,6 +16,11 @@ import { canStartBrowserPkceLogin } from "../../lib/authConfig"; const insecurePkceMessage = "이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요."; +function isPkceSetupFailure(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message); +} + function LoginPage() { const auth = useAuth(); const navigate = useNavigate(); @@ -55,11 +60,19 @@ function LoginPage() { } autoStartedRef.current = true; - void auth.signinRedirect({ - state: { - returnTo, - }, - }); + void auth + .signinRedirect({ + state: { + returnTo, + }, + }) + .catch((error) => { + if (isPkceSetupFailure(error)) { + setLoginError(insecurePkceMessage); + return; + } + console.error("Auto login redirect failed", error); + }); }, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]); const handleSSOLogin = async () => { @@ -75,6 +88,10 @@ function LoginPage() { }, }); } catch (error) { + if (isPkceSetupFailure(error)) { + setLoginError(insecurePkceMessage); + return; + } console.error("Redirect login failed", error); } }; diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 0de25b12..9c70cef8 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -28,6 +28,10 @@ import { TableHeader, TableRow, } from "../../components/ui/table"; +import { + commonTableShellClass, + commonTableViewportClass, +} from "../../../../common/ui/table"; import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { cn } from "../../lib/utils"; @@ -272,7 +276,7 @@ function ClientConsentsPage() { - +
@@ -430,146 +434,159 @@ function ClientConsentsPage() { )} - - - - - {t("ui.dev.clients.consents.table.user", "User")} - - - {t("ui.dev.clients.consents.table.tenant", "Tenant")} - - - {t("ui.dev.clients.consents.table.status", "Status")} - - - {t("ui.dev.clients.consents.table.scopes", "Granted Scopes")} - - - {t( - "ui.dev.clients.consents.table.first_granted", - "First Granted", - )} - - - {t( - "ui.dev.clients.consents.table.last_auth", - "Last Authenticated / Revoked", - )} - - - {t("ui.dev.clients.consents.table.action", "Action")} - - - - - {filteredRows.length === 0 && !isLoading && !error ? ( - - -
- -

- {t( - "msg.dev.clients.consents.empty", - "No consents found.", - )} -

-
-
-
- ) : ( - filteredRows.map((row) => ( - - -
-
- {(row.userName || row.subject) - .slice(0, 2) - .toUpperCase()} -
-
- - {row.userName || - t("ui.dev.clients.consents.subject", "Subject")} - - - {row.subject} - -
-
-
- -
- - {row.tenantName || t("ui.common.na", "N/A")} - - - {row.tenantId} - -
-
- - {row.status === "active" ? ( - - {t("ui.common.status.active", "Active")} - - ) : ( - - {t("ui.dev.clients.consents.status_revoked", "Revoked")} - +
+
+
+ + + + {t("ui.dev.clients.consents.table.user", "User")} + + + {t("ui.dev.clients.consents.table.tenant", "Tenant")} + + + {t("ui.dev.clients.consents.table.status", "Status")} + + + {t( + "ui.dev.clients.consents.table.scopes", + "Granted Scopes", )} - - -
- {row.grantedScopes.map((scope) => ( - - {scope} - - ))} -
-
- - {new Date(row.createdAt).toLocaleString()} - - - {row.status === "revoked" && row.deletedAt ? ( - - {t("ui.dev.clients.consents.revoked_at", "Revoked: ")} - {new Date(row.deletedAt).toLocaleString()} - - ) : row.authenticatedAt ? ( - new Date(row.authenticatedAt).toLocaleString() - ) : ( - "-" +
+ + {t( + "ui.dev.clients.consents.table.first_granted", + "First Granted", )} - - - {row.status === "active" && ( - + + + {t( + "ui.dev.clients.consents.table.last_auth", + "Last Authenticated / Revoked", )} - + + + {t("ui.dev.clients.consents.table.action", "Action")} +
- )) - )} - -
+ + + {filteredRows.length === 0 && !isLoading && !error ? ( + + +
+ +

+ {t( + "msg.dev.clients.consents.empty", + "No consents found.", + )} +

+
+
+
+ ) : ( + filteredRows.map((row) => ( + + +
+
+ {(row.userName || row.subject) + .slice(0, 2) + .toUpperCase()} +
+
+ + {row.userName || + t("ui.dev.clients.consents.subject", "Subject")} + + + {row.subject} + +
+
+
+ +
+ + {row.tenantName || t("ui.common.na", "N/A")} + + + {row.tenantId} + +
+
+ + {row.status === "active" ? ( + + {t("ui.common.status.active", "Active")} + + ) : ( + + {t( + "ui.dev.clients.consents.status_revoked", + "Revoked", + )} + + )} + + +
+ {row.grantedScopes.map((scope) => ( + + {scope} + + ))} +
+
+ + {new Date(row.createdAt).toLocaleString()} + + + {row.status === "revoked" && row.deletedAt ? ( + + {t( + "ui.dev.clients.consents.revoked_at", + "Revoked: ", + )} + {new Date(row.deletedAt).toLocaleString()} + + ) : row.authenticatedAt ? ( + new Date(row.authenticatedAt).toLocaleString() + ) : ( + "-" + )} + + + {row.status === "active" && ( + + )} + +
+ )) + )} +
+
+
+

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

- {isHeadlessLogin - ? t( - "msg.dev.clients.details.secret_not_applicable_headless", - "이 앱은 Headless Login용 signed key 인증을 사용하므로 Client Secret을 사용하지 않습니다.", - ) - : t( - "msg.dev.clients.details.secret_not_applicable", - "PKCE 앱에는 Client Secret이 없습니다.", - )} + {t( + "msg.dev.clients.details.secret_not_applicable", + "PKCE 앱에는 Client Secret이 없습니다.", + )}

) : null} diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 866a7a1c..d9b50101 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -1,18 +1,18 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { - ArrowDown, - ArrowUp, - ArrowUpDown, - BookOpenText, - Filter, - Plus, - Search, - X, -} from "lucide-react"; +import { BookOpenText, Filter, Plus, Search, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate } from "react-router-dom"; +import { + SortableTableHead, + sortableTableHeadBaseClassName, + sortableTableHeaderClassName, +} from "../../../../common/core/components/sort"; +import { + commonTableShellClass, + commonTableViewportClass, +} from "../../../../common/ui/table"; import { type SortConfig, type SortResolverMap, @@ -123,7 +123,10 @@ function ClientsPage() { const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); const [sortConfig, setSortConfig] = - useState | null>(null); + useState | null>({ + key: "createdAt", + direction: "desc", + }); const clients = data?.items || []; const clientSortResolvers = useMemo< @@ -230,18 +233,6 @@ function ClientsPage() { setSortConfig((current) => toggleSort(current, key)); }; - const getSortIcon = (key: ClientSortKey) => { - if (!sortConfig || sortConfig.key !== key) { - return ; - } - - return sortConfig.direction === "asc" ? ( - - ) : ( - - ); - }; - if (auth.isLoading || !hasAccessToken || isLoading) { return (
@@ -433,246 +424,235 @@ function ClientsPage() { - -
+ +
{t("ui.dev.clients.list.title", "클라이언트 목록")} - {canCreateClient && ( -
- -
- )} -
-
- - - - - requestSort("application")} - > -
- {t("ui.dev.clients.table.application", "애플리케이션")} - {getSortIcon("application")} -
-
- requestSort("id")} - > -
- {t("ui.dev.clients.table.client_id", "Client ID")} - {getSortIcon("id")} -
-
- requestSort("type")} - > -
- {t("ui.dev.clients.table.type", "유형")} - {getSortIcon("type")} -
-
- requestSort("status")} - > -
- {t("ui.dev.clients.table.status", "상태")} - {getSortIcon("status")} -
-
- requestSort("createdAt")} - > -
- {t("ui.dev.clients.table.created_at", "생성일")} - {getSortIcon("createdAt")} -
-
- - {t("ui.dev.clients.table.actions", "액션")} - -
-
- - {!hasFilterResult && ( - - -
-

- {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가 없습니다.", - )} -

-
-

- {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가 표시됩니다.", - )} -

- {!isFilteredOut && canCreateClient && ( - - )} - {!isFilteredOut && canRequestDeveloperAccess && ( - - )} -
-
-
-
- )} - {filteredClients.map((client) => ( - - - - -
-

- {client.name || - t("ui.dev.clients.untitled", "Untitled")} -

-

- {t("ui.dev.clients.tenant_scoped", "Tenant-scoped")} -

-
- -
- -
- - {client.id} - -
-
- -
- - {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")} - -
-
- - - {client.status === "active" - ? t("ui.common.status.active", "Active") - : t("ui.common.status.inactive", "Inactive")} - - - - {client.createdAt - ? new Date(client.createdAt).toLocaleDateString() - : "-"} - - -
- -
-
-
- ))} -
-
-
- + {t( "msg.dev.clients.showing", - "Showing {{shown}} of {{total}} clients", - { shown: filteredClients.length, total: totalClients }, + "총 {{shown}}개의 애플리케이션이 등록되어 있습니다.", + { shown: totalClients }, )} - -
- -
+ {canCreateClient && ( +
+
+ )} + + +
+
+ + + + + + + + + + {t("ui.dev.clients.table.actions", "액션")} + + + + + {!hasFilterResult && ( + + +
+

+ {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가 없습니다.", + )} +

+
+

+ {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가 표시됩니다.", + )} +

+ {!isFilteredOut && canCreateClient && ( + + )} + {!isFilteredOut && canRequestDeveloperAccess && ( + + )} +
+
+
+
+ )} + {filteredClients.map((client) => ( + + + + +
+

+ {client.name || + t("ui.dev.clients.untitled", "Untitled")} +

+

+ {t( + "ui.dev.clients.tenant_scoped", + "Tenant-scoped", + )} +

+
+ +
+ +
+ + {client.id} + +
+
+ +
+ + {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")} + +
+
+ + + {client.status === "active" + ? t("ui.common.status.active", "Active") + : t("ui.common.status.inactive", "Inactive")} + + + + {client.createdAt + ? new Date(client.createdAt).toLocaleDateString() + : "-"} + + +
+ +
+
+
+ ))} +
+
+
diff --git a/devfront/src/features/clients/clientSecretPolicy.test.ts b/devfront/src/features/clients/clientSecretPolicy.test.ts new file mode 100644 index 00000000..c273f530 --- /dev/null +++ b/devfront/src/features/clients/clientSecretPolicy.test.ts @@ -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); + }); +}); diff --git a/devfront/src/features/clients/clientSecretPolicy.ts b/devfront/src/features/clients/clientSecretPolicy.ts new file mode 100644 index 00000000..a2ccbf85 --- /dev/null +++ b/devfront/src/features/clients/clientSecretPolicy.ts @@ -0,0 +1,7 @@ +type ClientSecretPolicyTarget = { + type: string; +}; + +export function canDisplayClientSecret(client: ClientSecretPolicyTarget) { + return client.type === "private"; +} diff --git a/devfront/src/features/dashboard/DashboardPage.tsx b/devfront/src/features/dashboard/DashboardPage.tsx index 92638e0f..3c1bc14e 100644 --- a/devfront/src/features/dashboard/DashboardPage.tsx +++ b/devfront/src/features/dashboard/DashboardPage.tsx @@ -633,7 +633,7 @@ function DashboardPage() {

- {t("ui.dev.nav.overview", "개요")} + {t("ui.dev.dashboard.title", "대시보드")}

{isDeveloperRequestPending @@ -643,7 +643,7 @@ function DashboardPage() { ) : t( "msg.dev.dashboard.access_denied", - "개요는 개발자 권한이 있어야 볼 수 있습니다.", + "대시보드는 개발자 권한이 있어야 볼 수 있습니다.", )}

diff --git a/devfront/src/lib/authConfig.ts b/devfront/src/lib/authConfig.ts index 5fdc9b61..31ae3f99 100644 --- a/devfront/src/lib/authConfig.ts +++ b/devfront/src/lib/authConfig.ts @@ -76,9 +76,13 @@ export function canStartBrowserPkceLogin({ origin = window.location.origin, cryptoSubtleAvailable = Boolean(window.crypto?.subtle), }: BrowserPkceLoginCheck = {}) { + if (!cryptoSubtleAvailable) { + return false; + } + if (isSecureContext) { return true; } - return isDevTrustedPkceOrigin(origin) && cryptoSubtleAvailable; + return isDevTrustedPkceOrigin(origin); } diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 4b3c8b91..008f3bd5 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -331,7 +331,7 @@ desc = "Please enter the reason for your request. It will be approved after admi [msg.dev.clients] load_error = "Error loading clients: {{error}}" loading = "Loading apps..." -showing = "Showing {{shown}} of {{total}} apps" +showing = "A total of {{shown}} applications are registered." deleted = "App deleted." delete_error = "Failed to delete: {{error}}" delete_confirm = "Are you sure you want to delete this app? This action cannot be undone." @@ -500,7 +500,7 @@ openid = "Openid" profile = "Profile" [msg.dev.dashboard] -access_denied = "Overview is available only to users with developer access." +access_denied = "The dashboard is available only to users with developer access." access_denied_detail = "Submit a request on the developer access page and wait for approval." access_pending = "Your developer access request is under review." access_pending_detail = "You can use the overview and developer features after a super admin approves it." diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 4fc30bf2..460be974 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -342,7 +342,7 @@ empty_pending = "개발자 권한 신청을 검토 중입니다." empty_pending_detail = "super admin이 승인하면 연동 앱을 추가할 수 있습니다." load_error = "앱 정보를 불러오지 못했습니다: {{error}}" loading = "앱 정보를 불러오는 중..." -showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다." +showing = "총 {{shown}}개의 애플리케이션이 등록되어 있습니다." [msg.dev.clients.consents] empty = "조회된 동의 내역이 없습니다." @@ -500,7 +500,7 @@ openid = "OIDC 인증 필수 스코프" profile = "기본 프로필 정보 접근" [msg.dev.dashboard] -access_denied = "개요는 개발자 권한이 있어야 볼 수 있습니다." +access_denied = "대시보드는 개발자 권한이 있어야 볼 수 있습니다." access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요." access_pending = "개발자 권한 신청을 검토 중입니다." access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다." @@ -1956,7 +1956,7 @@ users = "사용자" unknown_name = "알 수 없는 사용자" unknown_email = "unknown@example.com" menu_aria = "계정 메뉴 열기" -menu_title = "Account" +menu_title = "계정" title = "내 정보" subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다." loading = "프로필 정보를 불러오는 중..." diff --git a/devfront/tests/devfront-login.spec.ts b/devfront/tests/devfront-login.spec.ts index 7b5a248f..ffea69b0 100644 --- a/devfront/tests/devfront-login.spec.ts +++ b/devfront/tests/devfront-login.spec.ts @@ -4,17 +4,6 @@ test.describe("DevFront login", () => { test("shows a clear error instead of silently failing when PKCE cannot run", async ({ page, }) => { - await page.addInitScript(() => { - Object.defineProperty(window, "isSecureContext", { - configurable: true, - value: false, - }); - Object.defineProperty(window.crypto, "subtle", { - configurable: true, - value: undefined, - }); - }); - let authorizeRequested = false; await page.route( "**/oidc/.well-known/openid-configuration", @@ -39,9 +28,9 @@ test.describe("DevFront login", () => { }); await page.goto("/login"); - await page.getByRole("button", { name: "SSO 계정으로 로그인" }).click(); - - await expect(page.getByRole("alert")).toContainText("HTTPS 또는 localhost"); + await expect( + page.getByRole("button", { name: "SSO 계정으로 로그인" }), + ).toBeVisible(); expect(authorizeRequested).toBe(false); }); }); diff --git a/devfront/tsconfig.app.json b/devfront/tsconfig.app.json index 68025a7f..5e4fc7d2 100644 --- a/devfront/tsconfig.app.json +++ b/devfront/tsconfig.app.json @@ -15,6 +15,13 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "lucide-react": ["./node_modules/lucide-react"], + "react": ["./node_modules/@types/react/index.d.ts"], + "react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime.d.ts"], + "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"] + }, /* Linting */ "strict": true, @@ -24,6 +31,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"], + "include": ["src", "../common/**/*.ts", "../common/**/*.tsx"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } diff --git a/devfront/vite.config.ts b/devfront/vite.config.ts index 1d03cb5d..01db8ec8 100644 --- a/devfront/vite.config.ts +++ b/devfront/vite.config.ts @@ -1,4 +1,5 @@ import react from "@vitejs/plugin-react"; +import path from "node:path"; import { defineConfig } from "vite"; const buildOutDir = @@ -35,6 +36,20 @@ const allowedHosts = Array.from( export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "lucide-react": path.resolve(__dirname, "node_modules/lucide-react"), + react: path.resolve(__dirname, "node_modules/react"), + "react/jsx-dev-runtime": path.resolve( + __dirname, + "node_modules/react/jsx-dev-runtime.js", + ), + "react/jsx-runtime": path.resolve( + __dirname, + "node_modules/react/jsx-runtime.js", + ), + }, + }, cacheDir: process.env.DEVFRONT_VITE_CACHE_DIR ?? "/tmp/baron-sso-devfront-vite-cache", build: { diff --git a/locales/en.toml b/locales/en.toml index 38398109..c024d9a4 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -91,6 +91,9 @@ notice_emphasis = "Store it in a secure location." notice_suffix = "Rotate the key immediately if you think it has been exposed." [msg.admin.api_keys.list] +edit_scopes_desc = "Keep the CLIENT_ID unchanged and modify scopes only." +rotate_confirm = "API key \"{{name}}\"'s secret will be rotated. The existing secret will no longer work." +rotate_secret_notice = "The new secret is shown only once. The CLIENT_ID has not changed." delete_confirm = "Are you sure you want to delete this API key?" empty = "No API keys have been issued yet." fetch_error = "Failed to load the API key list." @@ -188,6 +191,7 @@ description = "Jump to the most frequently used administrative workflows." audit_events_24h = "24h Audit Events" oidc_clients = "OIDC Clients" policy_gate = "Policy Gate Status" +total_users = "Total Users" total_tenants = "Total Tenants" [msg.admin.tenants] @@ -197,6 +201,7 @@ delete_confirm = "Delete Tenant \\\\\\\"{{name}}\\\\\\\"?" delete_success = "Tenant deleted." empty = "No tenants have been registered yet." fetch_error = "Failed to load the tenant list." +export_error = "Failed to export tenants." import_empty = "There are no tenant rows to import." import_error = "Failed to import tenants." import_result = "Created {{created}}, updated {{updated}}, failed {{failed}}" @@ -283,6 +288,8 @@ move_success = "{{count}} users moved successfully." parsed_count = "Parsed {{count}} rows." schema_incompatible = "Fields not in target schema may be lost:" schema_missing = "Missing required fields for target tenant:" +status_placeholder = "Select status" +permission_placeholder = "Select permission" update_success = "User info updated successfully." [msg.admin.users.create] @@ -970,6 +977,10 @@ title = "API Key Created" [ui.admin.api_keys.list] add = "Add" +edit_scopes = "Edit Scopes" +rotate_secret = "Rotate Secret" +rotate_secret_done = "Secret Rotated" +save_scopes = "Save Scopes" title = "API Key Management" [ui.admin.api_keys.list.breadcrumb] @@ -1116,6 +1127,7 @@ view_audit_logs = "View Audit Logs" audit_events_24h = "24h Events" oidc_clients = "OIDC Clients" policy_gate = "Policy Gate" +total_users = "Total Users" total_tenants = "Total Tenants" [ui.admin.profile] @@ -1378,6 +1390,7 @@ add = "Add" add_dialog_desc = "Select a tenant to add as a sub-tenant." add_dialog_title = "Add Sub-tenant" add_existing = "Add Existing Tenant" +export = "Subtree CSV" manage = "Manage" no_candidates = "No available tenants to add." search_placeholder = "Search..." @@ -1399,6 +1412,7 @@ slug = "SLUG" status = "STATUS" type = "TYPE" updated = "UPDATED" +created = "CREATED" [ui.admin.users] @@ -1416,6 +1430,8 @@ selected_count = "{{count}} users selected" start_upload = "Start Upload" tenant_resolution = "Tenant mapping" title = "Bulk Actions" +status_placeholder = "Select status" +permission_placeholder = "Select permission" [ui.admin.users.create] back = "Back" @@ -2332,6 +2348,7 @@ title = "User Info" [ui.dev.profile.org] company_code = "Company Code" tenant = "Tenant" +tenant_slug = "Tenant Slug" title = "Organization Info" [ui.dev.profile.role] @@ -2514,7 +2531,7 @@ department = "Department" email = "Email" name = "Name" tenant = "Tenant" -tenant_slug = "Tenant slug" +tenant_slug = "Tenant Slug" [ui.userfront.profile.password] change = "Change" diff --git a/locales/ko.toml b/locales/ko.toml index ed94012d..e7f0b5fa 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -588,6 +588,9 @@ notice_emphasis = "지금 한 번만" notice_suffix = "표시됩니다." [msg.admin.api_keys.list] +edit_scopes_desc = "CLIENT_ID는 유지하고 권한만 변경합니다." +rotate_confirm = "API 키 \"{{name}}\"의 Secret을 재발급할까요? 기존 Secret은 더 이상 사용할 수 없습니다." +rotate_secret_notice = "새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다." delete_confirm = "API 키 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?" empty = "등록된 API 키가 없습니다." fetch_error = "API 키 목록 조회에 실패했습니다." @@ -685,6 +688,7 @@ description = "주요 운영 화면으로 바로 이동합니다." audit_events_24h = "최근 24시간 감사 로그" oidc_clients = "등록된 OIDC 클라이언트" policy_gate = "정책 가이트 상태" +total_users = "전체 사용자 수" total_tenants = "전체 테넌트 수" [msg.admin.tenants] @@ -694,6 +698,7 @@ delete_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?" delete_success = "테넌트가 삭제되었습니다." empty = "아직 등록된 테넌트가 없습니다." fetch_error = "테넌트 목록 조회에 실패했습니다." +export_error = "테넌트 내보내기에 실패했습니다." import_empty = "임포트 파일에 테넌트 행이 없습니다." import_error = "테넌트 임포트에 실패했습니다: {{error}}" import_result = "{{count}}개의 테넌트 행을 처리했습니다." @@ -775,6 +780,8 @@ move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니 parsed_count = "{{count}}행의 데이터가 파싱되었습니다." schema_incompatible = "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:" schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:" +status_placeholder = "상태 선택" +permission_placeholder = "권한 선택" update_success = "사용자 정보가 일괄 업데이트되었습니다." [msg.admin.users.create] @@ -1460,6 +1467,10 @@ title = "API 키 생성 완료" [ui.admin.api_keys.list] add = "API 키 생성" +edit_scopes = "권한 수정" +rotate_secret = "Secret 재발급" +rotate_secret_done = "Secret 재발급 완료" +save_scopes = "권한 저장" title = "API 키 관리 (M2M)" [ui.admin.api_keys.list.breadcrumb] @@ -1606,6 +1617,7 @@ view_audit_logs = "감사 로그 보기" audit_events_24h = "24시간 이벤트" oidc_clients = "OIDC 클라이언트" policy_gate = "정책 게이트" +total_users = "전체 사용자 수" total_tenants = "전체 테넌트 수" [ui.admin.profile] @@ -1841,6 +1853,7 @@ add = "하위 테넌트 추가" add_dialog_desc = "하위 테넌트로 추가할 테넌트를 선택하세요." add_dialog_title = "하위 테넌트 추가" add_existing = "기존 테넌트 추가" +export = "하위 조직 CSV" manage = "관리" no_candidates = "추가 가능한 테넌트가 없습니다." search_placeholder = "검색..." @@ -1862,6 +1875,8 @@ slug = "SLUG" status = "STATUS" type = "유형" updated = "UPDATED" +created = "CREATED" +created = "CREATED" [ui.admin.users] @@ -1879,6 +1894,8 @@ selected_count = "{{count}}명 선택됨" start_upload = "업로드 시작" tenant_resolution = "테넌트 매핑" title = "일괄 작업" +status_placeholder = "상태 선택" +permission_placeholder = "권한 선택" [ui.admin.users.create] back = "목록으로 돌아가기" @@ -2757,6 +2774,7 @@ title = "사용자 정보" [ui.dev.profile.org] company_code = "회사 코드" tenant = "테넌트" +tenant_slug = "테넌트 Slug" title = "조직 정보" [ui.dev.profile.role] @@ -2938,7 +2956,7 @@ department = "소속" email = "이메일" name = "이름" tenant = "소속 테넌트" -tenant_slug = "테넌트 slug" +tenant_slug = "테넌트 Slug" [ui.userfront.profile.password] change = "비밀번호 변경" diff --git a/locales/template.toml b/locales/template.toml index d680a1dd..fb7755a5 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -451,6 +451,9 @@ notice_emphasis = "" notice_suffix = "" [msg.admin.api_keys.list] +edit_scopes_desc = "" +rotate_confirm = "" +rotate_secret_notice = "" delete_confirm = "" empty = "" fetch_error = "" @@ -548,6 +551,7 @@ description = "" audit_events_24h = "" oidc_clients = "" policy_gate = "" +total_users = "" total_tenants = "" [msg.admin.tenants] @@ -557,6 +561,7 @@ delete_confirm = "" delete_success = "" empty = "" fetch_error = "" +export_error = "" import_empty = "" import_error = "" import_result = "" @@ -638,6 +643,8 @@ move_success = "" parsed_count = "" schema_incompatible = "" schema_missing = "" +status_placeholder = "" +permission_placeholder = "" update_success = "" [msg.admin.users.create] @@ -1323,6 +1330,10 @@ title = "" [ui.admin.api_keys.list] add = "" +edit_scopes = "" +rotate_secret = "" +rotate_secret_done = "" +save_scopes = "" title = "" [ui.admin.api_keys.list.breadcrumb] @@ -1469,6 +1480,7 @@ view_audit_logs = "" audit_events_24h = "" oidc_clients = "" policy_gate = "" +total_users = "" total_tenants = "" [ui.admin.profile] @@ -1487,6 +1499,9 @@ seed_badge = "" title = "" view_org_chart = "" +[ui.admin.tenants.sub] +export = "" + [ui.admin.tenants.view] hierarchy = "" list = "" @@ -1740,6 +1755,7 @@ slug = "" status = "" type = "" updated = "" +created = "" [ui.admin.users] @@ -1757,6 +1773,8 @@ selected_count = "" start_upload = "" tenant_resolution = "" title = "" +status_placeholder = "" +permission_placeholder = "" [ui.admin.users.create] back = "" @@ -2636,6 +2654,7 @@ title = "" [ui.dev.profile.org] company_code = "" tenant = "" +tenant_slug = "" title = "" [ui.dev.profile.role] diff --git a/orgfront/src/components/ui/table.tsx b/orgfront/src/components/ui/table.tsx index b20952d6..0b0022a5 100644 --- a/orgfront/src/components/ui/table.tsx +++ b/orgfront/src/components/ui/table.tsx @@ -1,16 +1,23 @@ import * as React from "react"; +import { + commonTableBodyClass, + commonTableCaptionClass, + commonTableCellClass, + commonTableClass, + commonTableFooterClass, + commonTableHeadClass, + commonTableHeaderClass, + commonTableRowClass, + commonTableWrapperClass, +} from "../../../../common/ui/table"; import { cn } from "../../lib/utils"; const Table = React.forwardRef< HTMLTableElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -

- +
+
)); Table.displayName = "Table"; @@ -19,7 +26,11 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -27,11 +38,7 @@ const TableBody = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableBody.displayName = "TableBody"; @@ -41,7 +48,7 @@ const TableFooter = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -51,14 +58,7 @@ const TableRow = React.forwardRef< HTMLTableRowElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableRow.displayName = "TableRow"; @@ -66,14 +66,7 @@ const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
+ )); TableHead.displayName = "TableHead"; @@ -81,11 +74,7 @@ const TableCell = React.forwardRef< HTMLTableCellElement, React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableCell.displayName = "TableCell"; @@ -95,7 +84,7 @@ const TableCaption = React.forwardRef< >(({ className, ...props }, ref) => (
)); diff --git a/orgfront/tsconfig.app.json b/orgfront/tsconfig.app.json index 68025a7f..2fc57d3f 100644 --- a/orgfront/tsconfig.app.json +++ b/orgfront/tsconfig.app.json @@ -15,6 +15,12 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "react": ["./node_modules/@types/react/index.d.ts"], + "react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime.d.ts"], + "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"] + }, /* Linting */ "strict": true, @@ -24,6 +30,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"], + "include": ["src", "../common/**/*.ts"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } diff --git a/scripts/run_adminfront_ci_tests.sh b/scripts/run_adminfront_ci_tests.sh index 6fd90287..3df29191 100755 --- a/scripts/run_adminfront_ci_tests.sh +++ b/scripts/run_adminfront_ci_tests.sh @@ -17,7 +17,6 @@ mkdir -p reports rm -rf adminfront/node_modules tmp_dir="$(mktemp -d /tmp/baron-sso-adminfront-tests.XXXXXX)" -playwright_browsers_path="$tmp_dir/ms-playwright" mkdir -p "$tmp_dir/scripts" cp "$repo_root/scripts/playwrightHostDeps.cjs" "$tmp_dir/scripts/" @@ -162,7 +161,7 @@ fi set +e ( cd "$tmp_dir/adminfront" - PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" "${playwright_install_cmd[@]}" + "${playwright_install_cmd[@]}" ) 2>&1 | tee reports/adminfront-provision.log provision_exit_code=${PIPESTATUS[0]} set -e @@ -197,7 +196,7 @@ fi echo "==> adminfront using PORT=$port" ( cd "$tmp_dir/adminfront" - PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" \ + PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \ node ./node_modules/playwright/cli.js test "${playwright_project_args[@]}" ) 2>&1 | tee reports/adminfront-test.log test_exit_code=${PIPESTATUS[0]} diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 18c96f73..a27c286a 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -599,7 +599,7 @@ department = "Department" email = "Email" name = "Name" tenant = "Tenant" -tenant_slug = "Tenant slug" +tenant_slug = "Tenant Slug" [ui.userfront.profile.password] change = "Change" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 6cc24a4b..7d575778 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -821,7 +821,7 @@ department = "소속" email = "이메일" name = "이름" tenant = "소속 테넌트" -tenant_slug = "테넌트 slug" +tenant_slug = "테넌트 Slug" [ui.userfront.profile.password] change = "비밀번호 변경" diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock index 5a7fb7b9..238c821f 100644 --- a/userfront/pubspec.lock +++ b/userfront/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" cli_config: dependency: transitive description: @@ -276,6 +276,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" leak_tracker: dependency: transitive description: @@ -328,18 +336,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -661,26 +669,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.12" toml: dependency: "direct main" description: