From 53dacda5d51dcbab1a1106f96c23fded4a4607c3 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 20 May 2026 13:25:21 +0900 Subject: [PATCH 01/25] feat(adminfront): add Data Management menu to User tab This commit introduces a 'Data Management' dropdown menu to the User list page, consolidating user CSV import, template download, and export actions. It aligns the UI with the existing Tenant list page. --- .../src/features/users/UserListPage.tsx | 76 +++++++++++++------ .../users/components/UserBulkUploadModal.tsx | 25 ++++-- 2 files changed, 74 insertions(+), 27 deletions(-) diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 6142e93c..7e30b90c 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -7,7 +7,9 @@ import { ChevronDown, ChevronLeft, ChevronRight, + Download, FileDown, + FileSpreadsheet, LayoutDashboard, Plus, RefreshCw, @@ -15,6 +17,7 @@ import { Settings2, ShieldCheck, Trash2, + Upload, } from "lucide-react"; import * as React from "react"; import { Link, useNavigate } from "react-router-dom"; @@ -90,7 +93,10 @@ import { } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { isSuperAdminRole } from "../../lib/roles"; -import { UserBulkUploadModal } from "./components/UserBulkUploadModal"; +import { + UserBulkUploadModal, + downloadUserTemplate, +} from "./components/UserBulkUploadModal"; import { normalizeUserStatusValue, type UserStatusValue, @@ -485,27 +491,53 @@ function UserListPage() { {t("ui.common.refresh", "새로고침")} - - - query.refetch()} /> + + + + + + + + {t("ui.admin.users.csv_template", "템플릿 다운로드")} + + + query.refetch()} + /> + + handleExport(false)} + disabled={exportMutation.isPending} + data-testid="user-export-menu-item" + className="cursor-pointer" + > + + {t("ui.common.export_without_ids", "UUID 제외 내보내기")} + + handleExport(true)} + disabled={exportMutation.isPending} + data-testid="user-export-with-ids-menu-item" + className="cursor-pointer" + > + + {t("ui.common.export_with_ids", "UUID 포함 내보내기")} + + + - - From c7d25f36118905cf68e5b539e81be4cec1398b88 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 18 May 2026 11:00:06 +0900 Subject: [PATCH 04/25] =?UTF-8?q?API=20=ED=82=A4=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20locale=20=EC=A0=84=ED=99=98=20=EC=8B=9C=20/api-keys?= =?UTF-8?q?=20404=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/package.json | 1 + adminfront/pnpm-lock.yaml | 3469 +++++++++++++++++ .../common/LanguageSelector.test.tsx | 32 + .../components/common/LanguageSelector.tsx | 26 +- .../common/LocaleRefreshBoundary.test.tsx | 34 + .../common/LocaleRefreshBoundary.tsx | 27 + adminfront/src/main.tsx | 5 +- 7 files changed, 3584 insertions(+), 10 deletions(-) create mode 100644 adminfront/pnpm-lock.yaml create mode 100644 adminfront/src/components/common/LanguageSelector.test.tsx create mode 100644 adminfront/src/components/common/LocaleRefreshBoundary.test.tsx create mode 100644 adminfront/src/components/common/LocaleRefreshBoundary.tsx diff --git a/adminfront/package.json b/adminfront/package.json index 288be86f..dfacdb56 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -53,6 +53,7 @@ "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.5.0", + "jsdom": "^28.1.0", "postcss": "^8.5.14", "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", diff --git a/adminfront/pnpm-lock.yaml b/adminfront/pnpm-lock.yaml new file mode 100644 index 00000000..1d9a304d --- /dev/null +++ b/adminfront/pnpm-lock.yaml @@ -0,0 +1,3469 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-avatar': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-query': + specifier: ^5.100.10 + version: 5.100.10(react@19.2.6) + '@tanstack/react-query-devtools': + specifier: ^5.100.10 + version: 5.100.10(@tanstack/react-query@5.100.10(react@19.2.6))(react@19.2.6) + '@tanstack/react-virtual': + specifier: ^3.13.24 + version: 3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + axios: + specifier: ^1.16.1 + version: 1.16.1 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^1.14.0 + version: 1.16.0(react@19.2.6) + oidc-client-ts: + specifier: ^3.5.0 + version: 3.5.0 + react: + specifier: ^19.2.6 + version: 19.2.6 + react-dom: + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) + react-hook-form: + specifier: ^7.75.0 + version: 7.76.0(react@19.2.6) + react-oidc-context: + specifier: ^3.3.1 + version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.6) + react-router-dom: + specifier: ^7.15.0 + version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^25.7.0 + version: 25.8.0 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@types/react-router-dom': + specifier: ^5.3.3 + version: 5.3.3 + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.2(vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7)) + autoprefixer: + specifier: ^10.5.0 + version: 10.5.0(postcss@8.5.14) + jsdom: + specifier: ^28.1.0 + version: 28.1.0 + postcss: + specifier: ^8.5.14 + version: 8.5.14 + tailwindcss: + specifier: ^3.4.19 + version: 3.4.19 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.19) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.0.12 + version: 8.0.13(@types/node@25.8.0)(jiti@1.21.7) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@25.8.0)(jsdom@28.1.0)(vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7)) + +packages: + + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4': + resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/types@0.130.0': + resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.11': + resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/binding-android-arm64@1.0.1': + resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.1': + resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.1': + resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.1': + resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.1': + resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.1': + resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.1': + resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.1': + resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.1': + resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.1': + resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tanstack/query-core@5.100.10': + resolution: {integrity: sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==} + + '@tanstack/query-devtools@5.100.10': + resolution: {integrity: sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw==} + + '@tanstack/react-query-devtools@5.100.10': + resolution: {integrity: sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg==} + peerDependencies: + '@tanstack/react-query': ^5.100.10 + react: ^18 || ^19 + + '@tanstack/react-query@5.100.10': + resolution: {integrity: sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/history@4.7.11': + resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} + + '@types/node@25.8.0': + resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react-router-dom@5.3.3': + resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==} + + '@types/react-router@5.1.20': + resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + + baseline-browser-mapping@2.10.30: + resolution: {integrity: sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.357: + resolution: {integrity: sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + + lucide-react@1.16.0: + resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.44: + resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + oidc-client-ts@3.5.0: + resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==} + engines: {node: '>=18'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react-hook-form@7.76.0: + resolution: {integrity: sha512-eKtLGgFeSgkHqQD8J59AMZ9a4uD1D83iSIzt4YlTGD7liDen5rrjcUO1rVIGd9yC1gofryjtHbv+4ny4hkLWlw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-oidc-context@3.3.1: + resolution: {integrity: sha512-/Azvm9W4DhhOtSDBE73kFInh1b6zZRRfILKbgmk2syExMF0PCYJOn/dGdOOi2BFX8x0rCeUe45NXHU+/+xDcrQ==} + engines: {node: '>=18'} + peerDependencies: + oidc-client-ts: ^3.1.0 + react: '>=16.14.0' + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.15.1: + resolution: {integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.15.1: + resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown@1.0.1: + resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@8.0.13: + resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + + '@alloc/quick-lru@5.2.0': {} + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.6 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/runtime@7.29.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@exodus/bytes@1.15.0': {} + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@floating-ui/utils@0.2.11': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-project/types@0.130.0': {} + + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/rect': 1.1.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/binding-android-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-x64@1.0.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.1': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tanstack/query-core@5.100.10': {} + + '@tanstack/query-devtools@5.100.10': {} + + '@tanstack/react-query-devtools@5.100.10(@tanstack/react-query@5.100.10(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/query-devtools': 5.100.10 + '@tanstack/react-query': 5.100.10(react@19.2.6) + react: 19.2.6 + + '@tanstack/react-query@5.100.10(react@19.2.6)': + dependencies: + '@tanstack/query-core': 5.100.10 + react: 19.2.6 + + '@tanstack/react-virtual@3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/virtual-core': 3.14.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@tanstack/virtual-core@3.14.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/history@4.7.11': {} + + '@types/node@25.8.0': + dependencies: + undici-types: 7.24.6 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react-router-dom@5.3.3': + dependencies: + '@types/history': 4.7.11 + '@types/react': 19.2.14 + '@types/react-router': 5.1.20 + + '@types/react-router@5.1.20': + dependencies: + '@types/history': 4.7.11 + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.13(@types/node@25.8.0)(jiti@1.21.7) + + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.13(@types/node@25.8.0)(jiti@1.21.7) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + autoprefixer@10.5.0(postcss@8.5.14): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001792 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.14 + postcss-value-parser: 4.2.0 + + axios@1.16.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + baseline-browser-mapping@2.10.30: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + binary-extensions@2.3.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.30 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.357 + node-releases: 2.0.44 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001792: {} + + chai@6.2.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@4.1.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.3.6 + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.357: {} + + entities@8.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fraction.js@5.3.4: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + indent-string@4.0.0: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + + jwt-decode@4.0.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lru-cache@11.3.6: {} + + lucide-react@1.16.0(react@19.2.6): + dependencies: + react: 19.2.6 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mdn-data@2.27.1: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-indent@1.0.1: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + node-releases@2.0.44: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + obug@2.1.1: {} + + oidc-client-ts@3.5.0: + dependencies: + jwt-decode: 4.0.0 + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + path-parse@1.0.7: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + + postcss-import@15.1.0(postcss@8.5.14): + dependencies: + postcss: 8.5.14 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.14): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.14 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.14): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.14 + + postcss-nested@6.2.0(postcss@8.5.14): + dependencies: + postcss: 8.5.14 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-hook-form@7.76.0(react@19.2.6): + dependencies: + react: 19.2.6 + + react-is@17.0.2: {} + + react-oidc-context@3.3.1(oidc-client-ts@3.5.0)(react@19.2.6): + dependencies: + oidc-client-ts: 3.5.0 + react: 19.2.6 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.6) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + react-router-dom@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-router: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + + react-router@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + cookie: 1.1.1 + react: 19.2.6 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + get-nonce: 1.0.1 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react@19.2.6: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rolldown@1.0.1: + dependencies: + '@oxc-project/types': 0.130.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.1 + '@rolldown/binding-darwin-arm64': 1.0.1 + '@rolldown/binding-darwin-x64': 1.0.1 + '@rolldown/binding-freebsd-x64': 1.0.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.1 + '@rolldown/binding-linux-arm64-gnu': 1.0.1 + '@rolldown/binding-linux-arm64-musl': 1.0.1 + '@rolldown/binding-linux-ppc64-gnu': 1.0.1 + '@rolldown/binding-linux-s390x-gnu': 1.0.1 + '@rolldown/binding-linux-x64-gnu': 1.0.1 + '@rolldown/binding-linux-x64-musl': 1.0.1 + '@rolldown/binding-openharmony-arm64': 1.0.1 + '@rolldown/binding-wasm32-wasi': 1.0.1 + '@rolldown/binding-win32-arm64-msvc': 1.0.1 + '@rolldown/binding-win32-x64-msvc': 1.0.1 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + set-cookie-parser@2.7.2: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tailwind-merge@3.6.0: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.19): + dependencies: + tailwindcss: 3.4.19 + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.14 + postcss-import: 15.1.0(postcss@8.5.14) + postcss-js: 4.1.0(postcss@8.5.14) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.14) + postcss-nested: 6.2.0(postcss@8.5.14) + postcss-selector-parser: 6.1.2 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@7.0.30: {} + + tldts@7.0.30: + dependencies: + tldts-core: 7.0.30 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.30 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + undici@7.25.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.6): + dependencies: + react: 19.2.6 + + util-deprecate@1.0.2: {} + + vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.8.0 + fsevents: 2.3.3 + jiti: 1.21.7 + + vitest@4.1.6(@types/node@25.8.0)(jsdom@28.1.0)(vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.13(@types/node@25.8.0)(jiti@1.21.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.8.0 + jsdom: 28.1.0 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + zod@4.4.3: {} diff --git a/adminfront/src/components/common/LanguageSelector.test.tsx b/adminfront/src/components/common/LanguageSelector.test.tsx new file mode 100644 index 00000000..0d505b96 --- /dev/null +++ b/adminfront/src/components/common/LanguageSelector.test.tsx @@ -0,0 +1,32 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import LanguageSelector from "./LanguageSelector"; + +vi.mock("../../lib/i18n", () => ({ + t: (_key: string, fallback?: string) => fallback ?? "", +})); + +describe("LanguageSelector", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("updates locale without reloading the page", () => { + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + window.localStorage.setItem("locale", "ko"); + + render(); + + fireEvent.change(screen.getByRole("combobox"), { + target: { value: "en" }, + }); + + expect(window.localStorage.getItem("locale")).toBe("en"); + expect( + dispatchSpy.mock.calls.some( + ([event]) => event instanceof Event && event.type === "localechange", + ), + ).toBe(true); + }); +}); diff --git a/adminfront/src/components/common/LanguageSelector.tsx b/adminfront/src/components/common/LanguageSelector.tsx index 9612b744..b4e33c67 100644 --- a/adminfront/src/components/common/LanguageSelector.tsx +++ b/adminfront/src/components/common/LanguageSelector.tsx @@ -1,8 +1,6 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n"; import { t } from "../../lib/i18n"; - -const LOCALE_STORAGE_KEY = "locale"; -const LOCALE_CHANGED_EVENT = "baron_locale_changed"; const SUPPORTED_LOCALES = ["ko", "en"] as const; type Locale = (typeof SUPPORTED_LOCALES)[number]; @@ -29,17 +27,27 @@ function resolveLocale(): Locale { function LanguageSelector() { const [locale, setLocale] = useState(resolveLocale()); + useEffect(() => { + const syncLocale = () => { + setLocale(resolveLocale()); + }; + + window.addEventListener("localechange", syncLocale); + window.addEventListener("storage", syncLocale); + + return () => { + window.removeEventListener("localechange", syncLocale); + window.removeEventListener("storage", syncLocale); + }; + }, []); + const handleChange = (next: Locale) => { if (next === locale) { return; } window.localStorage.setItem(LOCALE_STORAGE_KEY, next); setLocale(next); - if (import.meta.env.MODE === "development") { - window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT)); - return; - } - window.location.reload(); + window.dispatchEvent(new Event("localechange")); }; return ( diff --git a/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx b/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx new file mode 100644 index 00000000..24e945d8 --- /dev/null +++ b/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx @@ -0,0 +1,34 @@ +import { act, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import LocaleRefreshBoundary from "./LocaleRefreshBoundary"; + +let renderCount = 0; + +function RenderCounter() { + renderCount += 1; + return {renderCount}; +} + +describe("LocaleRefreshBoundary", () => { + beforeEach(() => { + window.localStorage.clear(); + renderCount = 0; + }); + + it("re-renders children when locale changes", async () => { + render( + + + , + ); + + expect(screen.getByText("1")).toBeInTheDocument(); + + await act(async () => { + window.localStorage.setItem("locale", "en"); + window.dispatchEvent(new Event("localechange")); + }); + + expect(screen.getByText("2")).toBeInTheDocument(); + }); +}); diff --git a/adminfront/src/components/common/LocaleRefreshBoundary.tsx b/adminfront/src/components/common/LocaleRefreshBoundary.tsx new file mode 100644 index 00000000..64cc3841 --- /dev/null +++ b/adminfront/src/components/common/LocaleRefreshBoundary.tsx @@ -0,0 +1,27 @@ +import { Fragment, type ReactNode, useEffect, useState } from "react"; + +type LocaleRefreshBoundaryProps = { + children: ReactNode; +}; + +function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) { + const [localeVersion, setLocaleVersion] = useState(0); + + useEffect(() => { + const syncLocale = () => { + setLocaleVersion((current) => current + 1); + }; + + window.addEventListener("localechange", syncLocale); + window.addEventListener("storage", syncLocale); + + return () => { + window.removeEventListener("localechange", syncLocale); + window.removeEventListener("storage", syncLocale); + }; + }, []); + + return {children}; +} + +export default LocaleRefreshBoundary; diff --git a/adminfront/src/main.tsx b/adminfront/src/main.tsx index f2c82b6e..ca31abd2 100644 --- a/adminfront/src/main.tsx +++ b/adminfront/src/main.tsx @@ -3,6 +3,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { AuthProvider } from "react-oidc-context"; import { RouterProvider } from "react-router-dom"; +import LocaleRefreshBoundary from "./components/common/LocaleRefreshBoundary"; import { queryClient } from "./app/queryClient"; import { router } from "./app/routes"; import { Toaster } from "./components/ui/toaster"; @@ -19,7 +20,9 @@ createRoot(rootElement).render( - + + + From e7dab0f8fdd45ccf9de286cc0dea2d76407fcd04 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 18 May 2026 11:36:43 +0900 Subject: [PATCH 05/25] =?UTF-8?q?adminfront=20/api-keys=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0=EC=B9=A8=20404=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/scripts/runtime-mode.sh | 4 +- adminfront/scripts/serve-prod.mjs | 153 ++++++++++++++++++ .../features/api-keys/ApiKeyListPage.test.tsx | 20 +++ .../src/features/api-keys/ApiKeyListPage.tsx | 1 + 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 adminfront/scripts/serve-prod.mjs diff --git a/adminfront/scripts/runtime-mode.sh b/adminfront/scripts/runtime-mode.sh index be6c0930..075505c8 100644 --- a/adminfront/scripts/runtime-mode.sh +++ b/adminfront/scripts/runtime-mode.sh @@ -74,8 +74,8 @@ ensure_frontend_dependencies() { ensure_frontend_dependencies if [ "$mode" = "production" ]; then - echo "Running in production mode with Vite preview..." - exec sh -c "npm run build && npm run preview -- --host 0.0.0.0" + echo "Running in production mode with custom static server..." + exec sh -c "npm run build && node ./scripts/serve-prod.mjs" fi echo "Running in development mode..." diff --git a/adminfront/scripts/serve-prod.mjs b/adminfront/scripts/serve-prod.mjs new file mode 100644 index 00000000..804a7052 --- /dev/null +++ b/adminfront/scripts/serve-prod.mjs @@ -0,0 +1,153 @@ +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { extname, join, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = fileURLToPath(new URL("..", import.meta.url)); +const distDir = resolve( + process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist", +); +const host = process.env.HOST ?? "0.0.0.0"; +const port = Number(process.env.PORT ?? process.env.ADMINFRONT_PORT ?? 5173); +const backendTarget = new URL( + process.env.API_PROXY_TARGET || "http://localhost:3000", +); + +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".svg": "image/svg+xml", +}; + +function getContentType(filePath) { + return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"; +} + +function sendJson(res, statusCode, body) { + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(JSON.stringify(body)); +} + +function toSafePath(pathname) { + const decoded = decodeURIComponent(pathname); + const relative = decoded.replace(/^\/+/, ""); + const safe = normalize(relative).replace(/^(\.\.(?:[\\/]|$))+/, ""); + return join(distDir, safe); +} + +async function tryReadFile(filePath) { + try { + return await readFile(filePath); + } catch { + return null; + } +} + +async function proxyToBackend(req, res, pathname, search) { + const target = new URL(pathname + search, backendTarget); + const headers = new Headers(); + + for (const [key, value] of Object.entries(req.headers)) { + if (!value) continue; + if (key === "host" || key === "content-length" || key === "connection") { + continue; + } + if (Array.isArray(value)) { + headers.set(key, value.join(", ")); + continue; + } + headers.set(key, value); + } + + const hasBody = !["GET", "HEAD"].includes(req.method ?? "GET"); + const response = await fetch(target, { + method: req.method, + headers, + body: hasBody ? req : undefined, + duplex: hasBody ? "half" : undefined, + }); + + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("content-length"); + responseHeaders.delete("transfer-encoding"); + responseHeaders.delete("connection"); + + res.writeHead(response.status, Object.fromEntries(responseHeaders.entries())); + + if (req.method === "HEAD") { + res.end(); + return; + } + + const arrayBuffer = await response.arrayBuffer(); + res.end(Buffer.from(arrayBuffer)); +} + +async function serveStatic(req, res, pathname) { + const indexPath = join(distDir, "index.html"); + const filePath = toSafePath(pathname); + + let resolvedPath = filePath; + try { + const fileStat = await stat(resolvedPath); + if (fileStat.isDirectory()) { + resolvedPath = join(resolvedPath, "index.html"); + } + } catch { + resolvedPath = indexPath; + } + + let body = await tryReadFile(resolvedPath); + if (!body) { + body = await tryReadFile(indexPath); + resolvedPath = indexPath; + } + + if (!body) { + sendJson(res, 500, { error: "dist_not_found" }); + return; + } + + res.writeHead(200, { + "Content-Type": getContentType(resolvedPath), + "Cache-Control": resolvedPath.endsWith("index.html") + ? "no-cache" + : "public, max-age=31536000, immutable", + }); + + if (req.method === "HEAD") { + res.end(); + return; + } + + res.end(body); +} + +createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const { pathname, search } = url; + + if (pathname === "/api" || pathname.startsWith("/api/")) { + await proxyToBackend(req, res, pathname, search); + return; + } + + const normalizedPath = pathname === "/" ? "/index.html" : pathname; + await serveStatic(req, res, normalizedPath); + } catch (error) { + sendJson(res, 500, { + error: "internal_server_error", + message: error instanceof Error ? error.message : String(error), + }); + } +}).listen(port, host, () => { + console.log(`Adminfront production server listening on http://${host}:${port}`); +}); diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx index d2a0d47e..43d43135 100644 --- a/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx +++ b/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx @@ -10,6 +10,10 @@ import { } from "../../lib/adminApi"; import ApiKeyListPage from "./ApiKeyListPage"; +vi.mock("../../lib/i18n", () => ({ + t: (_key: string, fallback?: string) => fallback ?? "", +})); + vi.mock("../../lib/adminApi", () => ({ fetchApiKeys: vi.fn(async () => ({ items: [ @@ -102,4 +106,20 @@ describe("ApiKeyListPage", () => { ).toBeInTheDocument(); expect(fetchApiKeys).toHaveBeenCalled(); }); + + it("refresh button refetches the list without navigation", async () => { + const user = userEvent.setup(); + renderPage(); + + await screen.findByText("client-id-stable"); + + const refreshButton = screen.getByRole("button", { name: /새로고침/ }); + expect(refreshButton).toHaveAttribute("type", "button"); + + await user.click(refreshButton); + + await waitFor(() => { + expect(fetchApiKeys).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.tsx index c3efe89e..95931ade 100644 --- a/adminfront/src/features/api-keys/ApiKeyListPage.tsx +++ b/adminfront/src/features/api-keys/ApiKeyListPage.tsx @@ -172,6 +172,7 @@ function ApiKeyListPage() { actions={ <> @@ -117,18 +155,33 @@ function PermissionChecker() { {result.allowed ? ( <> -
Access ALLOWED
+
+ {t( + "ui.admin.auth_guard.checker.allowed", + "Access ALLOWED", + )} +

- 해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 - 포함) + {t( + "ui.admin.auth_guard.checker.allowed_description", + "The subject has access to the requested resource, including inherited permissions.", + )}

) : ( <> -
Access DENIED
+
+ {t( + "ui.admin.auth_guard.checker.denied", + "Access DENIED", + )} +

- 해당 사용자는 요청한 리소스에 대해 권한이 없습니다. + {t( + "ui.admin.auth_guard.checker.denied_description", + "The subject does not have access to the requested resource.", + )}

)} diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx index 0b36c1ed..e7da2193 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -7,8 +7,11 @@ import { fetchMe, fetchOrphanUserLoginIDs, } from "../../lib/adminApi"; +import { createI18nMock } from "../../test/i18nMock"; import DataIntegrityPage from "./DataIntegrityPage"; +vi.mock("../../lib/i18n", () => createI18nMock()); + let currentRole = "super_admin"; const integrityReport = { @@ -92,6 +95,7 @@ describe("DataIntegrityPage", () => { beforeEach(() => { currentRole = "super_admin"; vi.clearAllMocks(); + window.localStorage.setItem("locale", "ko"); }); it("renders integrity report for super_admin", async () => { @@ -161,4 +165,20 @@ describe("DataIntegrityPage", () => { expect(fetchMe).toHaveBeenCalled(); expect(fetchDataIntegrityReport).not.toHaveBeenCalled(); }); + + it("renders localized integrity labels in English", async () => { + window.localStorage.setItem("locale", "en"); + renderPage(); + + expect( + await screen.findByText("Data Integrity Check"), + ).toBeInTheDocument(); + expect(await screen.findByText("Tenant integrity")).toBeInTheDocument(); + expect(await screen.findByText("Duplicate tenant slug")).toBeInTheDocument(); + expect( + await screen.findByText( + "Checks duplicate active tenant slugs using LOWER(TRIM(slug)).", + ), + ).toBeInTheDocument(); + }); }); diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx index 50284603..abed94d5 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -18,6 +18,7 @@ import { fetchOrphanUserLoginIDs, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; +import { getAdminDateLocale } from "../../lib/locale"; function statusLabel(status: DataIntegrityStatus) { switch (status) { @@ -47,7 +48,7 @@ function formatDateTime(value?: string) { if (!value) return "-"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat("ko-KR", { + return new Intl.DateTimeFormat(getAdminDateLocale(), { dateStyle: "medium", timeStyle: "medium", }).format(date); @@ -78,6 +79,81 @@ function reasonLabel(reason: string) { } } +function integritySectionLabel(key: string, fallback: string) { + switch (key) { + case "tenant_integrity": + return t("ui.admin.integrity.section.tenant_integrity", fallback); + case "user_integrity": + return t("ui.admin.integrity.section.user_integrity", fallback); + default: + return fallback; + } +} + +function integrityCheckLabel(key: string, fallback: string) { + switch (key) { + case "duplicate_tenant_slugs": + return t( + "ui.admin.integrity.check.duplicate_tenant_slugs.title", + fallback, + ); + case "orphan_tenant_parents": + return t( + "ui.admin.integrity.check.orphan_tenant_parents.title", + fallback, + ); + case "orphan_user_tenant_memberships": + return t( + "ui.admin.integrity.check.orphan_user_tenant_memberships.title", + fallback, + ); + case "orphan_user_login_id_tenants": + return t( + "ui.admin.integrity.check.orphan_user_login_id_tenants.title", + fallback, + ); + case "orphan_user_login_id_users": + return t( + "ui.admin.integrity.check.orphan_user_login_id_users.title", + fallback, + ); + default: + return fallback; + } +} + +function integrityCheckDescription(key: string, fallback: string) { + switch (key) { + case "duplicate_tenant_slugs": + return t( + "msg.admin.integrity.check.duplicate_tenant_slugs.description", + fallback, + ); + case "orphan_tenant_parents": + return t( + "msg.admin.integrity.check.orphan_tenant_parents.description", + fallback, + ); + case "orphan_user_tenant_memberships": + return t( + "msg.admin.integrity.check.orphan_user_tenant_memberships.description", + fallback, + ); + case "orphan_user_login_id_tenants": + return t( + "msg.admin.integrity.check.orphan_user_login_id_tenants.description", + fallback, + ); + case "orphan_user_login_id_users": + return t( + "msg.admin.integrity.check.orphan_user_login_id_users.description", + fallback, + ); + default: + return fallback; + } +} + function recheckStatusText(status: "idle" | "running" | "success" | "error") { switch (status) { case "running": @@ -252,9 +328,6 @@ function DataIntegrityContent() {
-

- {t("ui.admin.integrity.kicker", "System")} -

{t("ui.admin.integrity.title", "데이터 정합성 검증")}

@@ -369,7 +442,9 @@ function DataIntegrityContent() { className="rounded-lg border border-border bg-card p-5" >
-

{section.label}

+

+ {integritySectionLabel(section.key, section.label)} +

{statusLabel(section.status)} @@ -383,9 +458,11 @@ function DataIntegrityContent() {
-
{check.label}
+
+ {integrityCheckLabel(check.key, check.label)} +

- {check.description} + {integrityCheckDescription(check.key, check.description)}

diff --git a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx index e1c4e63c..beeedb6c 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx @@ -7,9 +7,12 @@ import { fetchAdminRPUsageDaily, fetchDataIntegrityReport, } from "../../lib/adminApi"; +import { createI18nMock } from "../../test/i18nMock"; import AuthPage from "../auth/AuthPage"; import GlobalOverviewPage from "./GlobalOverviewPage"; +vi.mock("../../lib/i18n", () => createI18nMock()); + let currentRole = "super_admin"; vi.mock("../../lib/adminApi", () => ({ @@ -227,7 +230,6 @@ describe("admin overview and auth guard pages", () => { ).not.toBeInTheDocument(); expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument(); expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument(); - expect(await screen.findAllByText("05월")).not.toHaveLength(0); }); it("shows the latest integrity summary at the bottom for super admins only", async () => { @@ -253,7 +255,7 @@ describe("admin overview and auth guard pages", () => { it("moves the permission checker to the auth guard page and removes mock guardrails", () => { renderWithProviders(); - expect(screen.getByText("인증가드")).toBeInTheDocument(); + expect(screen.getByText("인증 가드")).toBeInTheDocument(); expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument(); expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument(); expect( diff --git a/adminfront/src/features/projections/UserProjectionPage.test.tsx b/adminfront/src/features/projections/UserProjectionPage.test.tsx index 8b5c9d7f..aefd0d37 100644 --- a/adminfront/src/features/projections/UserProjectionPage.test.tsx +++ b/adminfront/src/features/projections/UserProjectionPage.test.tsx @@ -6,8 +6,11 @@ import { reconcileUserProjection, resetUserProjection, } from "../../lib/adminApi"; +import { createI18nMock } from "../../test/i18nMock"; import UserProjectionPage from "./UserProjectionPage"; +vi.mock("../../lib/i18n", () => createI18nMock()); + let currentRole = "super_admin"; vi.mock("../../lib/adminApi", () => ({ @@ -52,18 +55,19 @@ describe("UserProjectionPage", () => { currentRole = "super_admin"; vi.clearAllMocks(); vi.spyOn(window, "confirm").mockReturnValue(true); + window.localStorage.setItem("locale", "ko"); }); it("renders projection status for super_admin", async () => { renderPage(); expect( - await screen.findByText("사용자 Projection 관리"), + await screen.findByText("사용자 동기화 관리"), ).toBeInTheDocument(); expect( - await screen.findByText("Kratos users projection"), + await screen.findByText("Kratos 사용자 동기화"), ).toBeInTheDocument(); - expect(screen.getByText("ready")).toBeInTheDocument(); + expect(screen.getByText("준비됨")).toBeInTheDocument(); expect(screen.getByText("152")).toBeInTheDocument(); expect(fetchUserProjectionStatus).toHaveBeenCalled(); }); @@ -71,7 +75,7 @@ describe("UserProjectionPage", () => { it("runs reconcile and reset actions for super_admin", async () => { renderPage(); - await screen.findByText("사용자 Projection 관리"); + await screen.findByText("사용자 동기화 관리"); fireEvent.click(screen.getByRole("button", { name: /재동기화/ })); await waitFor(() => { @@ -92,8 +96,19 @@ describe("UserProjectionPage", () => { expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument(); expect( - screen.queryByText("사용자 Projection 관리"), + screen.queryByText("사용자 동기화 관리"), ).not.toBeInTheDocument(); expect(fetchUserProjectionStatus).not.toHaveBeenCalled(); }); + + it("renders localized labels in English", async () => { + window.localStorage.setItem("locale", "en"); + renderPage(); + + expect( + await screen.findByText("User Projection Management"), + ).toBeInTheDocument(); + expect(screen.getByText("Re-sync")).toBeInTheDocument(); + expect(await screen.findByText("ready")).toBeInTheDocument(); + }); }); diff --git a/adminfront/src/features/projections/UserProjectionPage.tsx b/adminfront/src/features/projections/UserProjectionPage.tsx index c6b4ed04..f76b41ba 100644 --- a/adminfront/src/features/projections/UserProjectionPage.tsx +++ b/adminfront/src/features/projections/UserProjectionPage.tsx @@ -8,6 +8,8 @@ import { reconcileUserProjection, resetUserProjection, } from "../../lib/adminApi"; +import { t } from "../../lib/i18n"; +import { getAdminDateLocale } from "../../lib/locale"; function formatDateTime(value?: string) { if (!value) { @@ -17,7 +19,7 @@ function formatDateTime(value?: string) { if (Number.isNaN(date.getTime())) { return value; } - return new Intl.DateTimeFormat("ko-KR", { + return new Intl.DateTimeFormat(getAdminDateLocale(), { dateStyle: "medium", timeStyle: "medium", }).format(date); @@ -31,12 +33,26 @@ function ProjectionStatusBadge({ status: string; }) { if (ready) { - return ready; + return ( + + {t("ui.admin.user_projection.status.ready", "ready")} + + ); } if (status === "failed") { - return failed; + return ( + + {t("ui.admin.user_projection.status.failed", "failed")} + + ); } - return {status || "not ready"}; + return ( + + {status + ? status + : t("ui.admin.user_projection.status.not_ready", "not ready")} + + ); } function UserProjectionContent() { @@ -64,7 +80,10 @@ function UserProjectionContent() { const handleReset = () => { const confirmed = window.confirm( - "사용자 projection을 Kratos 기준으로 다시 구축하시겠습니까?", + t( + "msg.admin.user_projection.reset_confirm", + "Rebuild user projection from the Kratos source of truth?", + ), ); if (confirmed) { resetMutation.mutate(); @@ -79,9 +98,11 @@ function UserProjectionContent() {
-

System

- 사용자 Projection 관리 + {t( + "ui.admin.user_projection.title", + "User Projection Management", + )}

@@ -92,7 +113,7 @@ function UserProjectionContent() { disabled={isWorking} > - 재동기화 + {t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
@@ -109,19 +133,30 @@ function UserProjectionContent() { {isError ? (
{(error as Error)?.message || - "projection 상태를 불러오지 못했습니다."} + t( + "msg.admin.user_projection.load_error", + "Failed to load projection status.", + )}
) : null} {actionResult ? (
- {actionResult.syncedUsers}명 기준으로 projection을 갱신했습니다. + {t( + "msg.admin.user_projection.action_success", + "Refreshed the projection for {{count}} users.", + { count: actionResult.syncedUsers }, + )}
) : null} {actionError ? (
- {(actionError as Error)?.message || "projection 작업에 실패했습니다."} + {(actionError as Error)?.message || + t( + "msg.admin.user_projection.action_error", + "Projection operation failed.", + )}
) : null} @@ -131,19 +166,31 @@ function UserProjectionContent() {
-

Kratos users projection

+

+ {t( + "ui.admin.user_projection.card.title", + "Kratos users projection", + )} +

- Backend DB 통계가 참조하는 사용자 read model 상태입니다. + {t( + "ui.admin.user_projection.card.description", + "Current user read model state referenced by backend DB statistics.", + )}

{isLoading ? ( -
불러오는 중
+
+ {t("ui.admin.user_projection.loading", "Loading")} +
) : (
-
상태
+
+ {t("ui.admin.user_projection.summary.status", "Status")} +
- Projection 사용자 + {t( + "ui.admin.user_projection.summary.projected_users", + "Projected users", + )}
{data?.projectedUsers ?? 0}
-
마지막 동기화
+
+ {t( + "ui.admin.user_projection.summary.last_synced", + "Last synced", + )} +
{formatDateTime(data?.lastSyncedAt)}
-
상태 갱신
+
+ {t( + "ui.admin.user_projection.summary.updated_at", + "Updated at", + )} +
{formatDateTime(data?.updatedAt)}
@@ -190,14 +250,22 @@ export default function UserProjectionPage() { -
-

접근 권한이 없습니다

-

- 이 화면은 super_admin 권한으로만 접근할 수 있습니다. -

-
-
+
+
+

+ {t( + "ui.admin.user_projection.forbidden.title", + "Access denied", + )} +

+

+ {t( + "msg.admin.user_projection.forbidden.description", + "This screen is only available to super_admin users.", + )} +

+
+
} > diff --git a/adminfront/src/lib/locale.ts b/adminfront/src/lib/locale.ts new file mode 100644 index 00000000..9aafee8c --- /dev/null +++ b/adminfront/src/lib/locale.ts @@ -0,0 +1,32 @@ +import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, type Locale } from "../../../common/core/i18n"; + +function isLocale(value: string): value is Locale { + return value === "ko" || value === "en"; +} + +export function getAdminLocale(): Locale { + if (typeof window === "undefined") { + return DEFAULT_LOCALE; + } + + const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY); + if (stored && isLocale(stored)) { + return stored; + } + + const pathLocale = window.location.pathname.split("/")[1]; + if (pathLocale && isLocale(pathLocale)) { + return pathLocale; + } + + const browserLang = window.navigator.language.toLowerCase(); + if (browserLang.startsWith("ko")) { + return "ko"; + } + + return DEFAULT_LOCALE; +} + +export function getAdminDateLocale() { + return getAdminLocale() === "ko" ? "ko-KR" : "en-US"; +} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 62b1e668..d883b317 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -162,6 +162,31 @@ success = "Check completed." [msg.admin.integrity.report] load_error = "Failed to load the integrity report." +[msg.admin.integrity.check.duplicate_tenant_slugs] +description = "Checks duplicate active tenant slugs using LOWER(TRIM(slug))." + +[msg.admin.integrity.check.orphan_tenant_parents] +description = "Checks whether tenants.parent_id points to a missing or soft-deleted tenant." + +[msg.admin.integrity.check.orphan_user_login_id_tenants] +description = "Checks whether user_login_ids.tenant_id points to a missing or soft-deleted tenant." + +[msg.admin.integrity.check.orphan_user_login_id_users] +description = "Checks whether user_login_ids.user_id points to a missing or soft-deleted user." + +[msg.admin.integrity.check.orphan_user_tenant_memberships] +description = "Checks whether users.tenant_id points to a missing or soft-deleted tenant." + +[msg.admin.user_projection] +action_error = "Projection operation failed." +action_success = "Refreshed the projection for {{count}} users." +forbidden_description = "This screen is only available to super_admin users." +load_error = "Failed to load projection status." +reset_confirm = "Rebuild user projection from the Kratos source of truth?" + +[msg.admin.user_projection.forbidden] +description = "This screen is only available to super_admin users." + [msg.admin.groups.prompt] user_id = "User Id" @@ -910,6 +935,21 @@ user = "User" tenant_integrity = "Tenant integrity" user_integrity = "User integrity" +[ui.admin.integrity.check.duplicate_tenant_slugs] +title = "Duplicate tenant slug" + +[ui.admin.integrity.check.orphan_tenant_parents] +title = "Orphan tenant parents" + +[ui.admin.integrity.check.orphan_user_login_id_tenants] +title = "Orphan user login ID tenants" + +[ui.admin.integrity.check.orphan_user_login_id_users] +title = "Orphan user login ID users" + +[ui.admin.integrity.check.orphan_user_tenant_memberships] +title = "Orphan user tenant memberships" + [ui.admin.nav] org_chart = "Org Chart" api_keys = "API Keys" @@ -925,6 +965,60 @@ tenants = "Tenants" user_projection = "User Projection" users = "Users" +[ui.admin.user_projection] +loading = "Loading user projection data..." +title = "User Projection Management" + +[ui.admin.user_projection.actions] +reconcile = "Re-sync" +reset = "Reset and rebuild" + +[ui.admin.user_projection.card] +description = "Current user read model state referenced by backend DB statistics." +title = "Kratos users projection" + +[ui.admin.user_projection.forbidden] +title = "Access denied" + +[ui.admin.user_projection.status] +failed = "failed" +not_ready = "not ready" +ready = "ready" + +[ui.admin.user_projection.summary] +last_synced = "Last synced" +projected_users = "Projected users" +status = "Status" +updated_at = "Updated at" + +[ui.admin.auth_guard] +subtitle = "Verify admin privileges and ReBAC relationships against the policy engine." +title = "Auth Guard" + +[ui.admin.auth_guard.checker] +check = "Check permission" +checking = "Checking..." +denied = "Access DENIED" +denied_description = "The subject does not have access to the requested resource." +description = "Check in real time whether a subject has access to a resource through Ory Keto." +object_id = "Object ID" +object_id_placeholder = "Tenant UUID, etc." +allowed = "Access ALLOWED" +allowed_description = "The subject has access to the requested resource, including inherited permissions." +namespace = "Namespace" +relation = "Relation" +relation_placeholder = "view, manage, admins..." +subject = "Subject (User:ID)" +subject_placeholder = "User:uuid or Namespace:ID#Relation" +title = "ReBAC permission checker" + +[ui.admin.auth_guard.checker.namespace] +label = "Namespace" +relying_party = "RelyingParty" +system = "System" +tenant = "Tenant" +tenant_group = "TenantGroup" + [ui.admin.org] download_template = "Download Template" import_btn = "Org/User Import" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 12882b97..bcb0f649 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -162,6 +162,31 @@ success = "검사가 완료되었습니다." [msg.admin.integrity.report] load_error = "정합성 리포트를 불러오지 못했습니다." +[msg.admin.integrity.check.duplicate_tenant_slugs] +description = "삭제되지 않은 tenant의 LOWER(TRIM(slug)) 기준 중복을 검사합니다." + +[msg.admin.integrity.check.orphan_tenant_parents] +description = "tenants.parent_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다." + +[msg.admin.integrity.check.orphan_user_login_id_tenants] +description = "user_login_ids.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다." + +[msg.admin.integrity.check.orphan_user_login_id_users] +description = "user_login_ids.user_id가 존재하지 않거나 soft-deleted user를 참조하는지 검사합니다." + +[msg.admin.integrity.check.orphan_user_tenant_memberships] +description = "users.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다." + +[msg.admin.user_projection] +action_error = "사용자 동기화 작업에 실패했습니다." +action_success = "{{count}}명 기준으로 사용자 동기화를 갱신했습니다." +forbidden_description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다." +load_error = "사용자 동기화 상태를 불러오지 못했습니다." +reset_confirm = "사용자 동기화를 Kratos 기준으로 다시 구축하시겠습니까?" + +[msg.admin.user_projection.forbidden] +description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다." + [msg.admin.groups.prompt] user_id = "추가할 사용자의 UUID를 입력하세요:" @@ -912,6 +937,21 @@ user = "사용자" tenant_integrity = "테넌트 정합성" user_integrity = "사용자 정합성" +[ui.admin.integrity.check.duplicate_tenant_slugs] +title = "중복 테넌트 slug" + +[ui.admin.integrity.check.orphan_tenant_parents] +title = "고아 테넌트 부모" + +[ui.admin.integrity.check.orphan_user_login_id_tenants] +title = "고아 로그인 ID 테넌트" + +[ui.admin.integrity.check.orphan_user_login_id_users] +title = "고아 로그인 ID 사용자" + +[ui.admin.integrity.check.orphan_user_tenant_memberships] +title = "고아 사용자 테넌트 소속" + [ui.admin.nav] org_chart = "조직도" api_keys = "API 키" @@ -924,9 +964,63 @@ relying_parties = "애플리케이션(RP)" tenant_dashboard = "테넌트 대시보드" user_groups = "유저 그룹" tenants = "테넌트" -user_projection = "사용자 Projection" +user_projection = "사용자 동기화" users = "사용자" +[ui.admin.user_projection] +loading = "불러오는 중" +title = "사용자 동기화 관리" + +[ui.admin.user_projection.actions] +reconcile = "재동기화" +reset = "초기화 후 재구축" + +[ui.admin.user_projection.card] +description = "Backend DB 통계가 참조하는 사용자 read model 상태입니다." +title = "Kratos 사용자 동기화" + +[ui.admin.user_projection.forbidden] +title = "접근 권한이 없습니다" + +[ui.admin.user_projection.status] +failed = "실패" +not_ready = "준비되지 않음" +ready = "준비됨" + +[ui.admin.user_projection.summary] +last_synced = "마지막 동기화" +projected_users = "동기화 사용자" +status = "상태" +updated_at = "상태 갱신" + +[ui.admin.auth_guard] +subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다." +title = "인증 가드" + +[ui.admin.auth_guard.checker] +check = "권한 확인 실행" +checking = "검증 중..." +denied = "접근 거부" +denied_description = "해당 사용자는 요청한 리소스에 대해 권한이 없습니다." +description = "특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory Keto를 통해 실시간으로 확인합니다." +object_id = "대상 ID" +object_id_placeholder = "Tenant UUID 등" +allowed = "접근 허용" +allowed_description = "해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 포함)" +namespace = "네임스페이스" +relation = "관계" +relation_placeholder = "view, manage, admins..." +subject = "주체 (User:ID)" +subject_placeholder = "User:uuid 또는 Namespace:ID#Relation" +title = "ReBAC 권한 검증 도구" + +[ui.admin.auth_guard.checker.namespace] +label = "네임스페이스" +relying_party = "애플리케이션(RP)" +system = "시스템" +tenant = "테넌트" +tenant_group = "테넌트 그룹" + [ui.admin.org] download_template = "템플릿 다운로드" import_btn = "조직/사용자 통합 임포트" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 63d010f1..c81129c5 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -167,6 +167,31 @@ success = "" [msg.admin.integrity.report] load_error = "" +[msg.admin.integrity.check.duplicate_tenant_slugs] +description = "" + +[msg.admin.integrity.check.orphan_tenant_parents] +description = "" + +[msg.admin.integrity.check.orphan_user_login_id_tenants] +description = "" + +[msg.admin.integrity.check.orphan_user_login_id_users] +description = "" + +[msg.admin.integrity.check.orphan_user_tenant_memberships] +description = "" + +[msg.admin.user_projection] +action_error = "" +action_success = "" +forbidden_description = "" +load_error = "" +reset_confirm = "" + +[msg.admin.user_projection.forbidden] +description = "" + [msg.admin.groups.prompt] user_id = "" @@ -925,6 +950,21 @@ user = "" tenant_integrity = "" user_integrity = "" +[ui.admin.integrity.check.duplicate_tenant_slugs] +title = "" + +[ui.admin.integrity.check.orphan_tenant_parents] +title = "" + +[ui.admin.integrity.check.orphan_user_login_id_tenants] +title = "" + +[ui.admin.integrity.check.orphan_user_login_id_users] +title = "" + +[ui.admin.integrity.check.orphan_user_tenant_memberships] +title = "" + [ui.admin.nav] org_chart = "" api_keys = "" @@ -940,6 +980,60 @@ tenants = "" user_projection = "" users = "" +[ui.admin.user_projection] +loading = "" +title = "" + +[ui.admin.user_projection.actions] +reconcile = "" +reset = "" + +[ui.admin.user_projection.card] +description = "" +title = "" + +[ui.admin.user_projection.forbidden] +title = "" + +[ui.admin.user_projection.status] +failed = "" +not_ready = "" +ready = "" + +[ui.admin.user_projection.summary] +last_synced = "" +projected_users = "" +status = "" +updated_at = "" + +[ui.admin.auth_guard] +subtitle = "" +title = "" + +[ui.admin.auth_guard.checker] +check = "" +checking = "" +denied = "" +denied_description = "" +description = "" +object_id = "" +object_id_placeholder = "" +allowed = "" +allowed_description = "" +namespace = "" +relation = "" +relation_placeholder = "" +subject = "" +subject_placeholder = "" +title = "" + +[ui.admin.auth_guard.checker.namespace] +label = "" +relying_party = "" +system = "" +tenant = "" +tenant_group = "" + [ui.admin.org] download_template = "" import_btn = "" diff --git a/adminfront/src/test/i18nMock.ts b/adminfront/src/test/i18nMock.ts new file mode 100644 index 00000000..34ce2608 --- /dev/null +++ b/adminfront/src/test/i18nMock.ts @@ -0,0 +1,155 @@ +type Vars = Record; + +const translations: Record<"ko" | "en", Record> = { + ko: { + "ui.admin.auth_guard.title": "인증 가드", + "ui.admin.auth_guard.subtitle": + "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다.", + "ui.admin.auth_guard.checker.title": "ReBAC 권한 검증 도구", + "ui.admin.auth_guard.checker.description": + "특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory Keto를 통해 실시간으로 확인합니다.", + "ui.admin.auth_guard.checker.namespace.label": "네임스페이스", + "ui.admin.auth_guard.checker.namespace.tenant": "테넌트", + "ui.admin.auth_guard.checker.namespace.tenant_group": "테넌트 그룹", + "ui.admin.auth_guard.checker.namespace.relying_party": "애플리케이션(RP)", + "ui.admin.auth_guard.checker.namespace.system": "시스템", + "ui.admin.auth_guard.checker.relation": "관계", + "ui.admin.auth_guard.checker.object_id": "대상 ID", + "ui.admin.auth_guard.checker.subject": "주체 (User:ID)", + "ui.admin.auth_guard.checker.check": "권한 확인 실행", + "ui.admin.auth_guard.checker.checking": "검증 중...", + "ui.admin.auth_guard.checker.allowed": "접근 허용", + "ui.admin.auth_guard.checker.allowed_description": + "해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 포함)", + "ui.admin.auth_guard.checker.denied": "접근 거부", + "ui.admin.auth_guard.checker.denied_description": + "해당 사용자는 요청한 리소스에 대해 권한이 없습니다.", + "ui.admin.integrity.check.duplicate_tenant_slugs.title": "중복 테넌트 slug", + "ui.admin.integrity.section.tenant_integrity": "테넌트 정합성", + "ui.admin.integrity.section.user_integrity": "사용자 정합성", + "ui.admin.integrity.title": "데이터 정합성 검증", + "ui.admin.integrity.recheck.run": "다시 검사", + "ui.admin.integrity.recheck.running": "검사 중", + "ui.admin.integrity.status.fail": "실패", + "ui.admin.integrity.status.pass": "정상", + "ui.admin.integrity.status.warning": "주의", + "ui.admin.integrity.orphan_login_ids.title": "유령 로그인 ID 정리", + "ui.admin.integrity.forbidden.title": "접근 권한이 없습니다", + "ui.admin.integrity.summary.title": "정합성 최종 검증", + "ui.admin.user_projection.actions.reconcile": "재동기화", + "ui.admin.user_projection.actions.reset": "초기화 후 재구축", + "ui.admin.user_projection.card.description": + "Backend DB 통계가 참조하는 사용자 read model 상태입니다.", + "ui.admin.user_projection.card.title": "Kratos 사용자 동기화", + "ui.admin.user_projection.forbidden.title": "접근 권한이 없습니다", + "ui.admin.user_projection.loading": "불러오는 중", + "ui.admin.user_projection.status.failed": "실패", + "ui.admin.user_projection.status.not_ready": "준비되지 않음", + "ui.admin.user_projection.status.ready": "준비됨", + "ui.admin.user_projection.summary.last_synced": "마지막 동기화", + "ui.admin.user_projection.summary.projected_users": "동기화 사용자", + "ui.admin.user_projection.summary.status": "상태", + "ui.admin.user_projection.summary.updated_at": "상태 갱신", + "ui.admin.user_projection.title": "사용자 동기화 관리", + "msg.admin.integrity.check.duplicate_tenant_slugs.description": + "삭제되지 않은 tenant의 LOWER(TRIM(slug)) 기준 중복을 검사합니다.", + "msg.admin.integrity.check.orphan_tenant_parents.description": + "tenants.parent_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.", + "msg.admin.integrity.check.orphan_user_login_id_tenants.description": + "user_login_ids.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.", + "msg.admin.integrity.check.orphan_user_login_id_users.description": + "user_login_ids.user_id가 존재하지 않거나 soft-deleted user를 참조하는지 검사합니다.", + "msg.admin.integrity.check.orphan_user_tenant_memberships.description": + "users.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.", + "msg.admin.integrity.recheck.running": + "정합성 검사를 실행 중입니다.", + "msg.admin.integrity.recheck.success": "검사가 완료되었습니다.", + "msg.admin.user_projection.forbidden.description": + "이 화면은 super_admin 권한으로만 접근할 수 있습니다.", + }, + en: { + "ui.admin.auth_guard.title": "Auth Guard", + "ui.admin.auth_guard.subtitle": + "Verify admin privileges and ReBAC relationships against the policy engine.", + "ui.admin.auth_guard.checker.title": "ReBAC permission checker", + "ui.admin.auth_guard.checker.description": + "Check in real time whether a subject has access to a resource through Ory Keto.", + "ui.admin.auth_guard.checker.namespace.label": "Namespace", + "ui.admin.auth_guard.checker.namespace.tenant": "Tenant", + "ui.admin.auth_guard.checker.namespace.tenant_group": "TenantGroup", + "ui.admin.auth_guard.checker.namespace.relying_party": "RelyingParty", + "ui.admin.auth_guard.checker.namespace.system": "System", + "ui.admin.auth_guard.checker.relation": "Relation", + "ui.admin.auth_guard.checker.object_id": "Object ID", + "ui.admin.auth_guard.checker.subject": "Subject (User:ID)", + "ui.admin.auth_guard.checker.check": "Check permission", + "ui.admin.auth_guard.checker.checking": "Checking...", + "ui.admin.auth_guard.checker.allowed": "Access ALLOWED", + "ui.admin.auth_guard.checker.allowed_description": + "The subject has access to the requested resource, including inherited permissions.", + "ui.admin.auth_guard.checker.denied": "Access DENIED", + "ui.admin.auth_guard.checker.denied_description": + "The subject does not have access to the requested resource.", + "ui.admin.integrity.check.duplicate_tenant_slugs.title": "Duplicate tenant slug", + "ui.admin.integrity.section.tenant_integrity": "Tenant integrity", + "ui.admin.integrity.section.user_integrity": "User integrity", + "ui.admin.integrity.title": "Data Integrity Check", + "ui.admin.integrity.recheck.run": "Run again", + "ui.admin.integrity.recheck.running": "Checking", + "ui.admin.integrity.status.fail": "Failed", + "ui.admin.integrity.status.pass": "Passed", + "ui.admin.integrity.status.warning": "Warning", + "ui.admin.integrity.orphan_login_ids.title": "Orphan Login ID Cleanup", + "ui.admin.integrity.forbidden.title": "Access denied", + "ui.admin.integrity.summary.title": "Final integrity check", + "ui.admin.user_projection.actions.reconcile": "Re-sync", + "ui.admin.user_projection.actions.reset": "Reset and rebuild", + "ui.admin.user_projection.card.description": + "Current user read model state referenced by backend DB statistics.", + "ui.admin.user_projection.card.title": "Kratos users projection", + "ui.admin.user_projection.forbidden.title": "Access denied", + "ui.admin.user_projection.loading": "Loading", + "ui.admin.user_projection.status.failed": "failed", + "ui.admin.user_projection.status.not_ready": "not ready", + "ui.admin.user_projection.status.ready": "ready", + "ui.admin.user_projection.summary.last_synced": "Last synced", + "ui.admin.user_projection.summary.projected_users": "Projected users", + "ui.admin.user_projection.summary.status": "Status", + "ui.admin.user_projection.summary.updated_at": "Updated at", + "ui.admin.user_projection.title": "User Projection Management", + "msg.admin.integrity.check.duplicate_tenant_slugs.description": + "Checks duplicate active tenant slugs using LOWER(TRIM(slug)).", + "msg.admin.integrity.check.orphan_tenant_parents.description": + "Checks whether tenants.parent_id points to a missing or soft-deleted tenant.", + "msg.admin.integrity.check.orphan_user_login_id_tenants.description": + "Checks whether user_login_ids.tenant_id points to a missing or soft-deleted tenant.", + "msg.admin.integrity.check.orphan_user_login_id_users.description": + "Checks whether user_login_ids.user_id points to a missing or soft-deleted user.", + "msg.admin.integrity.check.orphan_user_tenant_memberships.description": + "Checks whether users.tenant_id points to a missing or soft-deleted tenant.", + "msg.admin.integrity.recheck.running": "Running integrity check.", + "msg.admin.integrity.recheck.success": "Check completed.", + "msg.admin.user_projection.forbidden.description": + "This screen is only available to super_admin users.", + }, +}; + +function format(template: string, vars?: Vars) { + if (!vars) { + return template; + } + return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => { + const value = vars[key]; + return value === undefined || value === null ? match : String(value); + }); +} + +export function createI18nMock() { + return { + t(key: string, fallback?: string, vars?: Vars) { + const locale = window.localStorage.getItem("locale") === "en" ? "en" : "ko"; + const template = translations[locale][key] ?? fallback ?? key; + return format(template, vars); + }, + }; +} diff --git a/locales/en.toml b/locales/en.toml index c3cc2776..0d2040b4 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -2663,6 +2663,31 @@ success = "Check completed." [msg.admin.integrity.report] load_error = "Failed to load the integrity report." +[msg.admin.integrity.check.duplicate_tenant_slugs] +description = "Checks duplicate active tenant slugs using LOWER(TRIM(slug))." + +[msg.admin.integrity.check.orphan_tenant_parents] +description = "Checks whether tenants.parent_id points to a missing or soft-deleted tenant." + +[msg.admin.integrity.check.orphan_user_login_id_tenants] +description = "Checks whether user_login_ids.tenant_id points to a missing or soft-deleted tenant." + +[msg.admin.integrity.check.orphan_user_login_id_users] +description = "Checks whether user_login_ids.user_id points to a missing or soft-deleted user." + +[msg.admin.integrity.check.orphan_user_tenant_memberships] +description = "Checks whether users.tenant_id points to a missing or soft-deleted tenant." + +[msg.admin.user_projection] +action_error = "Projection operation failed." +action_success = "Refreshed the projection for {{count}} users." +forbidden_description = "This screen is only available to super_admin users." +load_error = "Failed to load projection status." +reset_confirm = "Rebuild user projection from the Kratos source of truth?" + +[msg.admin.user_projection.forbidden] +description = "This screen is only available to super_admin users." + [ui.admin.integrity] fetch_error = "Unable to load the final integrity check result." kicker = "System" @@ -2715,6 +2740,21 @@ user = "User" tenant_integrity = "Tenant integrity" user_integrity = "User integrity" +[ui.admin.integrity.check.duplicate_tenant_slugs] +title = "Duplicate tenant slug" + +[ui.admin.integrity.check.orphan_tenant_parents] +title = "Orphan tenant parents" + +[ui.admin.integrity.check.orphan_user_login_id_tenants] +title = "Orphan user login ID tenants" + +[ui.admin.integrity.check.orphan_user_login_id_users] +title = "Orphan user login ID users" + +[ui.admin.integrity.check.orphan_user_tenant_memberships] +title = "Orphan user tenant memberships" + [msg.admin.api_keys.list] edit_scopes_desc = "Edit the scopes granted to this API key." rotate_confirm = "Rotate the secret for this API key?" @@ -2729,6 +2769,60 @@ rotate_secret = "Rotate secret" rotate_secret_done = "Secret rotated" save_scopes = "Save scopes" +[ui.admin.user_projection] +loading = "Loading user projection data..." +title = "User Projection Management" + +[ui.admin.user_projection.actions] +reconcile = "Re-sync" +reset = "Reset and rebuild" + +[ui.admin.user_projection.card] +description = "Current user read model state referenced by backend DB statistics." +title = "Kratos users projection" + +[ui.admin.user_projection.forbidden] +title = "Access denied" + +[ui.admin.user_projection.status] +failed = "failed" +not_ready = "not ready" +ready = "ready" + +[ui.admin.user_projection.summary] +last_synced = "Last synced" +projected_users = "Projected users" +status = "Status" +updated_at = "Updated at" + +[ui.admin.auth_guard] +subtitle = "Verify admin privileges and ReBAC relationships against the policy engine." +title = "Auth Guard" + +[ui.admin.auth_guard.checker] +check = "Check permission" +checking = "Checking..." +denied = "Access DENIED" +denied_description = "The subject does not have access to the requested resource." +description = "Check in real time whether a subject has access to a resource through Ory Keto." +object_id = "Object ID" +object_id_placeholder = "Tenant UUID, etc." +allowed = "Access ALLOWED" +allowed_description = "The subject has access to the requested resource, including inherited permissions." +namespace = "Namespace" +relation = "Relation" +relation_placeholder = "view, manage, admins..." +subject = "Subject (User:ID)" +subject_placeholder = "User:uuid or Namespace:ID#Relation" +title = "ReBAC permission checker" + +[ui.admin.auth_guard.checker.namespace] +label = "Namespace" +relying_party = "RelyingParty" +system = "System" +tenant = "Tenant" +tenant_group = "TenantGroup" + [ui.admin.overview.summary] total_users = "Total Users" diff --git a/locales/ko.toml b/locales/ko.toml index ba90aa78..d5e06066 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -3087,6 +3087,31 @@ success = "검사가 완료되었습니다." [msg.admin.integrity.report] load_error = "정합성 리포트를 불러오지 못했습니다." +[msg.admin.integrity.check.duplicate_tenant_slugs] +description = "삭제되지 않은 tenant의 LOWER(TRIM(slug)) 기준 중복을 검사합니다." + +[msg.admin.integrity.check.orphan_tenant_parents] +description = "tenants.parent_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다." + +[msg.admin.integrity.check.orphan_user_login_id_tenants] +description = "user_login_ids.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다." + +[msg.admin.integrity.check.orphan_user_login_id_users] +description = "user_login_ids.user_id가 존재하지 않거나 soft-deleted user를 참조하는지 검사합니다." + +[msg.admin.integrity.check.orphan_user_tenant_memberships] +description = "users.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다." + +[msg.admin.user_projection] +action_error = "사용자 동기화 작업에 실패했습니다." +action_success = "{{count}}명 기준으로 사용자 동기화를 갱신했습니다." +forbidden_description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다." +load_error = "사용자 동기화 상태를 불러오지 못했습니다." +reset_confirm = "사용자 동기화를 Kratos 기준으로 다시 구축하시겠습니까?" + +[msg.admin.user_projection.forbidden] +description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다." + [ui.admin.integrity] fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다." kicker = "시스템" @@ -3139,6 +3164,21 @@ user = "사용자" tenant_integrity = "테넌트 정합성" user_integrity = "사용자 정합성" +[ui.admin.integrity.check.duplicate_tenant_slugs] +title = "중복 테넌트 slug" + +[ui.admin.integrity.check.orphan_tenant_parents] +title = "고아 테넌트 부모" + +[ui.admin.integrity.check.orphan_user_login_id_tenants] +title = "고아 로그인 ID 테넌트" + +[ui.admin.integrity.check.orphan_user_login_id_users] +title = "고아 로그인 ID 사용자" + +[ui.admin.integrity.check.orphan_user_tenant_memberships] +title = "고아 사용자 테넌트 소속" + [msg.admin.api_keys.list] edit_scopes_desc = "API 키에 부여할 권한 범위를 수정합니다." rotate_confirm = "이 API 키의 Secret을 재발급할까요?" @@ -3153,6 +3193,60 @@ rotate_secret = "Secret 재발급" rotate_secret_done = "Secret 재발급 완료" save_scopes = "권한 저장" +[ui.admin.user_projection] +loading = "불러오는 중" +title = "사용자 동기화 관리" + +[ui.admin.user_projection.actions] +reconcile = "재동기화" +reset = "초기화 후 재구축" + +[ui.admin.user_projection.card] +description = "Backend DB 통계가 참조하는 사용자 read model 상태입니다." +title = "Kratos 사용자 동기화" + +[ui.admin.user_projection.forbidden] +title = "접근 권한이 없습니다" + +[ui.admin.user_projection.status] +failed = "실패" +not_ready = "준비되지 않음" +ready = "준비됨" + +[ui.admin.user_projection.summary] +last_synced = "마지막 동기화" +projected_users = "동기화 사용자" +status = "상태" +updated_at = "상태 갱신" + +[ui.admin.auth_guard] +subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다." +title = "인증 가드" + +[ui.admin.auth_guard.checker] +check = "권한 확인 실행" +checking = "검증 중..." +denied = "접근 거부" +denied_description = "해당 사용자는 요청한 리소스에 대해 권한이 없습니다." +description = "특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory Keto를 통해 실시간으로 확인합니다." +object_id = "대상 ID" +object_id_placeholder = "Tenant UUID 등" +allowed = "접근 허용" +allowed_description = "해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 포함)" +namespace = "네임스페이스" +relation = "관계" +relation_placeholder = "view, manage, admins..." +subject = "주체 (User:ID)" +subject_placeholder = "User:uuid 또는 Namespace:ID#Relation" +title = "ReBAC 권한 검증 도구" + +[ui.admin.auth_guard.checker.namespace] +label = "네임스페이스" +relying_party = "애플리케이션(RP)" +system = "시스템" +tenant = "테넌트" +tenant_group = "테넌트 그룹" + [ui.admin.overview.summary] total_users = "전체 사용자 수" diff --git a/locales/template.toml b/locales/template.toml index d19cba8b..3b5ab9cc 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -2967,6 +2967,31 @@ success = "" [msg.admin.integrity.report] load_error = "" +[msg.admin.integrity.check.duplicate_tenant_slugs] +description = "" + +[msg.admin.integrity.check.orphan_tenant_parents] +description = "" + +[msg.admin.integrity.check.orphan_user_login_id_tenants] +description = "" + +[msg.admin.integrity.check.orphan_user_login_id_users] +description = "" + +[msg.admin.integrity.check.orphan_user_tenant_memberships] +description = "" + +[msg.admin.user_projection] +action_error = "" +action_success = "" +forbidden_description = "" +load_error = "" +reset_confirm = "" + +[msg.admin.user_projection.forbidden] +description = "" + [ui.admin.integrity] fetch_error = "" kicker = "" @@ -3019,6 +3044,21 @@ user = "" tenant_integrity = "" user_integrity = "" +[ui.admin.integrity.check.duplicate_tenant_slugs] +title = "" + +[ui.admin.integrity.check.orphan_tenant_parents] +title = "" + +[ui.admin.integrity.check.orphan_user_login_id_tenants] +title = "" + +[ui.admin.integrity.check.orphan_user_login_id_users] +title = "" + +[ui.admin.integrity.check.orphan_user_tenant_memberships] +title = "" + [msg.admin.api_keys.list] edit_scopes_desc = "" rotate_confirm = "" @@ -3033,6 +3073,60 @@ rotate_secret = "" rotate_secret_done = "" save_scopes = "" +[ui.admin.user_projection] +loading = "" +title = "" + +[ui.admin.user_projection.actions] +reconcile = "" +reset = "" + +[ui.admin.user_projection.card] +description = "" +title = "" + +[ui.admin.user_projection.forbidden] +title = "" + +[ui.admin.user_projection.status] +failed = "" +not_ready = "" +ready = "" + +[ui.admin.user_projection.summary] +last_synced = "" +projected_users = "" +status = "" +updated_at = "" + +[ui.admin.auth_guard] +subtitle = "" +title = "" + +[ui.admin.auth_guard.checker] +check = "" +checking = "" +denied = "" +denied_description = "" +description = "" +object_id = "" +object_id_placeholder = "" +allowed = "" +allowed_description = "" +namespace = "" +relation = "" +relation_placeholder = "" +subject = "" +subject_placeholder = "" +title = "" + +[ui.admin.auth_guard.checker.namespace] +label = "" +relying_party = "" +system = "" +tenant = "" +tenant_group = "" + [ui.admin.overview.summary] total_users = "" From d1184613d844b4438febef5d64b43dd2223ab618 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 18 May 2026 13:32:50 +0900 Subject: [PATCH 09/25] =?UTF-8?q?=EC=84=9C=EB=B8=8C=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=ED=8B=80=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=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 --- .../features/integrity/DataIntegrityPage.test.tsx | 10 ++++++++++ .../src/features/integrity/DataIntegrityPage.tsx | 6 ++++++ .../projections/UserProjectionPage.test.tsx | 8 ++++++++ .../features/projections/UserProjectionPage.tsx | 6 ++++++ adminfront/src/locales/en.toml | 9 ++++++--- adminfront/src/locales/ko.toml | 4 ++++ adminfront/src/locales/template.toml | 4 ++++ adminfront/src/test/i18nMock.ts | 14 ++++++++++++++ locales/en.toml | 3 +++ locales/ko.toml | 4 ++++ locales/template.toml | 4 ++++ 11 files changed, 69 insertions(+), 3 deletions(-) diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx index e7da2193..f6c31bed 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -102,6 +102,11 @@ describe("DataIntegrityPage", () => { renderPage(); expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument(); + expect( + await screen.findByText( + "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.", + ), + ).toBeInTheDocument(); expect(await screen.findByText("테넌트 정합성")).toBeInTheDocument(); expect(screen.getByText("중복 테넌트 slug")).toBeInTheDocument(); expect(screen.getAllByText("1").length).toBeGreaterThan(0); @@ -173,6 +178,11 @@ describe("DataIntegrityPage", () => { expect( await screen.findByText("Data Integrity Check"), ).toBeInTheDocument(); + expect( + await screen.findByText( + "Review integrity status and inspect checks across the admin data model.", + ), + ).toBeInTheDocument(); expect(await screen.findByText("Tenant integrity")).toBeInTheDocument(); expect(await screen.findByText("Duplicate tenant slug")).toBeInTheDocument(); expect( diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx index abed94d5..b13e83eb 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -331,6 +331,12 @@ function DataIntegrityContent() {

{t("ui.admin.integrity.title", "데이터 정합성 검증")}

+

+ {t( + "msg.admin.integrity.subtitle", + "Review integrity status and inspect checks across the admin data model.", + )} +

+ ))} +
+ + ))} - {orphanLoginIDsQuery.isError ? ( -
- {t( - "msg.admin.integrity.orphan_login_ids.load_error", - "유령 로그인 ID 대상을 불러오지 못했습니다.", - )} + +
+
+
+

+ {t( + "ui.admin.integrity.orphan_login_ids.title", + "유령 로그인 ID 정리", + )} +

+

+ {t( + "msg.admin.integrity.orphan_login_ids.description", + "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.", + )} +

+
+
- ) : null} - {deleteMutation.data ? ( -
- {t( - "msg.admin.integrity.orphan_login_ids.delete_success", - "{{count}}개의 유령 로그인 ID를 삭제했습니다.", - { count: deleteMutation.data.deletedCount }, - )} -
- ) : null} - -
+ {orphanLoginIDsQuery.isError ? ( +
+ {t( + "msg.admin.integrity.orphan_login_ids.load_error", + "유령 로그인 ID 대상을 불러오지 못했습니다.", + )} +
+ ) : null} + {deleteMutation.data ? ( +
+ {t( + "msg.admin.integrity.orphan_login_ids.delete_success", + "{{count}}개의 유령 로그인 ID를 삭제했습니다.", + { count: deleteMutation.data.deletedCount }, + )} +
+ ) : null} + + +
); } diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 6cf0b64f..59c33efc 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -194,7 +194,7 @@ function IntegrityOverviewSummary() { return (
-
+
{data.status === "pass" ? ( @@ -287,7 +287,7 @@ function RPUsageMixedChart({ return (
-
+

{t("ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황")} @@ -507,9 +507,9 @@ function GlobalOverviewPage() { return (
-
+
-

+

{t("ui.common.overview.title", "운영 현황")}

@@ -562,7 +562,7 @@ function GlobalOverviewPage() { {usageQuery.isError ? (

-
+

{t( diff --git a/adminfront/src/features/projections/UserProjectionPage.tsx b/adminfront/src/features/projections/UserProjectionPage.tsx index 763dd29b..2c8efa7e 100644 --- a/adminfront/src/features/projections/UserProjectionPage.tsx +++ b/adminfront/src/features/projections/UserProjectionPage.tsx @@ -95,16 +95,16 @@ function UserProjectionContent() { const actionError = reconcileMutation.error ?? resetMutation.error; return ( -
-
-
-

+
+
+
+

{t( "ui.admin.user_projection.title", "User Projection Management", )}

-

+

{t( "msg.admin.user_projection.subtitle", "Review and sync the Kratos user read model.", @@ -134,7 +134,7 @@ function UserProjectionContent() { )}

-

+ {isError ? (
From a1f3604b24be4ff5cc8d9101a77bd5193fdefa91 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 18 May 2026 15:05:24 +0900 Subject: [PATCH 12/25] =?UTF-8?q?adminfront=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=ED=83=80=EC=9D=B4=ED=8B=80=20=ED=81=AC=EA=B8=B0=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=20(text-lg)=20=EB=B0=8F=20=ED=95=9C=EA=B5=AD=EC=96=B4?= =?UTF-8?q?=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/api-keys/ApiKeyListPage.tsx | 2 +- adminfront/src/features/audit/AuditLogsPage.tsx | 2 +- .../features/auth/components/PermissionChecker.tsx | 6 +++--- .../src/features/integrity/DataIntegrityPage.tsx | 6 +++--- .../src/features/overview/GlobalOverviewPage.tsx | 11 +++++++---- .../src/features/projections/UserProjectionPage.tsx | 2 +- .../src/features/tenants/routes/TenantListPage.tsx | 4 ++-- adminfront/src/features/users/UserListPage.tsx | 2 +- adminfront/src/locales/ko.toml | 6 +++--- 9 files changed, 22 insertions(+), 19 deletions(-) diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.tsx index 95931ade..c64b3fe6 100644 --- a/adminfront/src/features/api-keys/ApiKeyListPage.tsx +++ b/adminfront/src/features/api-keys/ApiKeyListPage.tsx @@ -193,7 +193,7 @@ function ApiKeyListPage() {
- + {t("ui.admin.apikeys.registry.title", "API Key Registry")} diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index 86e7eb84..71b83490 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -125,7 +125,7 @@ function AuditLogsPage() {
- + {t("ui.common.audit.registry.title", "Audit registry")}
diff --git a/adminfront/src/features/auth/components/PermissionChecker.tsx b/adminfront/src/features/auth/components/PermissionChecker.tsx index e286788b..2be914f1 100644 --- a/adminfront/src/features/auth/components/PermissionChecker.tsx +++ b/adminfront/src/features/auth/components/PermissionChecker.tsx @@ -47,7 +47,7 @@ function PermissionChecker() { return ( - + {t( "ui.admin.auth_guard.checker.title", "ReBAC permission checker", @@ -154,7 +154,7 @@ function PermissionChecker() { {result.allowed ? ( <> -
+
{t( "ui.admin.auth_guard.checker.allowed", "Access ALLOWED", @@ -170,7 +170,7 @@ function PermissionChecker() { ) : ( <> -
+
{t( "ui.admin.auth_guard.checker.denied", "Access DENIED", diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx index 5b295a50..bd8988df 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -375,7 +375,7 @@ function DataIntegrityContent() {
-

+

{t( "ui.admin.integrity.read_model.title", "Read model integrity", @@ -444,7 +444,7 @@ function DataIntegrityContent() { className="rounded-lg border border-border bg-card p-5" >
-

+

{integritySectionLabel(section.key, section.label)}

@@ -489,7 +489,7 @@ function DataIntegrityContent() {
-

+

{t( "ui.admin.integrity.orphan_login_ids.title", "유령 로그인 ID 정리", diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 59c33efc..dc04f151 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -201,8 +201,11 @@ function IntegrityOverviewSummary() { ) : ( )} -

- {t("ui.admin.integrity.summary.title", "정합성 최종 검증")} +

+ {t( + "ui.admin.integrity.summary.title", + "정합성 최종 검증", + )}

@@ -289,7 +292,7 @@ function RPUsageMixedChart({
-

+

{t("ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황")}

@@ -564,7 +567,7 @@ function GlobalOverviewPage() {

-

+

{t( "ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황", diff --git a/adminfront/src/features/projections/UserProjectionPage.tsx b/adminfront/src/features/projections/UserProjectionPage.tsx index 2c8efa7e..61666d9c 100644 --- a/adminfront/src/features/projections/UserProjectionPage.tsx +++ b/adminfront/src/features/projections/UserProjectionPage.tsx @@ -169,7 +169,7 @@ function UserProjectionContent() {
-

+

{t( "ui.admin.user_projection.card.title", "Kratos users projection", diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index e8c28d2e..1258f026 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -459,7 +459,7 @@ function TenantListPage() { ) { return (
-

+

{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}

- -
- - {scopeTenantId && ( - - )} - - - - - - - - - - - {t("ui.admin.tenants.csv_template", "템플릿 다운로드")} - - - fileInputRef.current?.click()} - disabled={importMutation.isPending} - data-testid="tenant-import-menu-item" - className="cursor-pointer" - > - - {t("ui.admin.tenants.import", "CSV 가져오기")} - - - exportMutation.mutate(false)} - disabled={exportMutation.isPending} - data-testid="tenant-export-menu-item" - className="cursor-pointer" - > - - {t( - "ui.admin.tenants.export_without_ids", - "UUID 제외 내보내기", - )} - - exportMutation.mutate(true)} - disabled={exportMutation.isPending} - data-testid="tenant-export-with-ids-menu-item" - className="cursor-pointer" - > - - {t("ui.admin.tenants.export_with_ids", "UUID 포함 내보내기")} - - - - - - - - - -

- {importMessage && ( -
- {importMessage} -
+ } + title={t("ui.admin.tenants.title", "테넌트 목록")} + description={t( + "msg.admin.tenants.subtitle", + "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.", )} - + actions={ + <> +
+
+ + setSearch(e.target.value)} + /> +
+ + + + + + + + + + + {t("ui.admin.tenants.csv_template", "템플릿 다운로드")} + + + fileInputRef.current?.click()} + disabled={importMutation.isPending} + data-testid="tenant-import-menu-item" + className="cursor-pointer" + > + + {t("ui.admin.tenants.import", "CSV 가져오기")} + + + exportMutation.mutate(false)} + disabled={exportMutation.isPending} + data-testid="tenant-export-menu-item" + className="cursor-pointer" + > + + {t( + "ui.admin.tenants.export_without_ids", + "UUID 제외 내보내기", + )} + + exportMutation.mutate(true)} + disabled={exportMutation.isPending} + data-testid="tenant-export-with-ids-menu-item" + className="cursor-pointer" + > + + {t( + "ui.admin.tenants.export_with_ids", + "UUID 포함 내보내기", + )} + + + + + + + + + +
+ {importMessage ? ( +
+ {importMessage} +
+ ) : null} + + } + /> diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 3217303a..fb4f5cb8 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -7,8 +7,8 @@ import { ChevronDown, ChevronLeft, ChevronRight, + Users, FileDown, - LayoutDashboard, Plus, RefreshCw, Search, @@ -416,6 +416,7 @@ function UserListPage() { } title={ {t("ui.admin.users.list.title", "사용자 관리")} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 7e34649c..1c12bd43 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -114,6 +114,7 @@ empty = "Empty" [msg.admin.audit.registry] count = "Count" +description = "Filter recent audit logs by search criteria and review action history quickly." [msg.admin.groups] @@ -937,6 +938,12 @@ user = "User" tenant_integrity = "Tenant integrity" user_integrity = "User integrity" +[msg.admin.integrity.section.tenant_integrity] +description = "Checks duplicate tenant slugs and orphan parent relationships." + +[msg.admin.integrity.section.user_integrity] +description = "Checks orphan records for users and login ID references." + [ui.admin.integrity.check.duplicate_tenant_slugs] title = "Duplicate tenant slug" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index bc4b623b..0b2d4aa0 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -114,6 +114,7 @@ empty = "필터 없음" [msg.admin.audit.registry] count = "로드된 로그 {{count}}건" +description = "최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다." [msg.admin.groups] @@ -941,6 +942,12 @@ user = "사용자" tenant_integrity = "테넌트 정합성" user_integrity = "사용자 정합성" +[msg.admin.integrity.section.tenant_integrity] +description = "테넌트 slug 중복과 부모 관계 이상을 확인합니다." + +[msg.admin.integrity.section.user_integrity] +description = "사용자와 로그인 ID 참조의 고아 레코드를 확인합니다." + [ui.admin.integrity.check.duplicate_tenant_slugs] title = "중복 테넌트 slug" diff --git a/adminfront/src/test/i18nMock.ts b/adminfront/src/test/i18nMock.ts index b15c6f2e..7c15c3cd 100644 --- a/adminfront/src/test/i18nMock.ts +++ b/adminfront/src/test/i18nMock.ts @@ -30,6 +30,12 @@ const translations: Record<"ko" | "en", Record> = { "ui.admin.integrity.title": "데이터 정합성 검증", "msg.admin.integrity.subtitle": "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.", + "msg.admin.integrity.section.tenant_integrity.description": + "테넌트 slug 중복과 부모 관계 이상을 확인합니다.", + "msg.admin.integrity.section.user_integrity.description": + "사용자와 로그인 ID 참조의 고아 레코드를 확인합니다.", + "msg.admin.audit.registry.description": + "최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.", "ui.admin.integrity.recheck.run": "다시 검사", "ui.admin.integrity.recheck.running": "검사 중", "ui.admin.integrity.status.fail": "실패", @@ -103,6 +109,12 @@ const translations: Record<"ko" | "en", Record> = { "ui.admin.integrity.title": "Data Integrity Check", "msg.admin.integrity.subtitle": "Review integrity status and inspect checks across the admin data model.", + "msg.admin.integrity.section.tenant_integrity.description": + "Checks duplicate tenant slugs and orphan parent relationships.", + "msg.admin.integrity.section.user_integrity.description": + "Checks orphan records for users and login ID references.", + "msg.admin.audit.registry.description": + "Filter recent audit logs by search criteria and review action history quickly.", "ui.admin.integrity.recheck.run": "Run again", "ui.admin.integrity.recheck.running": "Checking", "ui.admin.integrity.status.fail": "Failed", diff --git a/common/core/components/page/PageHeader.tsx b/common/core/components/page/PageHeader.tsx index 537f76f7..c574079a 100644 --- a/common/core/components/page/PageHeader.tsx +++ b/common/core/components/page/PageHeader.tsx @@ -6,6 +6,7 @@ function cx(...classNames: Array) { type PageHeaderProps = Omit, "title"> & { actions?: ReactNode; + icon?: ReactNode; as?: ElementType; description?: ReactNode; eyebrow?: ReactNode; @@ -20,6 +21,7 @@ export function PageHeader({ className, description, eyebrow, + icon, sticky = false, title, titleAs, @@ -38,18 +40,25 @@ export function PageHeader({ )} {...props} > -
- {eyebrow ? ( -

- {eyebrow} -

- ) : null} - - {title} - - {description ? ( -

{description}

+
+ {icon ? ( +
+ {icon} +
) : null} +
+ {eyebrow ? ( +

+ {eyebrow} +

+ ) : null} + + {title} + + {description ? ( +

{description}

+ ) : null} +
{actions ? (
{actions}
From 528ceea75470c5fcf350d4b8f5585201b73c5717 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 19 May 2026 17:28:38 +0900 Subject: [PATCH 17/25] =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=84=A4=EC=B9=98=20=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/scripts/runtime-mode.sh | 35 +++++++++++++++++++++++++++--- devfront/scripts/runtime-mode.sh | 35 +++++++++++++++++++++++++++--- orgfront/scripts/runtime-mode.sh | 35 +++++++++++++++++++++++++++--- 3 files changed, 96 insertions(+), 9 deletions(-) diff --git a/adminfront/scripts/runtime-mode.sh b/adminfront/scripts/runtime-mode.sh index fd466e85..096161ae 100644 --- a/adminfront/scripts/runtime-mode.sh +++ b/adminfront/scripts/runtime-mode.sh @@ -51,19 +51,37 @@ ensure_frontend_dependencies() { return 0 fi - acquire_install_lock() { - lock_file="$WORKSPACE_DIR/.baron-deps-install.lock" + lock_mode="" + lock_file="$WORKSPACE_DIR/.baron-deps-install.lock" + acquire_install_lock() { if command -v flock >/dev/null 2>&1; then + lock_mode="flock" exec 9>"$lock_file" flock 9 + trap 'release_install_lock' EXIT INT TERM return 0 fi + lock_mode="mkdir" while ! mkdir "$lock_file" 2>/dev/null; do sleep 1 done - trap 'rmdir "$lock_file" >/dev/null 2>&1 || true' EXIT INT TERM + trap 'release_install_lock' EXIT INT TERM + } + + release_install_lock() { + trap - EXIT INT TERM + + if [ "$lock_mode" = "flock" ]; then + flock -u 9 || true + exec 9>&- + return 0 + fi + + if [ "$lock_mode" = "mkdir" ]; then + rmdir "$lock_file" >/dev/null 2>&1 || true + fi } if command -v sha256sum >/dev/null 2>&1; then @@ -77,6 +95,16 @@ ensure_frontend_dependencies() { if [ "$installed_hash" != "$deps_hash" ]; then echo "Installing frontend dependencies..." acquire_install_lock + if command -v sha256sum >/dev/null 2>&1; then + deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | sha256sum | awk '{print $1}')" + else + deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | cksum | awk '{print $1}')" + fi + installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)" + if [ "$installed_hash" = "$deps_hash" ]; then + release_install_lock + return 0 + fi if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then (cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts) else @@ -84,6 +112,7 @@ ensure_frontend_dependencies() { fi mkdir -p node_modules printf '%s\n' "$deps_hash" > "$deps_stamp" + release_install_lock fi } diff --git a/devfront/scripts/runtime-mode.sh b/devfront/scripts/runtime-mode.sh index 1582472c..8b69817a 100644 --- a/devfront/scripts/runtime-mode.sh +++ b/devfront/scripts/runtime-mode.sh @@ -51,19 +51,37 @@ ensure_frontend_dependencies() { return 0 fi - acquire_install_lock() { - lock_file="$WORKSPACE_DIR/.baron-deps-install.lock" + lock_mode="" + lock_file="$WORKSPACE_DIR/.baron-deps-install.lock" + acquire_install_lock() { if command -v flock >/dev/null 2>&1; then + lock_mode="flock" exec 9>"$lock_file" flock 9 + trap 'release_install_lock' EXIT INT TERM return 0 fi + lock_mode="mkdir" while ! mkdir "$lock_file" 2>/dev/null; do sleep 1 done - trap 'rmdir "$lock_file" >/dev/null 2>&1 || true' EXIT INT TERM + trap 'release_install_lock' EXIT INT TERM + } + + release_install_lock() { + trap - EXIT INT TERM + + if [ "$lock_mode" = "flock" ]; then + flock -u 9 || true + exec 9>&- + return 0 + fi + + if [ "$lock_mode" = "mkdir" ]; then + rmdir "$lock_file" >/dev/null 2>&1 || true + fi } if command -v sha256sum >/dev/null 2>&1; then @@ -77,6 +95,16 @@ ensure_frontend_dependencies() { if [ "$installed_hash" != "$deps_hash" ]; then echo "Installing frontend dependencies..." acquire_install_lock + if command -v sha256sum >/dev/null 2>&1; then + deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | sha256sum | awk '{print $1}')" + else + deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | cksum | awk '{print $1}')" + fi + installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)" + if [ "$installed_hash" = "$deps_hash" ]; then + release_install_lock + return 0 + fi if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then (cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts) else @@ -84,6 +112,7 @@ ensure_frontend_dependencies() { fi mkdir -p node_modules printf '%s\n' "$deps_hash" > "$deps_stamp" + release_install_lock fi } diff --git a/orgfront/scripts/runtime-mode.sh b/orgfront/scripts/runtime-mode.sh index 2407c4af..392972e4 100644 --- a/orgfront/scripts/runtime-mode.sh +++ b/orgfront/scripts/runtime-mode.sh @@ -51,19 +51,37 @@ ensure_frontend_dependencies() { return 0 fi - acquire_install_lock() { - lock_file="$WORKSPACE_DIR/.baron-deps-install.lock" + lock_mode="" + lock_file="$WORKSPACE_DIR/.baron-deps-install.lock" + acquire_install_lock() { if command -v flock >/dev/null 2>&1; then + lock_mode="flock" exec 9>"$lock_file" flock 9 + trap 'release_install_lock' EXIT INT TERM return 0 fi + lock_mode="mkdir" while ! mkdir "$lock_file" 2>/dev/null; do sleep 1 done - trap 'rmdir "$lock_file" >/dev/null 2>&1 || true' EXIT INT TERM + trap 'release_install_lock' EXIT INT TERM + } + + release_install_lock() { + trap - EXIT INT TERM + + if [ "$lock_mode" = "flock" ]; then + flock -u 9 || true + exec 9>&- + return 0 + fi + + if [ "$lock_mode" = "mkdir" ]; then + rmdir "$lock_file" >/dev/null 2>&1 || true + fi } if command -v sha256sum >/dev/null 2>&1; then @@ -77,6 +95,16 @@ ensure_frontend_dependencies() { if [ "$installed_hash" != "$deps_hash" ]; then echo "Installing frontend dependencies..." acquire_install_lock + if command -v sha256sum >/dev/null 2>&1; then + deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | sha256sum | awk '{print $1}')" + else + deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | cksum | awk '{print $1}')" + fi + installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)" + if [ "$installed_hash" = "$deps_hash" ]; then + release_install_lock + return 0 + fi if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then (cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts) else @@ -84,6 +112,7 @@ ensure_frontend_dependencies() { fi mkdir -p node_modules printf '%s\n' "$deps_hash" > "$deps_stamp" + release_install_lock fi } From c2dbc8fc88b3338f2fbb674afb537e3fd3e4caca Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 19 May 2026 17:28:54 +0900 Subject: [PATCH 18/25] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/core/components/page/PageHeader.tsx | 2 +- devfront/src/features/audit/AuditLogsPage.tsx | 10 +++- .../features/clients/ClientConsentsPage.tsx | 57 +++++++----------- .../features/clients/ClientDetailsPage.tsx | 53 +++++++---------- .../features/clients/ClientGeneralPage.tsx | 35 +++++------ .../features/clients/ClientRelationsPage.tsx | 32 ++++------ devfront/src/features/clients/ClientsPage.tsx | 3 +- .../clients/routes/ClientFederationPage.tsx | 35 +++++------ .../DeveloperRequestPage.tsx | 12 ++++ .../features/overview/GlobalOverviewPage.tsx | 58 ++++++++++--------- devfront/src/features/profile/ProfilePage.tsx | 11 +++- devfront/src/locales/en.toml | 2 + devfront/src/locales/ko.toml | 2 + devfront/src/locales/template.toml | 2 + 14 files changed, 150 insertions(+), 164 deletions(-) diff --git a/common/core/components/page/PageHeader.tsx b/common/core/components/page/PageHeader.tsx index c574079a..a1a10969 100644 --- a/common/core/components/page/PageHeader.tsx +++ b/common/core/components/page/PageHeader.tsx @@ -35,7 +35,7 @@ export function PageHeader({ className={cx( "flex flex-wrap items-start justify-between gap-4", sticky && - "sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pt-4 pb-2 backdrop-blur", + "sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pt-4 pb-2 backdrop-blur", className, )} {...props} diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index c15aaa6f..14fe276f 100644 --- a/devfront/src/features/audit/AuditLogsPage.tsx +++ b/devfront/src/features/audit/AuditLogsPage.tsx @@ -1,6 +1,6 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { Download, RefreshCw, Search } from "lucide-react"; +import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react"; import * as React from "react"; import { parseAuditDetails } from "../../../../common/core/audit"; import { AuditLogTable } from "../../../../common/core/components/audit"; @@ -12,6 +12,7 @@ import { Button } from "../../components/ui/button"; import { Card, CardContent, + CardDescription, CardHeader, CardTitle, } from "../../components/ui/card"; @@ -120,6 +121,7 @@ function AuditLogsPage() { return (
} title={t("ui.common.audit.title", "Audit Logs")} description={t( "msg.dev.audit.subtitle", @@ -157,6 +159,12 @@ function AuditLogsPage() { {t("ui.common.audit.registry.title", "Audit registry")} + + {t( + "msg.dev.audit.registry_description", + "최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.", + )} +
diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 00da9ce7..8d9bb12b 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -1,15 +1,16 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { - ArrowLeft, ChevronLeft, ChevronRight, Download, Filter, Search, + ShieldHalf, } from "lucide-react"; import { useState } from "react"; import { Link, useParams } from "react-router-dom"; +import { PageHeader } from "../../../../common/core/components/page"; import { commonStickyTableHeaderClass, commonTableShellClass, @@ -194,21 +195,13 @@ function ClientConsentsPage() { )} -
- -
-

- {t( - "ui.dev.clients.consents.title", - "User Consent Grants", - )} -

-
-
+ } + title={t( + "ui.dev.clients.consents.title", + "User Consent Grants", + )} + />
@@ -242,24 +235,14 @@ function ClientConsentsPage() { )} -
- -
-

- {t("ui.dev.clients.consents.title", "User Consent Grants")} -

-

- {t( - "msg.dev.clients.consents.subtitle", - "OIDC Relying Party 사용자 권한을 검토·관리합니다.", - )} -

-
-
+ } + title={t("ui.dev.clients.consents.title", "User Consent Grants")} + description={t( + "msg.dev.clients.consents.subtitle", + "OIDC Relying Party 사용자 권한을 검토·관리합니다.", + )} + />

- + {rows.filter((r) => r.status === "active").length} @@ -636,7 +619,7 @@ function ClientConsentsPage() { "Total Scopes Issued", )}

- + {rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)} @@ -649,7 +632,7 @@ function ClientConsentsPage() { "Avg. Scopes per User", )}

- + {rows.length > 0 ? ( rows.reduce( diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index 0f2c50b0..e7ada9b3 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -1,16 +1,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { - ArrowLeft, Eye, EyeOff, Link2, RefreshCw, Save, Shield, + ShieldHalf, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; +import { PageHeader } from "../../../../common/core/components/page"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { @@ -246,36 +247,26 @@ function ClientDetailsPage() { {t("ui.dev.clients.details.tab.connection", "Federation")} -
-
- -
-

- {client?.name || client?.id || clientId} -

-

- {t( - "msg.dev.clients.details.subtitle", - "Manage OIDC credentials and endpoints.", - )} -

-
-
- - {client?.status === "active" - ? t("ui.common.status.active", "Active") - : client?.status === "inactive" - ? t("ui.common.status.inactive", "Inactive") - : t("msg.common.loading", "Loading...")} - -
+ } + title={client?.name || client?.id || clientId} + description={t( + "msg.dev.clients.details.subtitle", + "Manage OIDC credentials and endpoints.", + )} + actions={ + + {client?.status === "active" + ? t("ui.common.status.active", "Active") + : client?.status === "inactive" + ? t("ui.common.status.inactive", "Inactive") + : t("msg.common.loading", "Loading...")} + + } + />
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 8e3ace74..dd6e2788 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -1,7 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { - ArrowLeft, Check, ExternalLink, Info, @@ -9,6 +8,7 @@ import { Save, Search, Shield, + ShieldHalf, Sparkles, Trash2, Upload, @@ -17,6 +17,7 @@ import { import { useEffect, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate, useParams } from "react-router-dom"; +import { PageHeader } from "../../../../common/core/components/page"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { @@ -1195,26 +1196,18 @@ function ClientGeneralPage() { )} -
- -
-

- {isCreate - ? t("ui.dev.clients.general.title_create", "Create Client") - : t("ui.dev.clients.general.title_edit", "Client Settings")} -

-

- {t( - "ui.dev.clients.general.subtitle", - "앱 정보, 권한 스코프, 보안 설정을 관리합니다.", - )} -

-
-
+ } + title={ + isCreate + ? t("ui.dev.clients.general.title_create", "Create Client") + : t("ui.dev.clients.general.title_edit", "Client Settings") + } + description={t( + "ui.dev.clients.general.subtitle", + "앱 정보, 권한 스코프, 보안 설정을 관리합니다.", + )} + />
{!isCreate && ( -
- -
-

- {t( - "ui.dev.clients.relationships.title", - "Client Relationships", - )} -

-

- {t( - "msg.dev.clients.relationships.subtitle", - "RP direct operator relation을 조회하고 User 단위로 추가·삭제합니다.", - )} -

-
-
+ } + title={t("ui.dev.clients.relationships.title", "Client Relationships")} + description={t( + "msg.dev.clients.relationships.subtitle", + "RP direct operator relation을 조회하고 User 단위로 추가·삭제합니다.", + )} + />
} title={t("ui.dev.clients.registry.subtitle", "연동 앱")} description={t( "msg.dev.clients.registry.description", diff --git a/devfront/src/features/clients/routes/ClientFederationPage.tsx b/devfront/src/features/clients/routes/ClientFederationPage.tsx index 6700e7fe..8f8a2999 100644 --- a/devfront/src/features/clients/routes/ClientFederationPage.tsx +++ b/devfront/src/features/clients/routes/ClientFederationPage.tsx @@ -1,7 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Edit, Globe, Plus, Save, Trash2 } from "lucide-react"; +import { Edit, Plus, Save, ShieldHalf, Trash2 } from "lucide-react"; import { useState } from "react"; import { useParams } from "react-router-dom"; +import { PageHeader } from "../../../../../common/core/components/page"; import { Button } from "../../../components/ui/button"; import { Card, @@ -195,24 +196,20 @@ export function ClientFederationPage() { return (
-
-
-

- - {t("ui.dev.clients.federation.title", "Identity Federation")} -

-

- {t( - "msg.dev.clients.federation.subtitle", - "Manage external identity providers for this application.", - )} -

-
- -
+ } + title={t("ui.dev.clients.federation.title", "Identity Federation")} + description={t( + "msg.dev.clients.federation.subtitle", + "Manage external identity providers for this application.", + )} + actions={ + + } + /> diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.tsx index 76bdd042..1031b02f 100644 --- a/devfront/src/features/developer-request/DeveloperRequestPage.tsx +++ b/devfront/src/features/developer-request/DeveloperRequestPage.tsx @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + ClipboardCheck, CheckCircle2, Clock, Plus, @@ -20,6 +21,7 @@ import { Button } from "../../components/ui/button"; import { Card, CardContent, + CardDescription, CardHeader, CardTitle, } from "../../components/ui/card"; @@ -152,6 +154,8 @@ export default function DeveloperRequestPage() { const hasActiveRequest = requests?.some( (r) => r.status === "pending" || r.status === "approved", ); + const approvedRequestCount = + requests?.filter((request) => request.status === "approved").length ?? 0; const isActionPending = approveMutation.isPending || rejectMutation.isPending || @@ -160,6 +164,7 @@ export default function DeveloperRequestPage() { return (
} title={t("ui.dev.nav.developer_request", "개발자 권한 신청")} description={ isSuperAdmin @@ -187,6 +192,13 @@ export default function DeveloperRequestPage() { {t("ui.dev.request.list.title", "신청 내역")} + + {t( + "msg.dev.request.list.approved_count", + "총 {{count}}명의 사용자가 승인되었습니다.", + { count: approvedRequestCount }, + )} +
diff --git a/devfront/src/features/overview/GlobalOverviewPage.tsx b/devfront/src/features/overview/GlobalOverviewPage.tsx index 7db5b53d..4d6302bd 100644 --- a/devfront/src/features/overview/GlobalOverviewPage.tsx +++ b/devfront/src/features/overview/GlobalOverviewPage.tsx @@ -3,8 +3,8 @@ import type { AxiosError } from "axios"; import { Activity, AlertTriangle, - BarChart3, CheckCircle2, + LayoutDashboard, Layers3, ShieldCheck, } from "lucide-react"; @@ -662,17 +662,22 @@ function GlobalOverviewPage() { return (
-
-
-

- {t("ui.common.overview.title", "운영 현황")} -

-

- {t( - "msg.dev.dashboard.description", - "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다.", - )} -

+
+
+
+ +
+
+

+ {t("ui.common.overview.title", "운영 현황")} +

+

+ {t( + "msg.dev.dashboard.description", + "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다.", + )} +

+
@@ -704,22 +709,19 @@ function GlobalOverviewPage() {
-
- -
-

- {t( - "ui.dev.dashboard.chart.title", - "애플리케이션별 로그인요청/기타 요청 현황", - )} -

-

- {t( - "msg.dev.dashboard.chart.filter_description", - "전체 또는 선택한 애플리케이션만 기준으로 그래프를 확인합니다.", - )} -

-
+
+

+ {t( + "ui.dev.dashboard.chart.title", + "애플리케이션별 로그인요청/기타 요청 현황", + )} +

+

+ {t( + "msg.dev.dashboard.chart.filter_description", + "전체 또는 선택한 애플리케이션만 기준으로 그래프를 확인합니다.", + )} +

{[ diff --git a/devfront/src/features/profile/ProfilePage.tsx b/devfront/src/features/profile/ProfilePage.tsx index e19164bc..1aba06df 100644 --- a/devfront/src/features/profile/ProfilePage.tsx +++ b/devfront/src/features/profile/ProfilePage.tsx @@ -64,9 +64,14 @@ function ProfilePage() { return (
-

- {t("ui.dev.profile.title", "내 정보")} -

+
+
+ +
+

+ {t("ui.dev.profile.title", "내 정보")} +

+

{t( "ui.dev.profile.subtitle", diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 47c470d7..3deacc90 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -313,6 +313,7 @@ forbidden = "You do not have permission to view audit logs. Please request acces load_error = "Error loading audit logs: {{error}}" loaded_count = "Loaded {{count}} rows" loading = "Loading audit logs..." +registry_description = "Filter recent audit logs by search criteria and review action history quickly." subtitle = "View developer activity history within the current app scope and review target-specific changes." [msg.dev.request] @@ -320,6 +321,7 @@ admin_desc = "Manage developer access requests submitted by users." approved = "Approved." cancelled = "Approval has been cancelled." empty = "No requests found." +list.approved_count = "{{count}} users have been approved." need_cancel_notes = "Please enter a reason for cancelling approval." need_notes = "Please enter a rejection reason." rejected = "Rejected." diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 31638c6d..18a8b646 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -313,6 +313,7 @@ forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 load_error = "감사 로그 조회 실패: {{error}}" loaded_count = "로드된 로그 {{count}}건" loading = "감사 로그를 불러오는 중..." +registry_description = "최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다." subtitle = "현재 앱 범위에서 개발자 작업 이력을 조회하고 대상별 변경 내역을 확인합니다." [msg.dev.request] @@ -320,6 +321,7 @@ admin_desc = "사용자들의 개발자 권한 신청 내역을 관리합니다. approved = "승인되었습니다." cancelled = "승인이 취소되었습니다." empty = "신청 내역이 없습니다." +list.approved_count = "총 {{count}}명의 사용자가 승인되었습니다." need_cancel_notes = "승인 취소 사유를 입력해주세요." need_notes = "반려 사유를 입력해주세요." rejected = "반려되었습니다." diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 5be16834..9a0145d1 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -327,6 +327,7 @@ forbidden = "" load_error = "" loaded_count = "" loading = "" +registry_description = "" subtitle = "" [msg.dev.request] @@ -334,6 +335,7 @@ admin_desc = "" approved = "" cancelled = "" empty = "" +list.approved_count = "" need_cancel_notes = "" need_notes = "" rejected = "" From fc4a2f3536d03cc5a0f519f92e515915e30934f3 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 19 May 2026 17:47:41 +0900 Subject: [PATCH 19/25] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20i18n=20namespace=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/AppLayout.tsx | 20 +++++++-------- adminfront/src/locales/en.toml | 25 +++++++++++++++++++ adminfront/src/locales/ko.toml | 25 +++++++++++++++++++ adminfront/src/locales/template.toml | 25 +++++++++++++++++++ common/shell/index.ts | 10 ++++---- devfront/src/components/layout/AppLayout.tsx | 20 +++++++-------- devfront/src/locales/en.toml | 25 +++++++++++++++++++ devfront/src/locales/ko.toml | 25 +++++++++++++++++++ devfront/src/locales/template.toml | 25 +++++++++++++++++++ 9 files changed, 175 insertions(+), 25 deletions(-) diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 2c18e121..893eb19f 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -492,8 +492,8 @@ function AppLayout() { auth.user?.profile.name?.toString() || auth.user?.profile.preferred_username?.toString(), profileEmail: profile?.email || auth.user?.profile.email?.toString(), - fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"), - fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"), + fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"), + fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"), }); const profileRoleKey = mockRoleOverride || profile?.role || "user"; const handleSessionExpiryToggle = () => { @@ -559,7 +559,7 @@ function AppLayout() { className={shellLayoutClasses.logoutButton} > - {t("ui.admin.nav.logout", "Logout")} + {t("ui.shell.nav.logout", "Logout")}

); @@ -620,7 +620,7 @@ function AppLayout() { className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20" aria-haspopup="menu" aria-expanded={isProfileOpen} - aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")} + aria-label={t("ui.shell.profile.menu_aria", "계정 메뉴 열기")} >
{profileSummary.initial} @@ -642,7 +642,7 @@ function AppLayout() { {isProfileOpen ? (

- {t("ui.dev.profile.menu_title", "Account")} + {t("ui.shell.profile.menu_title", "Account")}

@@ -656,7 +656,7 @@ function AppLayout() {
{t( - `ui.admin.role.${profileRoleKey}`, + `ui.shell.role.${profileRoleKey}`, profileRoleKey.toUpperCase(), )} @@ -667,7 +667,7 @@ function AppLayout() {

- {t("ui.dev.session.auto_extend", "세션 만료 관리")} + {t("ui.shell.session.auto_extend", "세션 만료 관리")}

{isSessionExpiryEnabled ? ( @@ -676,7 +676,7 @@ function AppLayout() { t={t} /> ) : ( - t("ui.dev.session.disabled", "세션 만료 비활성화") + t("ui.shell.session.disabled", "세션 만료 비활성화") )}

@@ -754,7 +754,7 @@ function AppLayout() { className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20" > - {t("ui.userfront.nav.profile", "내 정보")} + {t("ui.shell.nav.profile", "내 정보")}
) : null} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 1c12bd43..b3228ecd 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -1521,6 +1521,22 @@ menu_title = "Account" unknown_email = "unknown@example.com" unknown_name = "Unknown User" +[ui.shell.profile] +menu_aria = "Open account menu" +menu_title = "Account" +unknown_email = "unknown@example.com" +unknown_name = "Unknown User" + +[ui.shell.nav] +logout = "Logout" +profile = "My Profile" + +[ui.shell.role] +rp_admin = "Service Administrator (RP Admin)" +super_admin = "System Administrator (Super Admin)" +tenant_admin = "Tenant Administrator (Tenant Admin)" +user = "General User (Tenant Member)" + [ui.dev.clients] new = "Add Connected Application" search_placeholder = "Search by app name or ID..." @@ -1727,6 +1743,15 @@ expired = "Session expired" expiring = "Expiring soon: {{minutes}}m {{seconds}}s left" remaining = "Expires in {{minutes}}m {{seconds}}s" +[ui.shell.session] +auto_extend = "Session expiry" +active = "Session active" +disabled = "Session expiry disabled" +unknown = "Unknown" +expired = "Session expired" +expiring = "Expiring soon: {{minutes}}m {{seconds}}s left" +remaining = "Expires in {{minutes}}m {{seconds}}s" + [ui.userfront] app_title = "Baron SW Portal" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 0b2d4aa0..e34691a0 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -1524,6 +1524,22 @@ menu_title = "계정" unknown_email = "unknown@example.com" unknown_name = "Unknown User" +[ui.shell.profile] +menu_aria = "계정 메뉴 열기" +menu_title = "계정" +unknown_email = "unknown@example.com" +unknown_name = "Unknown User" + +[ui.shell.nav] +logout = "Logout" +profile = "내 정보" + +[ui.shell.role] +rp_admin = "서비스 관리자 (RP Admin)" +super_admin = "시스템 관리자 (Super Admin)" +tenant_admin = "테넌트 관리자 (Tenant Admin)" +user = "일반 사용자 (Tenant Member)" + [ui.dev.clients] new = "연동 앱 추가" search_placeholder = "연동 앱 이름/ID로 검색..." @@ -1729,6 +1745,15 @@ expired = "세션 만료" expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음" +[ui.shell.session] +auto_extend = "세션 만료 관리" +active = "세션 활성" +disabled = "세션 만료 비활성화" +unknown = "알 수 없음" +expired = "세션 만료" +expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" +remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음" + [ui.userfront] app_title = "Baron SW 포탈" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index cf7ae1c5..85b2adbd 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -1495,6 +1495,22 @@ menu_title = "" unknown_email = "" unknown_name = "" +[ui.shell.profile] +menu_aria = "" +menu_title = "" +unknown_email = "" +unknown_name = "" + +[ui.shell.nav] +logout = "" +profile = "" + +[ui.shell.role] +rp_admin = "" +super_admin = "" +tenant_admin = "" +user = "" + [ui.dev.clients] new = "" search_placeholder = "" @@ -1701,6 +1717,15 @@ expired = "" expiring = "" remaining = "" +[ui.shell.session] +auto_extend = "" +active = "" +disabled = "" +unknown = "" +expired = "" +expiring = "" +remaining = "" + [ui.userfront] app_title = "" diff --git a/common/shell/index.ts b/common/shell/index.ts index e394cefc..f7cea93b 100644 --- a/common/shell/index.ts +++ b/common/shell/index.ts @@ -84,15 +84,15 @@ export function buildShellSessionStatus({ let toneClass = "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - let text = t("ui.dev.session.active", "세션 활성"); + let text = t("ui.shell.session.active", "세션 활성"); if (remainingMs === null) { toneClass = "border-border bg-card text-muted-foreground"; - text = t("ui.dev.session.unknown", "알 수 없음"); + text = t("ui.shell.session.unknown", "알 수 없음"); } else if (remainingMs <= 0) { toneClass = "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300"; - text = t("ui.dev.session.expired", "세션 만료"); + text = t("ui.shell.session.expired", "세션 만료"); } else if ( remainingMinutes !== null && remainingSeconds !== null && @@ -101,7 +101,7 @@ export function buildShellSessionStatus({ toneClass = "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; text = t( - "ui.dev.session.expiring", + "ui.shell.session.expiring", "만료 임박: {{minutes}}분 {{seconds}}초 남음", { minutes: remainingMinutes, @@ -110,7 +110,7 @@ export function buildShellSessionStatus({ ); } else { text = t( - "ui.dev.session.remaining", + "ui.shell.session.remaining", "만료 예정: {{minutes}}분 {{seconds}}초 남음", { minutes: remainingMinutes ?? 0, diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 34e453b9..aac3fcce 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -336,8 +336,8 @@ function AppLayout() { auth.user?.profile?.preferred_username?.toString() || auth.user?.profile?.nickname?.toString(), profileEmail: profile?.email || auth.user?.profile?.email?.toString(), - fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"), - fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"), + fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"), + fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"), }); const currentRole = resolveProfileRole( auth.user?.profile as Record | undefined, @@ -380,7 +380,7 @@ function AppLayout() { className={shellLayoutClasses.logoutButton} > - {t("ui.dev.nav.logout", "Logout")} + {t("ui.shell.nav.logout", "Logout")}
); @@ -433,7 +433,7 @@ function AppLayout() { aria-haspopup="menu" aria-expanded={isProfileMenuOpen} aria-label={t( - "ui.dev.profile.menu_aria", + "ui.shell.profile.menu_aria", "Open account menu", )} > @@ -456,7 +456,7 @@ function AppLayout() { {isProfileMenuOpen ? (

- {t("ui.dev.profile.menu_title", "Account")} + {t("ui.shell.profile.menu_title", "Account")}

@@ -470,7 +470,7 @@ function AppLayout() {
{t( - `ui.admin.role.${displayRoleKey}`, + `ui.shell.role.${displayRoleKey}`, displayRoleKey.toUpperCase(), )} @@ -481,7 +481,7 @@ function AppLayout() {

- {t("ui.dev.session.auto_extend", "Session expiry")} + {t("ui.shell.session.auto_extend", "Session expiry")}

{isSessionExpiryEnabled ? ( @@ -491,7 +491,7 @@ function AppLayout() { /> ) : ( t( - "ui.dev.session.disabled", + "ui.shell.session.disabled", "Session expiry disabled", ) )} @@ -529,7 +529,7 @@ function AppLayout() { }} > - {t("ui.dev.profile.title", "My Profile")} + {t("ui.shell.nav.profile", "My Profile")}

) : null} diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 3deacc90..f7317f0e 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -1340,6 +1340,22 @@ menu_title = "Account" unknown_email = "unknown@example.com" unknown_name = "Unknown User" +[ui.shell.profile] +menu_aria = "Open account menu" +menu_title = "Account" +unknown_email = "unknown@example.com" +unknown_name = "Unknown User" + +[ui.shell.nav] +logout = "Logout" +profile = "My Profile" + +[ui.shell.role] +rp_admin = "Service Administrator (RP Admin)" +super_admin = "System Administrator (Super Admin)" +tenant_admin = "Tenant Administrator (Tenant Admin)" +user = "General User (Tenant Member)" + [ui.dev.clients] new = "Add Connected Application" search_placeholder = "Search by app name or ID..." @@ -1730,6 +1746,15 @@ expired = "Session expired" expiring = "Expiring soon: {{minutes}}m {{seconds}}s left" remaining = "Expires in {{minutes}}m {{seconds}}s" +[ui.shell.session] +auto_extend = "Session expiry" +active = "Session active" +disabled = "Session expiry disabled" +unknown = "Unknown" +expired = "Session expired" +expiring = "Expiring soon: {{minutes}}m {{seconds}}s left" +remaining = "Expires in {{minutes}}m {{seconds}}s" + [ui.userfront] app_title = "Baron SW Portal" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 18a8b646..3ce007e4 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -1340,6 +1340,22 @@ menu_title = "계정" unknown_email = "unknown@example.com" unknown_name = "Unknown User" +[ui.shell.profile] +menu_aria = "계정 메뉴 열기" +menu_title = "계정" +unknown_email = "unknown@example.com" +unknown_name = "Unknown User" + +[ui.shell.nav] +logout = "Logout" +profile = "내 정보" + +[ui.shell.role] +rp_admin = "서비스 관리자 (RP Admin)" +super_admin = "시스템 관리자 (Super Admin)" +tenant_admin = "테넌트 관리자 (Tenant Admin)" +user = "일반 사용자 (Tenant Member)" + [ui.dev.clients] new = "연동 앱 추가" search_placeholder = "연동 앱 이름/ID로 검색..." @@ -1729,6 +1745,15 @@ expired = "세션 만료" expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음" +[ui.shell.session] +auto_extend = "세션 만료 관리" +active = "세션 활성" +disabled = "세션 만료 비활성화" +unknown = "알 수 없음" +expired = "세션 만료" +expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" +remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음" + [ui.userfront] app_title = "Baron SW 포탈" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 9a0145d1..d144c050 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -1396,6 +1396,22 @@ menu_title = "" unknown_email = "" unknown_name = "" +[ui.shell.profile] +menu_aria = "" +menu_title = "" +unknown_email = "" +unknown_name = "" + +[ui.shell.nav] +logout = "" +profile = "" + +[ui.shell.role] +rp_admin = "" +super_admin = "" +tenant_admin = "" +user = "" + [ui.dev.clients] new = "" search_placeholder = "" @@ -1786,6 +1802,15 @@ expired = "" expiring = "" remaining = "" +[ui.shell.session] +auto_extend = "" +active = "" +disabled = "" +unknown = "" +expired = "" +expiring = "" +remaining = "" + [ui.userfront] app_title = "" From c21ea291115dc61c466510bb978c4c1b09ff8c3e Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 19 May 2026 17:52:47 +0900 Subject: [PATCH 20/25] =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B0=80=EB=93=9C?= =?UTF-8?q?=20=EB=B0=95=EC=8A=A4=20=ED=83=80=EC=9D=B4=ED=8B=80=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EB=B0=B0=EC=A7=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/components/PermissionChecker.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/adminfront/src/features/auth/components/PermissionChecker.tsx b/adminfront/src/features/auth/components/PermissionChecker.tsx index c8dbba7b..eb7e9511 100644 --- a/adminfront/src/features/auth/components/PermissionChecker.tsx +++ b/adminfront/src/features/auth/components/PermissionChecker.tsx @@ -1,5 +1,5 @@ import { useMutation } from "@tanstack/react-query"; -import { CheckCircle2, ShieldCheck, XCircle } from "lucide-react"; +import { CheckCircle2, XCircle } from "lucide-react"; import { useState } from "react"; import { Button } from "../../../components/ui/button"; import { @@ -47,10 +47,7 @@ function PermissionChecker() { return ( - - - - + {t( "ui.admin.auth_guard.checker.title", "ReBAC permission checker", From 16d43c59733244fa8e75ce0fa9976d280ae156e8 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 20 May 2026 11:10:05 +0900 Subject: [PATCH 21/25] =?UTF-8?q?headless=20JWKS=20=EC=9B=8C=EC=BB=A4=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20backoff=20=EB=B0=8F=20timeout=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/domain/headless_jwks_cache.go | 1 + .../internal/service/headless_jwks_cache.go | 71 +++++++++++++++---- .../service/headless_jwks_cache_test.go | 43 +++++++++++ common/package-lock.json | 26 +++---- 4 files changed, 116 insertions(+), 25 deletions(-) diff --git a/backend/internal/domain/headless_jwks_cache.go b/backend/internal/domain/headless_jwks_cache.go index 082b69d6..84d0a262 100644 --- a/backend/internal/domain/headless_jwks_cache.go +++ b/backend/internal/domain/headless_jwks_cache.go @@ -17,6 +17,7 @@ type HeadlessJWKSCacheState struct { CachedAt *time.Time `json:"cachedAt,omitempty"` ExpiresAt *time.Time `json:"expiresAt,omitempty"` LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"` + NextRetryAt *time.Time `json:"nextRetryAt,omitempty"` LastSuccessfulVerificationAt *time.Time `json:"lastSuccessfulVerificationAt,omitempty"` LastRefreshStatus string `json:"lastRefreshStatus,omitempty"` LastError string `json:"lastError,omitempty"` diff --git a/backend/internal/service/headless_jwks_cache.go b/backend/internal/service/headless_jwks_cache.go index 2413a662..4ca67471 100644 --- a/backend/internal/service/headless_jwks_cache.go +++ b/backend/internal/service/headless_jwks_cache.go @@ -20,11 +20,13 @@ const ( ) type HeadlessJWKSCacheService struct { - Redis domain.RedisRepository - HTTPClient *http.Client - TTL time.Duration - PrefetchWindow time.Duration - RequestTimeout time.Duration + Redis domain.RedisRepository + HTTPClient *http.Client + TTL time.Duration + PrefetchWindow time.Duration + RequestTimeout time.Duration + FailureThreshold int + FailureBackoff time.Duration } type headlessJWKSCacheStateStore struct { @@ -33,6 +35,7 @@ type headlessJWKSCacheStateStore struct { CachedAt *time.Time `json:"cachedAt,omitempty"` ExpiresAt *time.Time `json:"expiresAt,omitempty"` LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"` + NextRetryAt *time.Time `json:"nextRetryAt,omitempty"` LastSuccessfulVerificationAt *time.Time `json:"lastSuccessfulVerificationAt,omitempty"` LastRefreshStatus string `json:"lastRefreshStatus,omitempty"` LastError string `json:"lastError,omitempty"` @@ -61,17 +64,29 @@ func NewHeadlessJWKSCacheService(redis domain.RedisRepository, httpClient *http. prefetchSeconds = 600 } - timeoutSeconds, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_FETCH_TIMEOUT_SECONDS", "5"))) + timeoutSeconds, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_FETCH_TIMEOUT_SECONDS", "2"))) if timeoutSeconds <= 0 { - timeoutSeconds = 5 + timeoutSeconds = 2 + } + + failureThreshold, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_FAILURE_THRESHOLD", "3"))) + if failureThreshold <= 0 { + failureThreshold = 3 + } + + backoffSeconds, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_FAILURE_BACKOFF_SECONDS", "1800"))) + if backoffSeconds <= 0 { + backoffSeconds = 1800 } return &HeadlessJWKSCacheService{ - Redis: redis, - HTTPClient: httpClient, - TTL: time.Duration(ttlSeconds) * time.Second, - PrefetchWindow: time.Duration(prefetchSeconds) * time.Second, - RequestTimeout: time.Duration(timeoutSeconds) * time.Second, + Redis: redis, + HTTPClient: httpClient, + TTL: time.Duration(ttlSeconds) * time.Second, + PrefetchWindow: time.Duration(prefetchSeconds) * time.Second, + RequestTimeout: time.Duration(timeoutSeconds) * time.Second, + FailureThreshold: failureThreshold, + FailureBackoff: time.Duration(backoffSeconds) * time.Second, } } @@ -115,6 +130,7 @@ func (s *HeadlessJWKSCacheService) SaveState(clientID string, state domain.Headl CachedAt: state.CachedAt, ExpiresAt: state.ExpiresAt, LastCheckedAt: state.LastCheckedAt, + NextRetryAt: state.NextRetryAt, LastSuccessfulVerificationAt: state.LastSuccessfulVerificationAt, LastRefreshStatus: state.LastRefreshStatus, LastError: state.LastError, @@ -151,6 +167,7 @@ func (s *HeadlessJWKSCacheService) GetState(clientID string) (*domain.HeadlessJW CachedAt: stored.CachedAt, ExpiresAt: stored.ExpiresAt, LastCheckedAt: stored.LastCheckedAt, + NextRetryAt: stored.NextRetryAt, LastSuccessfulVerificationAt: stored.LastSuccessfulVerificationAt, LastRefreshStatus: stored.LastRefreshStatus, LastError: stored.LastError, @@ -193,6 +210,9 @@ func (s *HeadlessJWKSCacheService) ShouldPrefetch(state *domain.HeadlessJWKSCach if state == nil { return true } + if s.ShouldSkipRefresh(state, now) { + return false + } if strings.TrimSpace(state.RawJWKS) == "" { return true } @@ -202,6 +222,13 @@ func (s *HeadlessJWKSCacheService) ShouldPrefetch(state *domain.HeadlessJWKSCach return !state.ExpiresAt.After(now.Add(s.PrefetchWindow)) } +func (s *HeadlessJWKSCacheService) ShouldSkipRefresh(state *domain.HeadlessJWKSCacheState, now time.Time) bool { + if state == nil || state.NextRetryAt == nil { + return false + } + return state.NextRetryAt.After(now) +} + func (s *HeadlessJWKSCacheService) EnsureFreshKeySet(ctx context.Context, client domain.HydraClient, expectedKid string) (*jose.JSONWebKeySet, *domain.HeadlessJWKSCacheState, bool, error) { if s == nil { return nil, nil, false, fmt.Errorf("headless jwks cache service is not configured") @@ -283,6 +310,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom updated.JWKSURI = jwksURI updated.LastCheckedAt = &now updated.ExpiresAt = ptrTime(now.Add(s.TTL)) + updated.NextRetryAt = nil updated.LastRefreshStatus = "success" updated.LastError = "" updated.ConsecutiveFailures = 0 @@ -313,6 +341,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom CachedAt: &now, ExpiresAt: ptrTime(now.Add(s.TTL)), LastCheckedAt: &now, + NextRetryAt: nil, LastSuccessfulVerificationAt: previousLastVerification(previous), LastRefreshStatus: "success", LastError: "", @@ -349,10 +378,28 @@ func (s *HeadlessJWKSCacheService) persistRefreshFailure(client domain.HydraClie state.RawJWKS = previous.RawJWKS state.ConsecutiveFailures = previous.ConsecutiveFailures + 1 } + if s.shouldBackoff(state.ConsecutiveFailures) { + state.NextRetryAt = ptrTime(now.Add(s.failureBackoffDuration())) + } _ = s.SaveState(client.ClientID, state) return &state } +func (s *HeadlessJWKSCacheService) shouldBackoff(consecutiveFailures int) bool { + threshold := s.FailureThreshold + if threshold <= 0 { + threshold = 3 + } + return consecutiveFailures >= threshold +} + +func (s *HeadlessJWKSCacheService) failureBackoffDuration() time.Duration { + if s.FailureBackoff > 0 { + return s.FailureBackoff + } + return 30 * time.Minute +} + func decodeHeadlessJWKS(raw string) (*jose.JSONWebKeySet, error) { var keySet jose.JSONWebKeySet if err := json.Unmarshal([]byte(raw), &keySet); err != nil { diff --git a/backend/internal/service/headless_jwks_cache_test.go b/backend/internal/service/headless_jwks_cache_test.go index c4b650dd..8d835eef 100644 --- a/backend/internal/service/headless_jwks_cache_test.go +++ b/backend/internal/service/headless_jwks_cache_test.go @@ -143,6 +143,49 @@ func TestHeadlessJWKSCacheService_EnsureFreshKeySet_RefreshesWhenKidMissing(t *t assert.Equal(t, []string{"fresh-key"}, stored.CachedKids) } +func TestHeadlessJWKSCacheService_PersistRefreshFailure_SetsNextRetryAtAfterThreshold(t *testing.T) { + redisRepo := &headlessJWKSCacheTestRedis{} + cacheService := NewHeadlessJWKSCacheService(redisRepo, nil) + cacheService.FailureThreshold = 3 + cacheService.FailureBackoff = 15 * time.Minute + + client := domain.HydraClient{ + ClientID: "client-headless", + Metadata: map[string]any{ + domain.MetadataHeadlessLoginEnabled: true, + domain.MetadataHeadlessJWKSURI: "https://rp.example.com/.well-known/jwks.json", + }, + } + + previous := &domain.HeadlessJWKSCacheState{ + ClientID: client.ClientID, + JWKSURI: "https://rp.example.com/.well-known/jwks.json", + LastRefreshStatus: "failure", + ConsecutiveFailures: 2, + } + + state := cacheService.persistRefreshFailure(client, previous, assert.AnError) + require.NotNil(t, state) + assert.Equal(t, 3, state.ConsecutiveFailures) + require.NotNil(t, state.NextRetryAt) + assert.WithinDuration(t, time.Now().Add(15*time.Minute), *state.NextRetryAt, 3*time.Second) +} + +func TestHeadlessJWKSCacheService_ShouldPrefetch_SkipsUntilNextRetryAt(t *testing.T) { + cacheService := NewHeadlessJWKSCacheService(&headlessJWKSCacheTestRedis{}, nil) + now := time.Now() + + state := &domain.HeadlessJWKSCacheState{ + ClientID: "client-headless", + LastRefreshStatus: "failure", + ConsecutiveFailures: 3, + NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)), + } + + assert.False(t, cacheService.ShouldPrefetch(state, now)) + assert.True(t, cacheService.ShouldPrefetch(state, now.Add(11*time.Minute))) +} + func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose.JSONWebKeySet) { t.Helper() diff --git a/common/package-lock.json b/common/package-lock.json index 04c0031f..c0ce14f5 100644 --- a/common/package-lock.json +++ b/common/package-lock.json @@ -2522,9 +2522,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3164,9 +3164,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.359", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.359.tgz", - "integrity": "sha512-8lPELWuYZIWk7NDvCNthtmMw/7Q5Wu25NpM4djFMHBmk8DubPAtL4YTOp7ou0e7HyJtwkVlWv8XMLURnrtgJQw==", + "version": "1.5.360", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.360.tgz", + "integrity": "sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==", "dev": true, "license": "ISC" }, @@ -3999,9 +3999,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", - "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -4285,9 +4285,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4305,7 +4305,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, From dd1238a4e4a6b89c7d2405482511c6999bcf7615 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 20 May 2026 11:32:11 +0900 Subject: [PATCH 22/25] =?UTF-8?q?headless=20JWKS=20=EC=9B=8C=EC=BB=A4=20ba?= =?UTF-8?q?ckoff=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/headless_jwks_cache_test.go | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) diff --git a/backend/internal/service/headless_jwks_cache_test.go b/backend/internal/service/headless_jwks_cache_test.go index 8d835eef..b08b8225 100644 --- a/backend/internal/service/headless_jwks_cache_test.go +++ b/backend/internal/service/headless_jwks_cache_test.go @@ -6,7 +6,9 @@ import ( "crypto/rand" "crypto/rsa" "encoding/json" + "io" "net/http" + "strings" "testing" "time" @@ -186,6 +188,218 @@ func TestHeadlessJWKSCacheService_ShouldPrefetch_SkipsUntilNextRetryAt(t *testin assert.True(t, cacheService.ShouldPrefetch(state, now.Add(11*time.Minute))) } +func TestHeadlessJWKSCacheWorker_RunOnce_SkipsBackoffTargets(t *testing.T) { + clients := []domain.HydraClient{ + newTestHeadlessClient("client-fail", "https://fail.example.com/.well-known/jwks.json"), + newTestHeadlessClient("client-skip", "https://skip.example.com/.well-known/jwks.json"), + } + + hydra := &HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: clientForHandler(jsonHandler(t, clients)), + } + + redisRepo := &headlessJWKSCacheTestRedis{} + cacheService := NewHeadlessJWKSCacheService(redisRepo, nil) + cacheService.FailureThreshold = 3 + cacheService.FailureBackoff = 15 * time.Minute + + now := time.Now() + require.NoError(t, cacheService.SaveState("client-fail", domain.HeadlessJWKSCacheState{ + ClientID: "client-fail", + JWKSURI: clients[0].HeadlessJWKSURI(), + LastRefreshStatus: "failure", + ConsecutiveFailures: 2, + })) + require.NoError(t, cacheService.SaveState("client-skip", domain.HeadlessJWKSCacheState{ + ClientID: "client-skip", + JWKSURI: clients[1].HeadlessJWKSURI(), + LastRefreshStatus: "failure", + ConsecutiveFailures: 3, + NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)), + })) + + fetchCounts := map[string]int{} + cacheService.HTTPClient = &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + fetchCounts[req.URL.Host]++ + if req.URL.Host == "fail.example.com" { + return jsonHTTPResponse(http.StatusInternalServerError, `{"error":"boom"}`), nil + } + t.Fatalf("unexpected fetch for host %s", req.URL.Host) + return nil, nil + }), + } + + worker := &HeadlessJWKSCacheWorker{ + Hydra: hydra, + Cache: cacheService, + PageSize: 100, + } + + worker.runOnce(context.Background()) + + assert.Equal(t, 1, fetchCounts["fail.example.com"]) + assert.Equal(t, 0, fetchCounts["skip.example.com"]) + + failedState, err := cacheService.GetState("client-fail") + require.NoError(t, err) + require.NotNil(t, failedState) + assert.Equal(t, 3, failedState.ConsecutiveFailures) + require.NotNil(t, failedState.NextRetryAt) + + skippedState, err := cacheService.GetState("client-skip") + require.NoError(t, err) + require.NotNil(t, skippedState) + assert.Equal(t, 3, skippedState.ConsecutiveFailures) + require.NotNil(t, skippedState.NextRetryAt) + assert.WithinDuration(t, now.Add(10*time.Minute), *skippedState.NextRetryAt, time.Second) +} + +func TestHeadlessJWKSCacheWorker_RunOnce_RetriesAfterBackoffAndClearsFailureStateOnSuccess(t *testing.T) { + _, freshJWKS := mustServiceHeadlessRSAJWK(t, "fresh-key") + freshRaw, err := json.Marshal(freshJWKS) + require.NoError(t, err) + + client := newTestHeadlessClient("client-recover", "https://recover.example.com/.well-known/jwks.json") + hydra := &HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: clientForHandler(jsonHandler(t, []domain.HydraClient{client})), + } + + redisRepo := &headlessJWKSCacheTestRedis{} + cacheService := NewHeadlessJWKSCacheService(redisRepo, nil) + cacheService.FailureThreshold = 3 + cacheService.FailureBackoff = 15 * time.Minute + + require.NoError(t, cacheService.SaveState("client-recover", domain.HeadlessJWKSCacheState{ + ClientID: "client-recover", + JWKSURI: client.HeadlessJWKSURI(), + LastRefreshStatus: "failure", + LastError: "previous failure", + ConsecutiveFailures: 3, + NextRetryAt: ptrTestTime(time.Now().Add(-time.Minute)), + })) + + fetchCount := 0 + cacheService.HTTPClient = &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + fetchCount++ + assert.Equal(t, "recover.example.com", req.URL.Host) + return jsonHTTPResponse(http.StatusOK, string(freshRaw)), nil + }), + } + + worker := &HeadlessJWKSCacheWorker{ + Hydra: hydra, + Cache: cacheService, + PageSize: 100, + } + + worker.runOnce(context.Background()) + + assert.Equal(t, 1, fetchCount) + + recoveredState, err := cacheService.GetState("client-recover") + require.NoError(t, err) + require.NotNil(t, recoveredState) + assert.Equal(t, "success", recoveredState.LastRefreshStatus) + assert.Empty(t, recoveredState.LastError) + assert.Equal(t, 0, recoveredState.ConsecutiveFailures) + assert.Nil(t, recoveredState.NextRetryAt) + assert.Equal(t, []string{"fresh-key"}, recoveredState.CachedKids) +} + +func TestHeadlessJWKSCacheWorker_RunOnce_MixedClients(t *testing.T) { + _, successJWKS := mustServiceHeadlessRSAJWK(t, "success-key") + successRaw, err := json.Marshal(successJWKS) + require.NoError(t, err) + + successClient := newTestHeadlessClient("client-success", "https://success.example.com/.well-known/jwks.json") + failClient := newTestHeadlessClient("client-fail", "https://fail.example.com/.well-known/jwks.json") + skipClient := newTestHeadlessClient("client-skip", "https://skip.example.com/.well-known/jwks.json") + disabledClient := domain.HydraClient{ + ClientID: "client-disabled", + Metadata: map[string]any{ + domain.MetadataHeadlessLoginEnabled: false, + domain.MetadataHeadlessJWKSURI: "https://disabled.example.com/.well-known/jwks.json", + domain.MetadataHeadlessTokenEndpointAuthMethod: "private_key_jwt", + }, + } + + hydra := &HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: clientForHandler(jsonHandler(t, []domain.HydraClient{ + successClient, + failClient, + skipClient, + disabledClient, + })), + } + + redisRepo := &headlessJWKSCacheTestRedis{} + cacheService := NewHeadlessJWKSCacheService(redisRepo, nil) + cacheService.FailureThreshold = 3 + cacheService.FailureBackoff = 20 * time.Minute + + require.NoError(t, cacheService.SaveState("client-fail", domain.HeadlessJWKSCacheState{ + ClientID: "client-fail", + JWKSURI: failClient.HeadlessJWKSURI(), + LastRefreshStatus: "failure", + ConsecutiveFailures: 2, + })) + require.NoError(t, cacheService.SaveState("client-skip", domain.HeadlessJWKSCacheState{ + ClientID: "client-skip", + JWKSURI: skipClient.HeadlessJWKSURI(), + LastRefreshStatus: "failure", + ConsecutiveFailures: 3, + NextRetryAt: ptrTestTime(time.Now().Add(10 * time.Minute)), + })) + + fetchCounts := map[string]int{} + cacheService.HTTPClient = &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + fetchCounts[req.URL.Host]++ + switch req.URL.Host { + case "success.example.com": + return jsonHTTPResponse(http.StatusOK, string(successRaw)), nil + case "fail.example.com": + return jsonHTTPResponse(http.StatusInternalServerError, `{"error":"boom"}`), nil + default: + t.Fatalf("unexpected fetch for host %s", req.URL.Host) + return nil, nil + } + }), + } + + worker := &HeadlessJWKSCacheWorker{ + Hydra: hydra, + Cache: cacheService, + PageSize: 100, + } + + worker.runOnce(context.Background()) + + assert.Equal(t, 1, fetchCounts["success.example.com"]) + assert.Equal(t, 1, fetchCounts["fail.example.com"]) + assert.Equal(t, 0, fetchCounts["skip.example.com"]) + assert.Equal(t, 0, fetchCounts["disabled.example.com"]) + + successState, err := cacheService.GetState("client-success") + require.NoError(t, err) + require.NotNil(t, successState) + assert.Equal(t, "success", successState.LastRefreshStatus) + assert.Equal(t, 0, successState.ConsecutiveFailures) + assert.Nil(t, successState.NextRetryAt) + + failState, err := cacheService.GetState("client-fail") + require.NoError(t, err) + require.NotNil(t, failState) + assert.Equal(t, "failure", failState.LastRefreshStatus) + assert.Equal(t, 3, failState.ConsecutiveFailures) + require.NotNil(t, failState.NextRetryAt) +} + func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose.JSONWebKeySet) { t.Helper() @@ -205,3 +419,31 @@ func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose. func ptrTestTime(value time.Time) *time.Time { return &value } + +func newTestHeadlessClient(clientID, jwksURI string) domain.HydraClient { + return domain.HydraClient{ + ClientID: clientID, + Metadata: map[string]any{ + domain.MetadataHeadlessLoginEnabled: true, + domain.MetadataHeadlessJWKSURI: jwksURI, + domain.MetadataHeadlessTokenEndpointAuthMethod: "private_key_jwt", + }, + } +} + +func jsonHandler(t *testing.T, payload any) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/clients", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(payload)) + } +} + +func jsonHTTPResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} From dcb442b68d428f8a719fe3c06c6bf61139b7e9bf Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 20 May 2026 11:55:51 +0900 Subject: [PATCH 23/25] =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B0=80=EB=93=9C?= =?UTF-8?q?=20=ED=83=80=EC=9D=B4=ED=8B=80=20=EB=B3=B4=EC=A1=B0=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EC=9C=84=EC=B9=98=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/features/auth/AuthPage.tsx | 26 +++++++++-------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/adminfront/src/features/auth/AuthPage.tsx b/adminfront/src/features/auth/AuthPage.tsx index 8985b290..b68f6757 100644 --- a/adminfront/src/features/auth/AuthPage.tsx +++ b/adminfront/src/features/auth/AuthPage.tsx @@ -1,26 +1,20 @@ import { ShieldHalf } from "lucide-react"; +import { PageHeader } from "../../../../common/core/components/page"; import { t } from "../../lib/i18n"; import PermissionChecker from "./components/PermissionChecker"; function AuthPage() { return (
-
-
-

- - - - {t("ui.admin.auth_guard.title", "Auth Guard")} -

-

- {t( - "ui.admin.auth_guard.subtitle", - "Verify admin privileges and ReBAC relationships against the policy engine.", - )} -

-
-
+ } + title={t("ui.admin.auth_guard.title", "Auth Guard")} + description={t( + "ui.admin.auth_guard.subtitle", + "Verify admin privileges and ReBAC relationships against the policy engine.", + )} + />
From b55ab7bc67f13a1cff76d91d0545258741bc04b6 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 20 May 2026 13:21:37 +0900 Subject: [PATCH 24/25] =?UTF-8?q?=EC=95=B1=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EA=B6=8C=ED=95=9C=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientsPage.tsx | 68 ++++++++++++++++--- .../clients/clientCreateAccess.test.ts | 55 +++++++++++++++ .../features/clients/clientCreateAccess.ts | 44 ++++++++++++ devfront/src/locales/en.toml | 3 + devfront/src/locales/ko.toml | 3 + devfront/src/locales/template.toml | 3 + 6 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 devfront/src/features/clients/clientCreateAccess.test.ts create mode 100644 devfront/src/features/clients/clientCreateAccess.ts diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 29b13ab8..27753102 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -60,6 +60,7 @@ import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { cn } from "../../lib/utils"; import { fetchMe } from "../auth/authApi"; +import { resolveClientCreateAccess } from "./clientCreateAccess"; import { ClientLogo } from "./components/ClientLogo"; type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt"; @@ -96,7 +97,8 @@ function ClientsPage() { } = useQuery({ queryKey: ["developer-request", tenantId], queryFn: () => fetchDeveloperRequestStatus(tenantId), - enabled: hasAccessToken && role === "user", + enabled: + hasAccessToken && (role === "user" || role === "tenant_member"), }); const { data: tenants } = useQuery({ queryKey: ["myTenants"], @@ -109,15 +111,14 @@ function ClientsPage() { enabled: hasAccessToken, }); - const canCreateClient = - (role !== "user" && role !== "tenant_member") || - requestStatus?.status === "approved"; - const isDeveloperRequestPending = requestStatus?.status === "pending"; + const createAccessState = resolveClientCreateAccess({ + role, + requestStatus: requestStatus?.status, + }); + const canCreateClient = createAccessState === "can_create"; + const isDeveloperRequestPending = createAccessState === "pending"; const canRequestDeveloperAccess = - role === "user" && - !isLoadingRequest && - !canCreateClient && - !isDeveloperRequestPending; + createAccessState === "request_required" && !isLoadingRequest; const [searchQuery, setSearchQuery] = useState(""); const [typeFilter, setTypeFilter] = useState("all"); @@ -278,7 +279,54 @@ function ClientsPage() { {t("ui.dev.clients.new", "새 클라이언트")} - ) : null + ) : isDeveloperRequestPending ? ( +
+

+ {t( + "msg.dev.clients.create_pending_detail", + "개발자 권한 신청을 검토 중입니다. 승인되면 연동 앱을 추가할 수 있습니다.", + )} +

+ +
+ ) : canRequestDeveloperAccess ? ( +
+

+ {t( + "msg.dev.clients.create_requires_request", + "연동 앱을 생성할 권한이 없습니다.\n개발자 권한 신청을 요청한 뒤 승인 받아주세요.", + ).replaceAll("\\n", "\n")} +

+ +
+ ) : ( +
+

+ {t( + "msg.dev.clients.create_forbidden_detail", + "연동 앱을 생성할 권한이 없습니다. 관리자에게 개발자 권한 또는 적절한 RP 권한 부여를 요청해 주세요.", + )} +

+ +
+ ) } /> diff --git a/devfront/src/features/clients/clientCreateAccess.test.ts b/devfront/src/features/clients/clientCreateAccess.test.ts new file mode 100644 index 00000000..11ebcff8 --- /dev/null +++ b/devfront/src/features/clients/clientCreateAccess.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { resolveClientCreateAccess } from "./clientCreateAccess"; + +describe("client create access", () => { + it("allows privileged roles to create clients without developer request approval", () => { + expect( + resolveClientCreateAccess({ + role: "rp_admin", + }), + ).toBe("can_create"); + }); + + it("requires a developer request for basic users without approval", () => { + expect( + resolveClientCreateAccess({ + role: "user", + requestStatus: "none", + }), + ).toBe("request_required"); + }); + + it("shows pending state while a developer request is under review", () => { + expect( + resolveClientCreateAccess({ + role: "tenant_member", + requestStatus: "pending", + }), + ).toBe("pending"); + }); + + it("allows client creation after developer request approval", () => { + expect( + resolveClientCreateAccess({ + role: "user", + requestStatus: "approved", + }), + ).toBe("can_create"); + }); + + it("routes cancelled or rejected requests back to requestable state", () => { + expect( + resolveClientCreateAccess({ + role: "user", + requestStatus: "cancelled", + }), + ).toBe("request_required"); + + expect( + resolveClientCreateAccess({ + role: "user", + requestStatus: "rejected", + }), + ).toBe("request_required"); + }); +}); diff --git a/devfront/src/features/clients/clientCreateAccess.ts b/devfront/src/features/clients/clientCreateAccess.ts new file mode 100644 index 00000000..150dce0e --- /dev/null +++ b/devfront/src/features/clients/clientCreateAccess.ts @@ -0,0 +1,44 @@ +import type { DeveloperRequestStatus } from "../../lib/devApi"; + +export type ClientCreateAccessState = + | "can_create" + | "pending" + | "request_required" + | "forbidden"; + +type ResolveClientCreateAccessParams = { + role: string; + requestStatus?: DeveloperRequestStatus; +}; + +function canSelfRequestDeveloperAccess(role: string) { + return role === "user" || role === "tenant_member"; +} + +export function resolveClientCreateAccess({ + role, + requestStatus, +}: ResolveClientCreateAccessParams): ClientCreateAccessState { + if (!canSelfRequestDeveloperAccess(role)) { + return "can_create"; + } + + if (requestStatus === "approved") { + return "can_create"; + } + + if (requestStatus === "pending") { + return "pending"; + } + + if ( + requestStatus === "none" || + requestStatus === "rejected" || + requestStatus === "cancelled" || + typeof requestStatus === "undefined" + ) { + return "request_required"; + } + + return "forbidden"; +} diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index f7317f0e..79f5d67f 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -341,6 +341,9 @@ empty = "No RPs are available." empty_detail = "RPs will appear here when a relationship is assigned to your account." empty_can_create = "No linked apps have been registered yet." empty_can_create_detail = "Create a new RP with the Add linked app button, and it will appear here." +create_requires_request = "You do not have permission to create applications.\nSubmit a developer access request and wait for approval." +create_pending_detail = "Your developer access request is under review. You will be able to add applications after approval." +create_forbidden_detail = "You do not have permission to create applications. Ask an administrator to grant developer access or the appropriate RP permissions." empty_filtered = "No linked apps match the current filters." empty_filtered_detail = "Try changing the search text or filters." empty_pending = "Your developer access request is under review." diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 3ce007e4..accd5310 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -338,6 +338,9 @@ empty = "조회 가능한 RP가 없습니다." empty_detail = "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다." empty_can_create = "아직 등록된 연동 앱이 없습니다." empty_can_create_detail = "연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다." +create_requires_request = "연동 앱을 생성할 권한이 없습니다.\n개발자 권한 신청을 요청한 뒤 승인 받아주세요." +create_pending_detail = "개발자 권한 신청을 검토 중입니다. 승인되면 연동 앱을 추가할 수 있습니다." +create_forbidden_detail = "연동 앱을 생성할 권한이 없습니다. 관리자에게 개발자 권한 또는 적절한 RP 권한 부여를 요청해 주세요." empty_filtered = "조건에 맞는 연동 앱이 없습니다." empty_filtered_detail = "검색어나 필터 조건을 변경해 보세요." empty_pending = "개발자 권한 신청을 검토 중입니다." diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index d144c050..a0dbec89 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -379,6 +379,9 @@ empty = "" empty_detail = "" empty_can_create = "" empty_can_create_detail = "" +create_requires_request = "" +create_pending_detail = "" +create_forbidden_detail = "" empty_filtered = "" empty_filtered_detail = "" empty_pending = "" From 0af268021ece5d159309ac9c75bcc6a4537bb2ec Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 20 May 2026 17:12:48 +0900 Subject: [PATCH 25/25] =?UTF-8?q?code-check=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ParentTenantSelector.tsx | 193 +++++++++--------- .../tenants/routes/TenantCreatePage.tsx | 37 +++- .../src/features/users/UserCreatePage.tsx | 24 +++ .../src/features/users/userStatus.test.ts | 5 + adminfront/src/features/users/userStatus.ts | 4 +- adminfront/tests/bulk_actions.spec.ts | 4 +- adminfront/tests/tenants.spec.ts | 74 ++----- adminfront/tests/users.spec.ts | 60 +----- adminfront/tests/worksmobile.spec.ts | 18 +- .../handler/auth_handler_login_test.go | 15 ++ locales/en.toml | 34 +++ locales/ko.toml | 31 +++ locales/template.toml | 31 +++ scripts/run_adminfront_ci_tests.sh | 52 ++++- .../tests/login-performance-budget.spec.ts | 18 +- userfront/assets/translations/en.toml | 10 + userfront/assets/translations/ko.toml | 10 + userfront/assets/translations/template.toml | 10 + .../auth/presentation/login_screen.dart | 49 +++-- userfront/pubspec.lock | 32 +-- 20 files changed, 442 insertions(+), 269 deletions(-) diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index a8cc3199..4f73f0ed 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -7,6 +7,7 @@ import { DialogContent, DialogDescription, DialogHeader, + DialogTrigger, DialogTitle, } from "../../../components/ui/dialog"; import { Label } from "../../../components/ui/label"; @@ -87,27 +88,100 @@ export function ParentTenantSelector({
- + + + + + + + + {t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")} + + + {t( + "msg.admin.tenants.parent.picker_description", + "org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.", + )} + + +