From a26093836f37e3d10d7cf9cd7da34e306210a6f9 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 10:28:10 +0900 Subject: [PATCH 01/16] =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/.gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 common/.gitkeep diff --git a/common/.gitkeep b/common/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/common/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - From b9a351ca59d28c50a19bafa8b28a5ae45cff90c7 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 10:29:08 +0900 Subject: [PATCH 02/16] =?UTF-8?q?=EB=B9=84=EA=B6=8C=ED=95=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=95=88=EB=82=B4=EB=AC=B8=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/dashboard/DashboardPage.tsx | 4 ++-- devfront/src/locales/en.toml | 2 +- devfront/src/locales/ko.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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/locales/en.toml b/devfront/src/locales/en.toml index 4b3c8b91..1ca157cd 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -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..3bb51e4e 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -500,7 +500,7 @@ openid = "OIDC 인증 필수 스코프" profile = "기본 프로필 정보 접근" [msg.dev.dashboard] -access_denied = "개요는 개발자 권한이 있어야 볼 수 있습니다." +access_denied = "대시보드는 개발자 권한이 있어야 볼 수 있습니다." access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요." access_pending = "개발자 권한 신청을 검토 중입니다." access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다." From 498fdd802c984d3430ec961262e79d316913e5c4 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 11:24:28 +0900 Subject: [PATCH 03/16] =?UTF-8?q?Server=20side=20app=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20=ED=82=A4=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientDetailsPage.tsx | 30 +++++++------------ .../clients/clientSecretPolicy.test.ts | 28 +++++++++++++++++ .../features/clients/clientSecretPolicy.ts | 7 +++++ 3 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 devfront/src/features/clients/clientSecretPolicy.test.ts create mode 100644 devfront/src/features/clients/clientSecretPolicy.ts 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/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"; +} From 187f0da29b040a95658032d31a68e9d2d310820d Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 14:15:30 +0900 Subject: [PATCH 04/16] =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=20UI=20=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F=20devfront=20se?= =?UTF-8?q?cret=20=ED=91=9C=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantListPage.tsx | 127 ++++++-------- adminfront/tsconfig.app.json | 9 +- adminfront/vite.config.ts | 15 ++ .../components/sort/SortableTableHead.tsx | 158 ++++++++++++++++++ common/core/components/sort/index.ts | 1 + devfront/src/features/clients/ClientsPage.tsx | 96 ++++------- devfront/tsconfig.app.json | 9 +- devfront/vite.config.ts | 15 ++ 8 files changed, 291 insertions(+), 139 deletions(-) create mode 100644 common/core/components/sort/SortableTableHead.tsx create mode 100644 common/core/components/sort/index.ts diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 8739e474..b2622121 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -2,9 +2,6 @@ import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { AxiosError } from "axios"; import { - ArrowDown, - ArrowUp, - ArrowUpDown, Building2, ChevronDown, ChevronRight, @@ -22,6 +19,7 @@ import { } from "lucide-react"; import * as React from "react"; import { Link, useNavigate } from "react-router-dom"; +import { SortableTableHead } from "../../../../../common/core/components/sort"; import { type SortConfig, type SortResolverMap, @@ -513,17 +511,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( () => tenants.filter((tenant) => !isSeedTenant(tenant)), [tenants], @@ -977,69 +964,55 @@ function TenantListPage() { } /> - requestSort("id")} - > -
- {t("ui.admin.tenants.table.id", "ID")} - {getSortIcon("id")} -
-
- requestSort("name")} - > -
- {t("ui.admin.tenants.table.name", "NAME")} - {getSortIcon("name")} -
-
- requestSort("type")} - > -
- {t("ui.admin.tenants.table.type", "TYPE")} - {getSortIcon("type")} -
-
- requestSort("slug")} - > -
- {t("ui.admin.tenants.table.slug", "SLUG")} - {getSortIcon("slug")} -
-
- requestSort("status")} - > -
- {t("ui.admin.tenants.table.status", "STATUS")} - {getSortIcon("status")} -
-
- requestSort("recursiveMemberCount")} - > -
- {t("ui.admin.tenants.table.members", "MEMBERS")} - {getSortIcon("recursiveMemberCount")} -
-
- requestSort("updatedAt")} - > -
- {t("ui.admin.tenants.table.updated", "UPDATED")} - {getSortIcon("updatedAt")} -
-
+ + + + + + + {t("ui.common.actions", "액션")} 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/core/components/sort/SortableTableHead.tsx b/common/core/components/sort/SortableTableHead.tsx new file mode 100644 index 00000000..b6774a85 --- /dev/null +++ b/common/core/components/sort/SortableTableHead.tsx @@ -0,0 +1,158 @@ +import type { ReactNode, ThHTMLAttributes } from "react"; +import type { SortConfig } from "../../utils"; + +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/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 866a7a1c..fcc26b1d 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -1,9 +1,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { - ArrowDown, - ArrowUp, - ArrowUpDown, BookOpenText, Filter, Plus, @@ -13,6 +10,7 @@ import { import { useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate } from "react-router-dom"; +import { SortableTableHead } from "../../../../common/core/components/sort"; import { type SortConfig, type SortResolverMap, @@ -230,18 +228,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 (
@@ -452,51 +438,41 @@ function ClientsPage() { - 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", "액션")} 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: { From 8a8b5baaf61346a110ebaa4ddd40133366562dfe Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 14:36:52 +0900 Subject: [PATCH 05/16] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=A0=95=EB=A0=AC=20=EC=83=9D=EC=84=B1=EC=9D=BC=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantListPage.tsx | 26 +++++++++++-------- devfront/src/features/clients/ClientsPage.tsx | 13 +++++----- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index b2622121..5a3d4080 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -238,8 +238,12 @@ function TenantListPage() { const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list"); const [selectedIds, setSelectedIds] = React.useState([]); const [search, setSearch] = React.useState(""); - const [sortConfig, setSortConfig] = - React.useState | null>(null); + const [sortConfig, setSortConfig] = React.useState | null>( + { + key: "createdAt", + direction: "desc", + }, + ); const fileInputRef = React.useRef(null); const [importMessage, setImportMessage] = React.useState(""); const [previewRows, setPreviewRows] = React.useState< @@ -965,53 +969,53 @@ function TenantListPage() { /> {t("ui.common.actions", "액션")} diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index fcc26b1d..7f6f92e7 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -120,8 +120,12 @@ function ClientsPage() { const [statusFilter, setStatusFilter] = useState("all"); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); - const [sortConfig, setSortConfig] = - useState | null>(null); + const [sortConfig, setSortConfig] = useState | null>( + { + key: "createdAt", + direction: "desc", + }, + ); const clients = data?.items || []; const clientSortResolvers = useMemo< @@ -439,35 +443,30 @@ function ClientsPage() { Date: Wed, 13 May 2026 15:17:37 +0900 Subject: [PATCH 06/16] =?UTF-8?q?dev/admin=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20=ED=97=A4=EB=8D=94=20UI=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantListPage.tsx | 30 ++-- .../src/features/users/UserListPage.tsx | 142 +++++++----------- .../components/sort/SortableTableHead.tsx | 24 ++- devfront/src/features/clients/ClientsPage.tsx | 12 +- 4 files changed, 102 insertions(+), 106 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 5a3d4080..363fe5a7 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -19,7 +19,11 @@ import { } from "lucide-react"; import * as React from "react"; import { Link, useNavigate } from "react-router-dom"; -import { SortableTableHead } from "../../../../../common/core/components/sort"; +import { + SortableTableHead, + sortableTableHeadBaseClassName, + sortableTableHeaderClassName, +} from "../../../../../common/core/components/sort"; import { type SortConfig, type SortResolverMap, @@ -954,9 +958,11 @@ function TenantListPage() { onScroll={handleTenantTableScroll} >
- + - + 0 && @@ -969,55 +975,57 @@ function TenantListPage() { /> - + {t("ui.common.actions", "액션")} diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 45a4a1cc..f987838c 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -1,9 +1,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { - ArrowDown, - ArrowUp, - ArrowUpDown, ChevronLeft, ChevronRight, FileDown, @@ -22,6 +19,11 @@ import { sortItems, toggleSort, } from "../../../../common/core/utils"; +import { + SortableTableHead, + sortableTableHeadBaseClassName, + sortableTableHeaderClassName, +} from "../../../../common/core/components/sort"; import { Button } from "../../components/ui/button"; import { Card, @@ -285,17 +287,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); @@ -567,9 +558,11 @@ function UserListPage() {
- + - + - requestSort("id")} - > -
- {t("ui.admin.users.list.table.id", "ID")} - {getSortIcon("id")} -
-
- requestSort("name_email")} - > -
- {t( - "ui.admin.users.list.table.name_email", - "이름 / 이메일 / 전화번호", - )} - {getSortIcon("name_email")} -
-
- requestSort("status")} - > -
- {t("ui.admin.users.list.table.status", "STATUS")} - {getSortIcon("status")} -
-
- requestSort("role")} - > -
- {t("ui.admin.users.list.table.role", "ROLE")} - {getSortIcon("role")} -
-
- requestSort("tenant_dept")} - > -
- {t( - "ui.admin.users.list.table.tenant_dept", - "TENANT / DEPT", - )} - {getSortIcon("tenant_dept")} -
-
+ + + + {/* Dynamic Columns from Schema */} {userSchema.map( (field) => 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")} -
-
+
diff --git a/common/core/components/sort/SortableTableHead.tsx b/common/core/components/sort/SortableTableHead.tsx index b6774a85..5a32e990 100644 --- a/common/core/components/sort/SortableTableHead.tsx +++ b/common/core/components/sort/SortableTableHead.tsx @@ -1,12 +1,18 @@ import type { ReactNode, ThHTMLAttributes } from "react"; import type { SortConfig } from "../../utils"; +export const sortableTableHeadBaseClassName = + "h-12 px-6 text-left text-xs font-sans font-bold uppercase tracking-[0.08em] text-foreground align-middle"; + +export const sortableTableHeaderClassName = + "sticky top-0 z-10 bg-secondary shadow-sm"; + function SortAscendingIcon() { return ( {label} {direction === "asc" ? ( - + + + ) : direction === "desc" ? ( - + + + ) : ( )} diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 7f6f92e7..73b03603 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -10,7 +10,11 @@ import { import { useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate } from "react-router-dom"; -import { SortableTableHead } from "../../../../common/core/components/sort"; +import { + SortableTableHead, + sortableTableHeadBaseClassName, + sortableTableHeaderClassName, +} from "../../../../common/core/components/sort"; import { type SortConfig, type SortResolverMap, @@ -440,7 +444,7 @@ function ClientsPage() {
- + - + {t("ui.dev.clients.table.actions", "액션")} From 40d64acf15e6f01c334ad44259a6082a2e26b923 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 16:50:25 +0900 Subject: [PATCH 07/16] =?UTF-8?q?devfront=20=EC=97=B0=EB=8F=99=20=EC=95=B1?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=20=EB=B0=8F=20=EB=AC=B8=EA=B5=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientsPage.tsx | 436 +++++++++--------- devfront/src/locales/en.toml | 2 +- devfront/src/locales/ko.toml | 2 +- 3 files changed, 219 insertions(+), 221 deletions(-) diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 73b03603..2d840605 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -15,6 +15,10 @@ import { sortableTableHeadBaseClassName, sortableTableHeaderClassName, } from "../../../../common/core/components/sort"; +import { + commonTableShellClass, + commonTableViewportClass, +} from "../../../../common/ui/table"; import { type SortConfig, type SortResolverMap, @@ -427,233 +431,227 @@ function ClientsPage() { - -
+ +
{t("ui.dev.clients.list.title", "클라이언트 목록")} - {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() - : "-"} - - -
- -
-
-
- ))} -
-
-
- + {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/locales/en.toml b/devfront/src/locales/en.toml index 1ca157cd..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." diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 3bb51e4e..1d5ca5d3 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 = "조회된 동의 내역이 없습니다." From c8ac953b143b745e1abd31a6a0390290fd956111 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 16:51:26 +0900 Subject: [PATCH 08/16] =?UTF-8?q?adminfront=20=EC=82=AC=EC=9A=A9=EC=9E=90/?= =?UTF-8?q?=ED=85=8C=EB=84=8C=ED=8A=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=89=98=20=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantListPage.tsx | 359 ++++++------------ .../src/features/users/UserListPage.tsx | 8 +- 2 files changed, 125 insertions(+), 242 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 363fe5a7..f564294b 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -1,5 +1,4 @@ -import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; -import { useVirtualizer } from "@tanstack/react-virtual"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Building2, @@ -25,10 +24,14 @@ import { sortableTableHeaderClassName, } from "../../../../../common/core/components/sort"; import { - type SortConfig, - type SortResolverMap, + 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"; @@ -76,12 +79,7 @@ import { importTenantsCSV, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -import { normalizeAdminRole } from "../../../lib/roles"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; -import { - filterNonHanmacFamilyTenants, - isHanmacFamilyUser, -} from "../../users/orgChartPicker"; import { isSeedTenant } from "../utils/protectedTenants"; import { type TenantImportPreviewRow, @@ -95,14 +93,8 @@ import { const tenantCSVTemplate = "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n"; -const tenantPageSize = 500; -const tenantVirtualizationThreshold = 250; -const tenantEstimatedRowHeight = 73; -const tenantLoadAheadPx = 360; -const tenantLoadAheadRows = 30; type TenantSortKey = keyof TenantSummary | "recursiveMemberCount"; -type TenantListRow = TenantSummary & { recursiveMemberCount: number }; const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { @@ -263,17 +255,15 @@ function TenantListPage() { Record >({}); const [previewOpen, setPreviewOpen] = React.useState(false); - const tenantTableScrollRef = React.useRef(null); const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, }); - const profileRole = normalizeAdminRole(profile?.role); // Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list React.useEffect(() => { - if (profile && profileRole === "tenant_admin") { + if (profile?.role === "tenant_admin") { const manageableCount = profile.manageableTenants?.length ?? 0; if ( (manageableCount === 1 || manageableCount === 0) && @@ -282,24 +272,15 @@ function TenantListPage() { navigate(`/tenants/${profile.tenantId}`, { replace: true }); } } - }, [profile, profileRole, navigate]); + }, [profile, navigate]); - const query = useInfiniteQuery({ - queryKey: ["tenants", "lazy"], - queryFn: ({ pageParam }) => - fetchTenants( - tenantPageSize, - 0, - undefined, - pageParam ? pageParam : undefined, - ), - initialPageParam: "", - getNextPageParam: (lastPage) => - lastPage.nextCursor || lastPage.next_cursor || undefined, + const query = useQuery({ + queryKey: ["tenants", { limit: 1000, offset: 0 }], + queryFn: () => fetchTenants(1000, 0), enabled: - profileRole === "super_admin" || - (profileRole === "tenant_admin" && - (profile?.manageableTenants?.length ?? 0) > 1), + profile?.role === "super_admin" || + (profile?.role === "tenant_admin" && + (profile.manageableTenants?.length ?? 0) > 1), }); const deleteMutation = useMutation({ @@ -364,8 +345,8 @@ function TenantListPage() { if ( profile && - profileRole !== "super_admin" && - profileRole !== "tenant_admin" + profile.role !== "super_admin" && + profile.role !== "tenant_admin" ) { return (
@@ -380,8 +361,7 @@ function TenantListPage() { } if ( - profile && - profileRole === "tenant_admin" && + profile?.role === "tenant_admin" && (profile.manageableTenants?.length ?? 0) <= 1 ) { return null; @@ -394,28 +374,7 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const tenantPages = query.data?.pages ?? []; - const rawTenants = tenantPages.flatMap((page) => page.items); - const tenantTotal = tenantPages[0]?.total ?? 0; - const hanmacFamilyTenantId = React.useMemo(() => { - const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; - if (typeof envTenantId === "string" && envTenantId.trim()) { - return envTenantId.trim(); - } - return rawTenants.find((tenant) => tenant.slug === "hanmac-family")?.id; - }, [rawTenants]); - const allTenants = React.useMemo(() => { - if (profileRole === "super_admin") { - return rawTenants; - } - if ( - profile && - isHanmacFamilyUser(profile, rawTenants, hanmacFamilyTenantId) - ) { - return rawTenants; - } - return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId); - }, [hanmacFamilyTenantId, profile, profileRole, rawTenants]); + const allTenants = query.data?.items ?? []; const importParentOptionGroups = buildTenantImportParentOptionGroups(allTenants); const tenantSortResolvers = React.useMemo< @@ -465,56 +424,6 @@ function TenantListPage() { return sortItems(enriched, sortConfig, tenantSortResolvers); }, [allTenants, search, sortConfig, tenantSortResolvers]); - const shouldVirtualizeTenants = - tenants.length >= tenantVirtualizationThreshold; - const tenantRowVirtualizer = useVirtualizer({ - count: tenants.length, - getScrollElement: () => tenantTableScrollRef.current, - estimateSize: () => tenantEstimatedRowHeight, - overscan: 12, - enabled: shouldVirtualizeTenants, - }); - const virtualTenantRows = shouldVirtualizeTenants - ? tenantRowVirtualizer.getVirtualItems() - : []; - const lastVirtualTenantIndex = - virtualTenantRows[virtualTenantRows.length - 1]?.index ?? -1; - - const fetchNextTenantPage = React.useCallback(() => { - if (query.hasNextPage && !query.isFetchingNextPage) { - void query.fetchNextPage(); - } - }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]); - - const handleTenantTableScroll = React.useCallback( - (event: React.UIEvent) => { - const scrollElement = event.currentTarget; - const distanceToEnd = - scrollElement.scrollHeight - - scrollElement.scrollTop - - scrollElement.clientHeight; - if (distanceToEnd <= tenantLoadAheadPx) { - fetchNextTenantPage(); - } - }, - [fetchNextTenantPage], - ); - - React.useEffect(() => { - if ( - !shouldVirtualizeTenants || - lastVirtualTenantIndex < tenants.length - tenantLoadAheadRows - ) { - return; - } - fetchNextTenantPage(); - }, [ - fetchNextTenantPage, - lastVirtualTenantIndex, - shouldVirtualizeTenants, - tenants.length, - ]); - const requestSort = (key: TenantSortKey) => { setSortConfig((current) => toggleSort(current, key)); }; @@ -690,96 +599,6 @@ function TenantListPage() { deleteMutation.mutate(tenantId); }; - const renderTenantRow = ( - tenant: TenantListRow, - options?: { - style?: React.CSSProperties; - virtualIndex?: number; - }, - ) => ( - - - {isSeedTenant(tenant) ? ( - - ) : ( - handleSelect(tenant, !!checked)} - /> - )} - - - {tenant.id} - - -
- - {tenant.name} - - {isSeedTenant(tenant) && ( - - {t("ui.admin.tenants.seed_badge", "초기 설정")} - - )} -
-
- - - {tenant.type} - - - {tenant.slug} - - - {t(`ui.common.status.${tenant.status}`, tenant.status)} - - - - {tenant.recursiveMemberCount} - - - {tenant.updatedAt - ? new Date(tenant.updatedAt).toLocaleString("ko-KR") - : "-"} - - - - -
- ); - return (
@@ -908,7 +727,7 @@ function TenantListPage() { "msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", { - count: tenantTotal, + count: query.data?.total ?? 0, }, )} @@ -950,13 +769,8 @@ function TenantListPage() { value="list" className="flex-1 flex flex-col min-h-0 m-0" > -
-
+
+
@@ -1030,18 +844,7 @@ function TenantListPage() { - + {query.isLoading && ( @@ -1062,26 +865,102 @@ function TenantListPage() { )} - {shouldVirtualizeTenants - ? virtualTenantRows.map((virtualRow) => { - const tenant = tenants[virtualRow.index]; - if (!tenant) { - return null; - } - return renderTenantRow(tenant, { - virtualIndex: virtualRow.index, - style: { - position: "absolute", - top: 0, - left: 0, - width: "100%", - display: "table", - tableLayout: "fixed", - transform: `translateY(${virtualRow.start}px)`, - }, - }); - }) - : tenants.map((tenant) => renderTenantRow(tenant))} + {tenants.map((tenant) => ( + + + {isSeedTenant(tenant) ? ( + + ) : ( + + handleSelect(tenant, !!checked) + } + /> + )} + + + {tenant.id} + + +
+ + {tenant.name} + + {isSeedTenant(tenant) && ( + + {t( + "ui.admin.tenants.seed_badge", + "초기 설정", + )} + + )} +
+
+ + + {tenant.type} + + + + {tenant.slug} + + + + {t( + `ui.common.status.${tenant.status}`, + tenant.status, + )} + + + + {tenant.recursiveMemberCount} + + + {tenant.createdAt + ? new Date(tenant.createdAt).toLocaleString( + "ko-KR", + ) + : "-"} + + + + +
+ ))}
diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index f987838c..3cd8eabc 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -24,6 +24,10 @@ import { sortableTableHeadBaseClassName, sortableTableHeaderClassName, } from "../../../../common/core/components/sort"; +import { + commonTableShellClass, + commonTableViewportClass, +} from "../../../../common/ui/table"; import { Button } from "../../components/ui/button"; import { Card, @@ -555,8 +559,8 @@ function UserListPage() {
)} -
-
+
+
From ee8cfb4ba851a6dd335b2ee3c816d012d64531e2 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 16:51:51 +0900 Subject: [PATCH 09/16] =?UTF-8?q?common/ui/table=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/components/ui/table.tsx | 35 +++++++++++++++----------- common/ui/table.ts | 15 +++++++++++ devfront/src/components/ui/table.tsx | 35 +++++++++++++++----------- orgfront/src/components/ui/table.tsx | 35 +++++++++++++++----------- orgfront/tsconfig.app.json | 8 +++++- 5 files changed, 82 insertions(+), 46 deletions(-) create mode 100644 common/ui/table.ts diff --git a/adminfront/src/components/ui/table.tsx b/adminfront/src/components/ui/table.tsx index 62c33432..46370544 100644 --- a/adminfront/src/components/ui/table.tsx +++ b/adminfront/src/components/ui/table.tsx @@ -1,14 +1,25 @@ 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) => ( -
+
@@ -19,7 +30,7 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -29,7 +40,7 @@ const TableBody = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -41,7 +52,7 @@ const TableFooter = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -53,10 +64,7 @@ const TableRow = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -68,10 +76,7 @@ const TableHead = React.forwardRef< >(({ className, ...props }, ref) => (
)); @@ -83,7 +88,7 @@ const TableCell = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -95,7 +100,7 @@ const TableCaption = React.forwardRef< >(({ className, ...props }, ref) => (
)); diff --git a/common/ui/table.ts b/common/ui/table.ts new file mode 100644 index 00000000..459d5699 --- /dev/null +++ b/common/ui/table.ts @@ -0,0 +1,15 @@ +export const commonTableWrapperClass = "relative w-full overflow-auto"; +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-bold uppercase tracking-[0.08em] text-muted-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..46370544 100644 --- a/devfront/src/components/ui/table.tsx +++ b/devfront/src/components/ui/table.tsx @@ -1,14 +1,25 @@ 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) => ( -
+
@@ -19,7 +30,7 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -29,7 +40,7 @@ const TableBody = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -41,7 +52,7 @@ const TableFooter = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -53,10 +64,7 @@ const TableRow = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -68,10 +76,7 @@ const TableHead = React.forwardRef< >(({ className, ...props }, ref) => (
)); @@ -83,7 +88,7 @@ const TableCell = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -95,7 +100,7 @@ const TableCaption = React.forwardRef< >(({ className, ...props }, ref) => (
)); diff --git a/orgfront/src/components/ui/table.tsx b/orgfront/src/components/ui/table.tsx index b20952d6..46370544 100644 --- a/orgfront/src/components/ui/table.tsx +++ b/orgfront/src/components/ui/table.tsx @@ -1,14 +1,25 @@ 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) => ( -
+
@@ -19,7 +30,7 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -29,7 +40,7 @@ const TableBody = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -41,7 +52,7 @@ const TableFooter = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -53,10 +64,7 @@ const TableRow = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -68,10 +76,7 @@ const TableHead = React.forwardRef< >(({ className, ...props }, ref) => (
)); @@ -83,7 +88,7 @@ const TableCell = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -95,7 +100,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"] } From 481ec5fc15852cd1be0fe325b58aba601db1a09d Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 17:07:15 +0900 Subject: [PATCH 10/16] =?UTF-8?q?=EA=B0=90=EC=82=AC=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?table=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/features/audit/AuditLogsPage.tsx | 8 ++++++-- devfront/src/features/audit/AuditLogsPage.tsx | 14 +++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) 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/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index 61dced27..941690dd 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,8 +284,10 @@ function AuditLogsPage() { : "" } > -
- +
+
+
+ {t("ui.dev.audit.table.time", "Time")} @@ -430,7 +436,9 @@ function AuditLogsPage() { }) )} -
+
+
+
{query.hasNextPage ? ( From 76a63264feaf72b9fdacdc3787dd41c87dd8b79e Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 17:20:43 +0900 Subject: [PATCH 11/16] =?UTF-8?q?devfront=20consents=20=EB=B0=8F=20audit?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/sort/SortableTableHead.tsx | 3 +- common/ui/table.ts | 2 +- .../features/clients/ClientConsentsPage.tsx | 285 +++++++++--------- 3 files changed, 151 insertions(+), 139 deletions(-) diff --git a/common/core/components/sort/SortableTableHead.tsx b/common/core/components/sort/SortableTableHead.tsx index 5a32e990..e9c89117 100644 --- a/common/core/components/sort/SortableTableHead.tsx +++ b/common/core/components/sort/SortableTableHead.tsx @@ -1,8 +1,9 @@ import type { ReactNode, ThHTMLAttributes } from "react"; import type { SortConfig } from "../../utils"; +import { commonTableHeadClass } from "../../../ui/table"; export const sortableTableHeadBaseClassName = - "h-12 px-6 text-left text-xs font-sans font-bold uppercase tracking-[0.08em] text-foreground align-middle"; + commonTableHeadClass; export const sortableTableHeaderClassName = "sticky top-0 z-10 bg-secondary shadow-sm"; diff --git a/common/ui/table.ts b/common/ui/table.ts index 459d5699..40a47e30 100644 --- a/common/ui/table.ts +++ b/common/ui/table.ts @@ -6,7 +6,7 @@ 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-bold uppercase tracking-[0.08em] text-muted-foreground align-middle"; + "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 = diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 0de25b12..3ec9ff40 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"; @@ -430,146 +434,153 @@ 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")} + + + {t( + "ui.dev.clients.consents.table.first_granted", + "First Granted", )} - - -
- {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.last_auth", + "Last Authenticated / Revoked", )} - - - {row.status === "active" && ( - - )} - + + + {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( From c7ed9186c9300354859f942fbce53f14d5d95818 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 17:26:32 +0900 Subject: [PATCH 12/16] =?UTF-8?q?consents=20=ED=83=AD=20=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientConsentsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 3ec9ff40..a078b1de 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -276,7 +276,7 @@ function ClientConsentsPage() { - +

From e803a0b150377e2a23c34328f346ae68d114a1b7 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 13 May 2026 17:29:07 +0900 Subject: [PATCH 13/16] =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EB=AC=B8=EA=B5=AC=20=ED=95=9C=EA=B5=AD=EC=96=B4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/locales/ko.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 1d5ca5d3..460be974 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -1956,7 +1956,7 @@ users = "사용자" unknown_name = "알 수 없는 사용자" unknown_email = "unknown@example.com" menu_aria = "계정 메뉴 열기" -menu_title = "Account" +menu_title = "계정" title = "내 정보" subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다." loading = "프로필 정보를 불러오는 중..." From da10b4be1565522531ec9ec4d9c267f2fa107361 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 14 May 2026 10:22:59 +0900 Subject: [PATCH 14/16] =?UTF-8?q?92e607aee8=20=EA=B8=B0=EC=A4=80=20code=20?= =?UTF-8?q?check=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/components/ui/table.tsx | 36 +- .../tenants/routes/TenantListPage.tsx | 363 ++++++++++++------ .../src/features/users/UserListPage.tsx | 11 +- common/locales/en.toml | 1 + common/locales/ko.toml | 1 + common/locales/template.toml | 1 + common/ui/table.ts | 2 +- devfront/src/components/ui/table.tsx | 36 +- devfront/src/features/audit/AuditLogsPage.tsx | 294 +++++++------- .../features/clients/ClientConsentsPage.tsx | 10 +- devfront/src/features/clients/ClientsPage.tsx | 29 +- locales/en.toml | 19 +- locales/ko.toml | 20 +- locales/template.toml | 19 + orgfront/src/components/ui/table.tsx | 36 +- scripts/run_adminfront_ci_tests.sh | 5 +- userfront/pubspec.lock | 32 +- 17 files changed, 536 insertions(+), 379 deletions(-) diff --git a/adminfront/src/components/ui/table.tsx b/adminfront/src/components/ui/table.tsx index 46370544..0b0022a5 100644 --- a/adminfront/src/components/ui/table.tsx +++ b/adminfront/src/components/ui/table.tsx @@ -17,11 +17,7 @@ const Table = React.forwardRef< React.HTMLAttributes >(({ className, ...props }, ref) => (
- +
)); Table.displayName = "Table"; @@ -30,7 +26,11 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -38,11 +38,7 @@ const TableBody = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableBody.displayName = "TableBody"; @@ -62,11 +58,7 @@ const TableRow = React.forwardRef< HTMLTableRowElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableRow.displayName = "TableRow"; @@ -74,11 +66,7 @@ const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
+ )); TableHead.displayName = "TableHead"; @@ -86,11 +74,7 @@ const TableCell = React.forwardRef< HTMLTableCellElement, React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableCell.displayName = "TableCell"; diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index f564294b..94d32e33 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -1,6 +1,10 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; import type { AxiosError } from "axios"; import { + ArrowDown, + ArrowUp, + ArrowUpDown, Building2, ChevronDown, ChevronRight, @@ -79,7 +83,12 @@ import { importTenantsCSV, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { normalizeAdminRole } from "../../../lib/roles"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; +import { + filterNonHanmacFamilyTenants, + isHanmacFamilyUser, +} from "../../users/orgChartPicker"; import { isSeedTenant } from "../utils/protectedTenants"; import { type TenantImportPreviewRow, @@ -93,8 +102,14 @@ import { const tenantCSVTemplate = "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n"; +const tenantPageSize = 500; +const tenantVirtualizationThreshold = 250; +const tenantEstimatedRowHeight = 73; +const tenantLoadAheadPx = 360; +const tenantLoadAheadRows = 30; type TenantSortKey = keyof TenantSummary | "recursiveMemberCount"; +type TenantListRow = TenantSummary & { recursiveMemberCount: number }; const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { @@ -234,12 +249,11 @@ function TenantListPage() { const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list"); const [selectedIds, setSelectedIds] = React.useState([]); const [search, setSearch] = React.useState(""); - const [sortConfig, setSortConfig] = React.useState | null>( - { + const [sortConfig, setSortConfig] = + React.useState | null>({ key: "createdAt", direction: "desc", - }, - ); + }); const fileInputRef = React.useRef(null); const [importMessage, setImportMessage] = React.useState(""); const [previewRows, setPreviewRows] = React.useState< @@ -255,15 +269,17 @@ function TenantListPage() { Record >({}); const [previewOpen, setPreviewOpen] = React.useState(false); + const tenantTableScrollRef = React.useRef(null); const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, }); + const profileRole = normalizeAdminRole(profile?.role); // Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list React.useEffect(() => { - if (profile?.role === "tenant_admin") { + if (profile && profileRole === "tenant_admin") { const manageableCount = profile.manageableTenants?.length ?? 0; if ( (manageableCount === 1 || manageableCount === 0) && @@ -272,15 +288,24 @@ function TenantListPage() { navigate(`/tenants/${profile.tenantId}`, { replace: true }); } } - }, [profile, navigate]); + }, [profile, profileRole, navigate]); - const query = useQuery({ - queryKey: ["tenants", { limit: 1000, offset: 0 }], - queryFn: () => fetchTenants(1000, 0), + const query = useInfiniteQuery({ + queryKey: ["tenants", "lazy"], + queryFn: ({ pageParam }) => + fetchTenants( + tenantPageSize, + 0, + undefined, + pageParam ? pageParam : undefined, + ), + initialPageParam: "", + getNextPageParam: (lastPage) => + lastPage.nextCursor || lastPage.next_cursor || undefined, enabled: - profile?.role === "super_admin" || - (profile?.role === "tenant_admin" && - (profile.manageableTenants?.length ?? 0) > 1), + profileRole === "super_admin" || + (profileRole === "tenant_admin" && + (profile?.manageableTenants?.length ?? 0) > 1), }); const deleteMutation = useMutation({ @@ -345,8 +370,8 @@ function TenantListPage() { if ( profile && - profile.role !== "super_admin" && - profile.role !== "tenant_admin" + profileRole !== "super_admin" && + profileRole !== "tenant_admin" ) { return (
@@ -361,7 +386,7 @@ function TenantListPage() { } if ( - profile?.role === "tenant_admin" && + profileRole === "tenant_admin" && (profile.manageableTenants?.length ?? 0) <= 1 ) { return null; @@ -374,7 +399,28 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const allTenants = query.data?.items ?? []; + const tenantPages = query.data?.pages ?? []; + const rawTenants = tenantPages.flatMap((page) => page.items); + const tenantTotal = tenantPages[0]?.total ?? 0; + const hanmacFamilyTenantId = React.useMemo(() => { + const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; + if (typeof envTenantId === "string" && envTenantId.trim()) { + return envTenantId.trim(); + } + return rawTenants.find((tenant) => tenant.slug === "hanmac-family")?.id; + }, [rawTenants]); + const allTenants = React.useMemo(() => { + if (profileRole === "super_admin") { + return rawTenants; + } + if ( + profile && + isHanmacFamilyUser(profile, rawTenants, hanmacFamilyTenantId) + ) { + return rawTenants; + } + return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId); + }, [hanmacFamilyTenantId, profile, profileRole, rawTenants]); const importParentOptionGroups = buildTenantImportParentOptionGroups(allTenants); const tenantSortResolvers = React.useMemo< @@ -389,15 +435,8 @@ function TenantListPage() { [], ); const tenants = React.useMemo(() => { - // 1. Calculate recursive counts - // buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally. - // However, to easily map them back to a flat list, we can just run the builder, - // and then extract the recursive counts. const treeResult = buildTenantFullTree(allTenants); - // Flatten the tree or just extract from allTenants map? - // buildTenantFullTree does NOT mutate the objects passed in allTenants. It creates new ones. - // Let's create a map of id -> recursiveMemberCount const recursiveCounts = new Map(); const extractCounts = (nodes: TenantNode[]) => { for (const node of nodes) { @@ -424,6 +463,56 @@ function TenantListPage() { return sortItems(enriched, sortConfig, tenantSortResolvers); }, [allTenants, search, sortConfig, tenantSortResolvers]); + const shouldVirtualizeTenants = + tenants.length >= tenantVirtualizationThreshold; + const tenantRowVirtualizer = useVirtualizer({ + count: tenants.length, + getScrollElement: () => tenantTableScrollRef.current, + estimateSize: () => tenantEstimatedRowHeight, + overscan: 12, + enabled: shouldVirtualizeTenants, + }); + const virtualTenantRows = shouldVirtualizeTenants + ? tenantRowVirtualizer.getVirtualItems() + : []; + const lastVirtualTenantIndex = + virtualTenantRows[virtualTenantRows.length - 1]?.index ?? -1; + + const fetchNextTenantPage = React.useCallback(() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + void query.fetchNextPage(); + } + }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]); + + const handleTenantTableScroll = React.useCallback( + (event: React.UIEvent) => { + const scrollElement = event.currentTarget; + const distanceToEnd = + scrollElement.scrollHeight - + scrollElement.scrollTop - + scrollElement.clientHeight; + if (distanceToEnd <= tenantLoadAheadPx) { + fetchNextTenantPage(); + } + }, + [fetchNextTenantPage], + ); + + React.useEffect(() => { + if ( + !shouldVirtualizeTenants || + lastVirtualTenantIndex < tenants.length - tenantLoadAheadRows + ) { + return; + } + fetchNextTenantPage(); + }, [ + fetchNextTenantPage, + lastVirtualTenantIndex, + shouldVirtualizeTenants, + tenants.length, + ]); + const requestSort = (key: TenantSortKey) => { setSortConfig((current) => toggleSort(current, key)); }; @@ -599,6 +688,94 @@ function TenantListPage() { deleteMutation.mutate(tenantId); }; + const renderTenantRow = ( + tenant: TenantListRow, + options?: { + style?: React.CSSProperties; + virtualIndex?: number; + }, + ) => ( + + + {isSeedTenant(tenant) ? ( + + ) : ( + handleSelect(tenant, !!checked)} + /> + )} + + + {tenant.id} + + +
+ + {tenant.name} + + {isSeedTenant(tenant) && ( + + {t("ui.admin.tenants.seed_badge", "초기 설정")} + + )} +
+
+ + + {tenant.type} + + + {tenant.slug} + + + {t(`ui.common.status.${tenant.status}`, tenant.status)} + + + {tenant.recursiveMemberCount} + + {tenant.createdAt + ? new Date(tenant.createdAt).toLocaleString("ko-KR") + : "-"} + + + + +
+ ); + return (
@@ -727,7 +904,7 @@ function TenantListPage() { "msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", { - count: query.data?.total ?? 0, + count: tenantTotal, }, )} @@ -770,7 +947,12 @@ function TenantListPage() { className="flex-1 flex flex-col min-h-0 m-0" >
-
+
@@ -844,7 +1026,18 @@ function TenantListPage() { - + {query.isLoading && ( @@ -865,102 +1058,26 @@ function TenantListPage() { )} - {tenants.map((tenant) => ( - - - {isSeedTenant(tenant) ? ( - - ) : ( - - handleSelect(tenant, !!checked) - } - /> - )} - - - {tenant.id} - - -
- - {tenant.name} - - {isSeedTenant(tenant) && ( - - {t( - "ui.admin.tenants.seed_badge", - "초기 설정", - )} - - )} -
-
- - - {tenant.type} - - - - {tenant.slug} - - - - {t( - `ui.common.status.${tenant.status}`, - tenant.status, - )} - - - - {tenant.recursiveMemberCount} - - - {tenant.createdAt - ? new Date(tenant.createdAt).toLocaleString( - "ko-KR", - ) - : "-"} - - - - -
- ))} + {shouldVirtualizeTenants + ? virtualTenantRows.map((virtualRow) => { + const tenant = tenants[virtualRow.index]; + if (!tenant) { + return null; + } + return renderTenantRow(tenant, { + virtualIndex: virtualRow.index, + style: { + position: "absolute", + top: 0, + left: 0, + width: "100%", + display: "table", + tableLayout: "fixed", + transform: `translateY(${virtualRow.start}px)`, + }, + }); + }) + : tenants.map((tenant) => renderTenantRow(tenant))}
diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 3cd8eabc..ac093868 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -601,6 +601,13 @@ function UserListPage() { sortConfig={sortConfig} sortKey="status" /> + {t("msg.common.loading", "로딩 중...")} @@ -648,7 +655,7 @@ function UserListPage() { {!query.isLoading && items.length === 0 && ( {t( diff --git a/common/locales/en.toml b/common/locales/en.toml index 55082e0c..a13f95f8 100644 --- a/common/locales/en.toml +++ b/common/locales/en.toml @@ -14,6 +14,7 @@ unknown_error = "unknown error" 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 7e1acee5..93555438 100644 --- a/common/locales/ko.toml +++ b/common/locales/ko.toml @@ -14,6 +14,7 @@ unknown_error = "알 수 없는 오류" actions = "액션" add = "추가" all = "전체" +apply = "적용" admin_only = "관리자 전용" apply = "적용" approve = "승인" diff --git a/common/locales/template.toml b/common/locales/template.toml index e1a8b0dc..b4a670e2 100644 --- a/common/locales/template.toml +++ b/common/locales/template.toml @@ -14,6 +14,7 @@ unknown_error = "" actions = "" add = "" all = "" +apply = "" admin_only = "" apply = "" approve = "" diff --git a/common/ui/table.ts b/common/ui/table.ts index 40a47e30..feafc531 100644 --- a/common/ui/table.ts +++ b/common/ui/table.ts @@ -1,4 +1,4 @@ -export const commonTableWrapperClass = "relative w-full overflow-auto"; +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"; diff --git a/devfront/src/components/ui/table.tsx b/devfront/src/components/ui/table.tsx index 46370544..0b0022a5 100644 --- a/devfront/src/components/ui/table.tsx +++ b/devfront/src/components/ui/table.tsx @@ -17,11 +17,7 @@ const Table = React.forwardRef< React.HTMLAttributes >(({ className, ...props }, ref) => (
- +
)); Table.displayName = "Table"; @@ -30,7 +26,11 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -38,11 +38,7 @@ const TableBody = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableBody.displayName = "TableBody"; @@ -62,11 +58,7 @@ const TableRow = React.forwardRef< HTMLTableRowElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableRow.displayName = "TableRow"; @@ -74,11 +66,7 @@ const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
+ )); TableHead.displayName = "TableHead"; @@ -86,11 +74,7 @@ const TableCell = React.forwardRef< HTMLTableCellElement, React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableCell.displayName = "TableCell"; diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index 941690dd..4c3666ac 100644 --- a/devfront/src/features/audit/AuditLogsPage.tsx +++ b/devfront/src/features/audit/AuditLogsPage.tsx @@ -288,154 +288,164 @@ 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} + + ); + }) + )} +
diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index a078b1de..9c70cef8 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -449,7 +449,10 @@ function ClientConsentsPage() { {t("ui.dev.clients.consents.table.status", "Status")} - {t("ui.dev.clients.consents.table.scopes", "Granted Scopes")} + {t( + "ui.dev.clients.consents.table.scopes", + "Granted Scopes", + )} {t( @@ -553,7 +556,10 @@ function ClientConsentsPage() { {row.status === "revoked" && row.deletedAt ? ( - {t("ui.dev.clients.consents.revoked_at", "Revoked: ")} + {t( + "ui.dev.clients.consents.revoked_at", + "Revoked: ", + )} {new Date(row.deletedAt).toLocaleString()} ) : row.authenticatedAt ? ( diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 2d840605..d9b50101 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -1,12 +1,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { - 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"; @@ -128,12 +122,11 @@ function ClientsPage() { const [statusFilter, setStatusFilter] = useState("all"); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); - const [sortConfig, setSortConfig] = useState | null>( - { + const [sortConfig, setSortConfig] = + useState | null>({ key: "createdAt", direction: "desc", - }, - ); + }); const clients = data?.items || []; const clientSortResolvers = useMemo< @@ -460,7 +453,10 @@ function ClientsPage() {

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

@@ -625,7 +624,9 @@ function ClientsPage() {
{client.status === "active" diff --git a/locales/en.toml b/locales/en.toml index 7c0724da..057314f9 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 377ce3dc..7941b9f7 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 90a2039f..4a5024dd 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 46370544..0b0022a5 100644 --- a/orgfront/src/components/ui/table.tsx +++ b/orgfront/src/components/ui/table.tsx @@ -17,11 +17,7 @@ const Table = React.forwardRef< React.HTMLAttributes >(({ className, ...props }, ref) => (
- +
)); Table.displayName = "Table"; @@ -30,7 +26,11 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -38,11 +38,7 @@ const TableBody = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableBody.displayName = "TableBody"; @@ -62,11 +58,7 @@ const TableRow = React.forwardRef< HTMLTableRowElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableRow.displayName = "TableRow"; @@ -74,11 +66,7 @@ const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
+ )); TableHead.displayName = "TableHead"; @@ -86,11 +74,7 @@ const TableCell = React.forwardRef< HTMLTableCellElement, React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableCell.displayName = "TableCell"; 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/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: From 79f5ace7efae8435a84f9ee880097e3d49e1f5d2 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 14 May 2026 10:56:23 +0900 Subject: [PATCH 15/16] =?UTF-8?q?=EB=88=84=EB=9D=BD=20=ED=82=A4=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A6=B0=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantListPage.tsx | 4 ++- devfront/src/features/auth/LoginPage.tsx | 27 +++++++++++++++---- devfront/src/lib/authConfig.ts | 6 ++++- devfront/tests/devfront-login.spec.ts | 17 +++--------- userfront/assets/translations/en.toml | 3 ++- userfront/assets/translations/ko.toml | 3 ++- userfront/assets/translations/template.toml | 1 + 7 files changed, 38 insertions(+), 23 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 94d32e33..d4e3bebb 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -755,7 +755,9 @@ function TenantListPage() { {t(`ui.common.status.${tenant.status}`, tenant.status)} - {tenant.recursiveMemberCount} + + {tenant.recursiveMemberCount} + {tenant.createdAt ? new Date(tenant.createdAt).toLocaleString("ko-KR") 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/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/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/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 0e5fed73..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" @@ -692,3 +692,4 @@ toggle_label = "Show active sessions only" [msg.userfront.audit.filter] description = "Toggle to view only active sessions." + diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index e423a2a5..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 = "비밀번호 변경" @@ -913,3 +913,4 @@ toggle_label = "활성 세션만 보기" [msg.userfront.audit.filter] description = "활성화된 세션만 보려면 토글을 켜주세요." + diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 28b9cff8..44669479 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -885,3 +885,4 @@ toggle_label = "" [msg.userfront.audit.filter] description = "" + From 258c91a74020db676a0dd65d03d16ab2271a02dd Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 14 May 2026 11:07:28 +0900 Subject: [PATCH 16/16] =?UTF-8?q?adminfront=20profile=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/features/tenants/routes/TenantListPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index d4e3bebb..0df53fb3 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -387,7 +387,7 @@ function TenantListPage() { if ( profileRole === "tenant_admin" && - (profile.manageableTenants?.length ?? 0) <= 1 + (profile?.manageableTenants?.length ?? 0) <= 1 ) { return null; }