forked from baron/baron-sso
Merge branch 'dev' into feature/issue-917-sub-email-support
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"extends": ["../common/config/biome.base.json"],
|
||||
"files": {
|
||||
"ignore": [".vite"]
|
||||
}
|
||||
"root": true,
|
||||
"extends": ["../common/config/biome.base.json"]
|
||||
}
|
||||
|
||||
577
adminfront/package-lock.json
generated
577
adminfront/package-lock.json
generated
@@ -32,6 +32,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -41,13 +42,15 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"playwright": "1.60.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.12",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"engines": {
|
||||
@@ -130,14 +133,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -145,17 +148,42 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
@@ -166,6 +194,205 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz",
|
||||
"integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
"biome": "bin/biome"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.16",
|
||||
"@biomejs/cli-darwin-x64": "2.4.16",
|
||||
"@biomejs/cli-linux-arm64": "2.4.16",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.16",
|
||||
"@biomejs/cli-linux-x64": "2.4.16",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.16",
|
||||
"@biomejs/cli-win32-arm64": "2.4.16",
|
||||
"@biomejs/cli-win32-x64": "2.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz",
|
||||
"integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz",
|
||||
"integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz",
|
||||
"integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz",
|
||||
"integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz",
|
||||
"integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz",
|
||||
"integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz",
|
||||
"integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz",
|
||||
"integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
@@ -506,9 +733,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.130.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
|
||||
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
|
||||
"version": "0.132.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
|
||||
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2002,9 +2229,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2019,9 +2246,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2036,9 +2263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2053,9 +2280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2070,9 +2297,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
|
||||
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
|
||||
"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2087,13 +2314,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2104,13 +2334,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2121,13 +2354,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2138,13 +2374,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2155,13 +2394,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2172,13 +2414,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2189,9 +2434,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2206,9 +2451,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
|
||||
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
|
||||
"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -2225,9 +2470,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2242,9 +2487,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2572,6 +2817,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
|
||||
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.2",
|
||||
"obug": "^2.1.1",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.6",
|
||||
"vitest": "4.1.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||
@@ -2782,6 +3058,25 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz",
|
||||
"integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -3541,6 +3836,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -3593,6 +3898,13 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -3709,6 +4021,45 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -4122,6 +4473,34 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
|
||||
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@babel/types": "^7.29.0",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -4390,9 +4769,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": [
|
||||
{
|
||||
@@ -4410,7 +4789,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -4854,13 +5233,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
||||
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.130.0",
|
||||
"@oxc-project/types": "=0.132.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4870,21 +5249,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"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"
|
||||
"@rolldown/binding-android-arm64": "1.0.2",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.2",
|
||||
"@rolldown/binding-darwin-x64": "1.0.2",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.2",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.2",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.2",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.2",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.2",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.2",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
@@ -4930,6 +5309,19 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
@@ -5003,6 +5395,19 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -5373,16 +5778,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.13",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||
"version": "8.0.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
||||
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.14",
|
||||
"rolldown": "1.0.1",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.2",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"format": "biome format . --write",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "vitest run",
|
||||
"test:ui": "playwright test --ui",
|
||||
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
|
||||
@@ -43,6 +44,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -52,8 +54,10 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"playwright": "1.60.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
247
adminfront/pnpm-lock.yaml
generated
247
adminfront/pnpm-lock.yaml
generated
@@ -75,6 +75,9 @@ importers:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.4.16
|
||||
version: 2.4.16
|
||||
'@playwright/test':
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
@@ -102,12 +105,18 @@ importers:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.2(vite@8.0.14(@types/node@25.8.0)(jiti@1.21.7))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.1.6
|
||||
version: 4.1.6(vitest@4.1.6)
|
||||
autoprefixer:
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0(postcss@8.5.14)
|
||||
jsdom:
|
||||
specifier: ^28.1.0
|
||||
version: 28.1.0
|
||||
playwright:
|
||||
specifier: 1.60.0
|
||||
version: 1.60.0
|
||||
postcss:
|
||||
specifier: ^8.5.14
|
||||
version: 8.5.14
|
||||
@@ -125,7 +134,7 @@ importers:
|
||||
version: 8.0.14(@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.14(@types/node@25.8.0)(jiti@1.21.7))
|
||||
version: 4.1.6(@types/node@25.8.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.8.0)(jiti@1.21.7))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -157,14 +166,92 @@ packages:
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/runtime@7.29.2':
|
||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@biomejs/biome@2.4.16':
|
||||
resolution: {integrity: sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.4.16':
|
||||
resolution: {integrity: sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.4.16':
|
||||
resolution: {integrity: sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.16':
|
||||
resolution: {integrity: sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.4.16':
|
||||
resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.4.16':
|
||||
resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.4.16':
|
||||
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.4.16':
|
||||
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/cli-win32-x64@2.4.16':
|
||||
resolution: {integrity: sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||
hasBin: true
|
||||
@@ -877,6 +964,15 @@ packages:
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
|
||||
'@vitest/coverage-v8@4.1.6':
|
||||
resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.1.6
|
||||
vitest: 4.1.6
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.1.6':
|
||||
resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==}
|
||||
|
||||
@@ -947,6 +1043,9 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@1.0.2:
|
||||
resolution: {integrity: sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
@@ -1198,6 +1297,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1214,6 +1317,9 @@ packages:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -1253,10 +1359,25 @@ packages:
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
jiti@1.21.7:
|
||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||
hasBin: true
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -1370,6 +1491,13 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magicast@0.5.3:
|
||||
resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1645,6 +1773,11 @@ packages:
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
semver@7.8.1:
|
||||
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
@@ -1670,6 +1803,10 @@ packages:
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
hasBin: true
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1932,10 +2069,60 @@ snapshots:
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/runtime@7.29.2': {}
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@biomejs/biome@2.4.16':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.4.16
|
||||
'@biomejs/cli-darwin-x64': 2.4.16
|
||||
'@biomejs/cli-linux-arm64': 2.4.16
|
||||
'@biomejs/cli-linux-arm64-musl': 2.4.16
|
||||
'@biomejs/cli-linux-x64': 2.4.16
|
||||
'@biomejs/cli-linux-x64-musl': 2.4.16
|
||||
'@biomejs/cli-win32-arm64': 2.4.16
|
||||
'@biomejs/cli-win32-x64': 2.4.16
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.4.16':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.4.16':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.16':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.4.16':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.4.16':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64@2.4.16':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.4.16':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-x64@2.4.16':
|
||||
optional: true
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
dependencies:
|
||||
css-tree: 3.2.1
|
||||
@@ -2576,6 +2763,20 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.0.14(@types/node@25.8.0)(jiti@1.21.7)
|
||||
|
||||
'@vitest/coverage-v8@4.1.6(vitest@4.1.6)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.6
|
||||
ast-v8-to-istanbul: 1.0.2
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.3
|
||||
obug: 2.1.1
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.6(@types/node@25.8.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.8.0)(jiti@1.21.7))
|
||||
|
||||
'@vitest/expect@4.1.6':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -2650,6 +2851,12 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-v8-to-istanbul@1.0.2:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
autoprefixer@10.5.0(postcss@8.5.14):
|
||||
@@ -2882,6 +3089,8 @@ snapshots:
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
@@ -2898,6 +3107,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -2939,8 +3150,23 @@ snapshots:
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
dependencies:
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
jiti@1.21.7: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
jsdom@28.1.0:
|
||||
@@ -3037,6 +3263,16 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.5.3:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.8.1
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdn-data@2.27.1: {}
|
||||
@@ -3275,6 +3511,8 @@ snapshots:
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
semver@7.8.1: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
@@ -3299,6 +3537,10 @@ snapshots:
|
||||
tinyglobby: 0.2.16
|
||||
ts-interface-checker: 0.1.13
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
@@ -3423,7 +3665,7 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
|
||||
vitest@4.1.6(@types/node@25.8.0)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.8.0)(jiti@1.21.7)):
|
||||
vitest@4.1.6(@types/node@25.8.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.8.0)(jiti@1.21.7)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.6
|
||||
'@vitest/mocker': 4.1.6(vite@8.0.14(@types/node@25.8.0)(jiti@1.21.7))
|
||||
@@ -3447,6 +3689,7 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.8.0
|
||||
'@vitest/coverage-v8': 4.1.6(vitest@4.1.6)
|
||||
jsdom: 28.1.0
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
allowBuilds:
|
||||
'@biomejs/biome': true
|
||||
esbuild: false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -24,7 +24,9 @@ const contentTypes = {
|
||||
};
|
||||
|
||||
function getContentType(filePath) {
|
||||
return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
||||
return (
|
||||
contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, body) {
|
||||
@@ -132,7 +134,10 @@ async function serveStatic(req, res, pathname) {
|
||||
|
||||
createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
const url = new URL(
|
||||
req.url ?? "/",
|
||||
`http://${req.headers.host ?? "localhost"}`,
|
||||
);
|
||||
const { pathname, search } = url;
|
||||
|
||||
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
||||
@@ -149,5 +154,7 @@ createServer(async (req, res) => {
|
||||
});
|
||||
}
|
||||
}).listen(port, host, () => {
|
||||
console.log(`Adminfront production server listening on http://${host}:${port}`);
|
||||
console.log(
|
||||
`Adminfront production server listening on http://${host}:${port}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import AppLayout from "../components/layout/AppLayout";
|
||||
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
|
||||
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||
|
||||
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
@@ -22,13 +22,13 @@ import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AppSidebar,
|
||||
type ShellSidebarNavItem,
|
||||
type ShellTranslator,
|
||||
applyShellTheme,
|
||||
buildShellProfileSummary,
|
||||
buildShellSessionStatus,
|
||||
readShellSessionExpiryEnabled,
|
||||
readShellTheme,
|
||||
type ShellSidebarNavItem,
|
||||
type ShellTranslator,
|
||||
shellLayoutClasses,
|
||||
writeShellSessionExpiryEnabled,
|
||||
} from "../../../../common/shell";
|
||||
@@ -310,13 +310,16 @@ function AppLayout() {
|
||||
window.addEventListener(DEV_ROLE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||
window.removeEventListener(
|
||||
LOCALE_CHANGED_EVENT,
|
||||
rerenderDevelopmentShell,
|
||||
);
|
||||
window.removeEventListener(
|
||||
DEV_ROLE_CHANGED_EVENT,
|
||||
rerenderDevelopmentShell,
|
||||
);
|
||||
};
|
||||
}, [isDevelopmentRuntime]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -429,7 +432,6 @@ function AppLayout() {
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isDevelopmentRuntime,
|
||||
isSessionExpiryEnabled,
|
||||
]);
|
||||
|
||||
@@ -668,7 +670,10 @@ function AppLayout() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("ui.shell.session.auto_extend", "세션 만료 관리")}
|
||||
{t(
|
||||
"ui.shell.session.auto_extend",
|
||||
"세션 만료 관리",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled ? (
|
||||
@@ -677,7 +682,10 @@ function AppLayout() {
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
t("ui.shell.session.disabled", "세션 만료 비활성화")
|
||||
t(
|
||||
"ui.shell.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -44,4 +44,4 @@ const AvatarFallback = React.forwardRef<
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
export { Avatar, AvatarFallback, AvatarImage };
|
||||
|
||||
@@ -50,9 +50,9 @@ function CardFooter({
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
};
|
||||
|
||||
@@ -144,18 +144,20 @@ const DialogClose = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
DialogClose.displayName = "DialogClose";
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
>(({ className, onMouseDown, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogOverlay");
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 border-0 bg-black/80 p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
data-state="open"
|
||||
aria-label="Close dialog"
|
||||
onMouseDown={composeEventHandlers(onMouseDown, (event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setOpen(false);
|
||||
@@ -273,13 +275,13 @@ DialogDescription.displayName = "DialogDescription";
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
@@ -183,18 +183,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -146,13 +146,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
||||
@@ -92,11 +92,11 @@ TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
|
||||
@@ -84,4 +84,4 @@ const TabsContent = React.forwardRef<
|
||||
});
|
||||
TabsContent.displayName = "TabsContent";
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
|
||||
@@ -31,6 +31,8 @@ describe("AuthPage", () => {
|
||||
|
||||
expect(screen.getByText("Auth Guard")).toBeInTheDocument();
|
||||
expect(screen.getByText("ReBAC permission checker")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Check permission" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Check permission" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,9 @@ describe("LoginPage", () => {
|
||||
it("shows an actionable error instead of starting PKCE when WebCrypto is unavailable", async () => {
|
||||
renderLoginPage("/login?returnTo=%2F");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /SSO 계정으로 로그인/i }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
|
||||
);
|
||||
|
||||
expect(mockSigninRedirect).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
@@ -61,7 +63,9 @@ describe("LoginPage", () => {
|
||||
});
|
||||
renderLoginPage("/login?returnTo=%2Fusers%3Fpage%3D2");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /SSO 계정으로 로그인/i }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
|
||||
);
|
||||
|
||||
expect(mockSigninRedirect).toHaveBeenCalledWith({
|
||||
state: {
|
||||
|
||||
@@ -48,10 +48,7 @@ function PermissionChecker() {
|
||||
<Card className="border-primary/20 bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.title",
|
||||
"ReBAC permission checker",
|
||||
)}
|
||||
{t("ui.admin.auth_guard.checker.title", "ReBAC permission checker")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
@@ -92,7 +89,9 @@ function PermissionChecker() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("ui.admin.auth_guard.checker.relation", "Relation")}</Label>
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.relation", "Relation")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.relation_placeholder",
|
||||
@@ -103,7 +102,9 @@ function PermissionChecker() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("ui.admin.auth_guard.checker.object_id", "Object ID")}</Label>
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.object_id", "Object ID")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.object_id_placeholder",
|
||||
@@ -115,10 +116,7 @@ function PermissionChecker() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.subject",
|
||||
"Subject (User:ID)",
|
||||
)}
|
||||
{t("ui.admin.auth_guard.checker.subject", "Subject (User:ID)")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
@@ -155,10 +153,7 @@ function PermissionChecker() {
|
||||
<>
|
||||
<CheckCircle2 size={48} />
|
||||
<div className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.allowed",
|
||||
"Access ALLOWED",
|
||||
)}
|
||||
{t("ui.admin.auth_guard.checker.allowed", "Access ALLOWED")}
|
||||
</div>
|
||||
<p className="text-center text-sm opacity-80">
|
||||
{t(
|
||||
@@ -171,10 +166,7 @@ function PermissionChecker() {
|
||||
<>
|
||||
<XCircle size={48} />
|
||||
<div className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.denied",
|
||||
"Access DENIED",
|
||||
)}
|
||||
{t("ui.admin.auth_guard.checker.denied", "Access DENIED")}
|
||||
</div>
|
||||
<p className="text-center text-sm opacity-80">
|
||||
{t(
|
||||
|
||||
@@ -175,16 +175,16 @@ describe("DataIntegrityPage", () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Data Integrity Check"),
|
||||
).toBeInTheDocument();
|
||||
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(
|
||||
await screen.findByText("Duplicate tenant slug"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",
|
||||
|
||||
@@ -12,10 +12,10 @@ import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
type DataIntegrityCheck,
|
||||
type DataIntegrityStatus,
|
||||
type OrphanUserLoginID,
|
||||
deleteOrphanUserLoginIDs,
|
||||
fetchDataIntegrityReport,
|
||||
fetchOrphanUserLoginIDs,
|
||||
type OrphanUserLoginID,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { getAdminDateLocale } from "../../lib/locale";
|
||||
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
type DataIntegrityStatus,
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
type TenantSummary,
|
||||
fetchAdminOverviewStats,
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchAllTenants,
|
||||
fetchDataIntegrityReport,
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
type TenantSummary,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
@@ -203,10 +203,7 @@ function IntegrityOverviewSummary() {
|
||||
<AlertTriangle size={18} className="text-amber-600" />
|
||||
)}
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.summary.title",
|
||||
"정합성 최종 검증",
|
||||
)}
|
||||
{t("ui.admin.integrity.summary.title", "정합성 최종 검증")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
@@ -466,7 +463,7 @@ function GlobalOverviewPage() {
|
||||
const metric = (value: number | undefined) =>
|
||||
value === undefined ? "-" : value.toLocaleString();
|
||||
const periodControls = (
|
||||
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||
<fieldset className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||
{[
|
||||
["day", t("ui.common.chart.period.day", "일")],
|
||||
["week", t("ui.common.chart.period.week", "주")],
|
||||
@@ -486,7 +483,7 @@ function GlobalOverviewPage() {
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
const chartFilters = (
|
||||
<div>
|
||||
|
||||
@@ -61,17 +61,13 @@ describe("UserProjectionPage", () => {
|
||||
it("renders projection status for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("사용자 동기화 관리"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Kratos 사용자 동기화"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
|
||||
expect(screen.getByText("준비됨")).toBeInTheDocument();
|
||||
expect(screen.getByText("152")).toBeInTheDocument();
|
||||
expect(fetchUserProjectionStatus).toHaveBeenCalled();
|
||||
@@ -100,9 +96,7 @@ describe("UserProjectionPage", () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("사용자 동기화 관리"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("사용자 동기화 관리")).not.toBeInTheDocument();
|
||||
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -133,10 +133,7 @@ function UserProjectionContent() {
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
{t(
|
||||
"ui.admin.user_projection.actions.reset",
|
||||
"Reset and rebuild",
|
||||
)}
|
||||
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -230,10 +227,7 @@ function UserProjectionContent() {
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.user_projection.summary.updated_at",
|
||||
"Updated at",
|
||||
)}
|
||||
{t("ui.admin.user_projection.summary.updated_at", "Updated at")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.updatedAt)}
|
||||
@@ -258,22 +252,19 @@ export default function UserProjectionPage() {
|
||||
<RoleGuard
|
||||
roles={["super_admin"]}
|
||||
fallback={
|
||||
<main className="p-6 md:p-8">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t(
|
||||
"ui.admin.user_projection.forbidden.title",
|
||||
"Access denied",
|
||||
)}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.user_projection.forbidden.description",
|
||||
"This screen is only available to super_admin users.",
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<main className="p-6 md:p-8">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("ui.admin.user_projection.forbidden.title", "Access denied")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.user_projection.forbidden.description",
|
||||
"This screen is only available to super_admin users.",
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<UserProjectionContent />
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type TenantAdmin,
|
||||
addTenantAdmin,
|
||||
addTenantOwner,
|
||||
fetchTenantAdmins,
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
fetchUsers,
|
||||
removeTenantAdmin,
|
||||
removeTenantOwner,
|
||||
type TenantAdmin,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
@@ -69,15 +69,14 @@ export function TenantAdminsAndOwnersTab() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||
const tenantId = tenantIdParam ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
|
||||
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
const ownersQuery = useQuery({
|
||||
queryKey: ["tenant-owners", tenantId],
|
||||
queryFn: () => fetchTenantOwners(tenantId),
|
||||
@@ -339,6 +338,8 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
const serverOwners = ownersQuery.data || [];
|
||||
const serverAdmins = adminsQuery.data || [];
|
||||
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
||||
|
||||
@@ -19,15 +19,15 @@ import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||
import {
|
||||
type ServerDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
type ServerDomainConflict,
|
||||
} from "../utils/domainTags";
|
||||
import {
|
||||
mergeTenantOrgConfig,
|
||||
ORG_UNIT_TYPE_OPTIONS,
|
||||
shouldAllowHanmacOrgConfig,
|
||||
TENANT_VISIBILITY_OPTIONS,
|
||||
type TenantVisibility,
|
||||
mergeTenantOrgConfig,
|
||||
shouldAllowHanmacOrgConfig,
|
||||
} from "../utils/orgConfig";
|
||||
|
||||
type AdminFrontTestHooks = {
|
||||
|
||||
@@ -53,13 +53,13 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type GroupSummary,
|
||||
addGroupMember,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
fetchGroups,
|
||||
fetchTenant,
|
||||
fetchUsers,
|
||||
type GroupSummary,
|
||||
removeGroupMember,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../../common/core/components/page";
|
||||
import {
|
||||
SortableTableHead,
|
||||
sortableTableHeadBaseClassName,
|
||||
@@ -38,7 +39,6 @@ import {
|
||||
sortItems,
|
||||
toggleSort,
|
||||
} from "../../../../../common/core/utils";
|
||||
import { PageHeader } from "../../../../../common/core/components/page";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableShellClass,
|
||||
@@ -92,18 +92,18 @@ import {
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import type { UserProfileResponse } from "../../../lib/adminApi";
|
||||
import {
|
||||
type TenantSummary,
|
||||
deleteTenant,
|
||||
deleteTenantsBulk,
|
||||
exportTenantsCSV,
|
||||
fetchMe,
|
||||
fetchTenants,
|
||||
importTenantsCSV,
|
||||
type TenantSummary,
|
||||
updateTenant,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
@@ -112,20 +112,20 @@ import {
|
||||
} from "../../users/orgChartPicker";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
import {
|
||||
type TenantImportPreviewRow,
|
||||
type TenantImportResolution,
|
||||
buildTenantImportParentOptionGroups,
|
||||
buildTenantImportPreview,
|
||||
inferTenantImportRootParentSlug,
|
||||
parseTenantCSV,
|
||||
serializeTenantImportCSV,
|
||||
type TenantImportPreviewRow,
|
||||
type TenantImportResolution,
|
||||
} from "../utils/tenantCsvImport";
|
||||
import {
|
||||
type TenantViewMode,
|
||||
type TenantViewRow,
|
||||
filterTenantsByScope,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
type TenantViewMode,
|
||||
type TenantViewRow,
|
||||
tenantMatchesListSearch,
|
||||
} from "./tenantListView";
|
||||
|
||||
@@ -453,30 +453,6 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
profile &&
|
||||
profileRole !== "super_admin" &&
|
||||
profileRole !== "tenant_admin"
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<h3 className="text-lg font-bold">
|
||||
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
|
||||
</h3>
|
||||
<Button onClick={() => navigate("/")}>
|
||||
{t("ui.common.go_home", "홈으로 이동")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
profileRole === "tenant_admin" &&
|
||||
(profile?.manageableTenants?.length ?? 0) <= 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
@@ -574,6 +550,30 @@ function TenantListPage() {
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [allTenants, scopePickerOpen]);
|
||||
|
||||
if (
|
||||
profile &&
|
||||
profileRole !== "super_admin" &&
|
||||
profileRole !== "tenant_admin"
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<h3 className="text-lg font-bold">
|
||||
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
|
||||
</h3>
|
||||
<Button onClick={() => navigate("/")}>
|
||||
{t("ui.common.go_home", "홈으로 이동")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
profileRole === "tenant_admin" &&
|
||||
(profile?.manageableTenants?.length ?? 0) <= 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(deletableTenants.map((t) => t.id));
|
||||
|
||||
@@ -26,34 +26,30 @@ import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||
import {
|
||||
type ServerDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
type ServerDomainConflict,
|
||||
} from "../utils/domainTags";
|
||||
import {
|
||||
ORG_UNIT_TYPE_OPTIONS,
|
||||
TENANT_VISIBILITY_OPTIONS,
|
||||
type TenantVisibility,
|
||||
mergeTenantOrgConfig,
|
||||
ORG_UNIT_TYPE_OPTIONS,
|
||||
readTenantOrgConfig,
|
||||
removeTenantOrgConfig,
|
||||
shouldAllowHanmacOrgConfig,
|
||||
TENANT_VISIBILITY_OPTIONS,
|
||||
type TenantVisibility,
|
||||
} from "../utils/orgConfig";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
|
||||
export function TenantProfilePage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||
const tenantId = tenantIdParam ?? "";
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (!tenantId) {
|
||||
return (
|
||||
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const parentQuery = useQuery({
|
||||
@@ -197,6 +193,12 @@ export function TenantProfilePage() {
|
||||
? isSeedTenant(tenantQuery.data)
|
||||
: false;
|
||||
|
||||
if (!tenantId) {
|
||||
return (
|
||||
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (isProtectedSeedTenant) {
|
||||
return;
|
||||
|
||||
@@ -18,10 +18,10 @@ import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import {
|
||||
type SchemaField,
|
||||
createSchemaField,
|
||||
isSchemaFieldType,
|
||||
normalizeSchemaField,
|
||||
type SchemaField,
|
||||
} from "./tenantSchemaFields";
|
||||
|
||||
export function TenantSchemaPage() {
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type WorksmobileComparisonItem,
|
||||
downloadWorksmobileInitialPasswordsCSV,
|
||||
enqueueWorksmobileBackfillDryRun,
|
||||
enqueueWorksmobileOrgUnitDelete,
|
||||
@@ -47,13 +46,10 @@ import {
|
||||
fetchWorksmobileComparison,
|
||||
fetchWorksmobileOverview,
|
||||
retryWorksmobileJob,
|
||||
type WorksmobileComparisonItem,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
type WorksmobileComparisonColumnKey,
|
||||
type WorksmobileComparisonColumnVisibility,
|
||||
type WorksmobileComparisonFilter,
|
||||
type WorksmobileComparisonSummary,
|
||||
buildWorksmobilePasswordManageUrl,
|
||||
canOpenWorksmobilePasswordManage,
|
||||
canSelectWorksmobileRow,
|
||||
@@ -71,6 +67,10 @@ import {
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
summarizeWorksmobileComparison,
|
||||
type WorksmobileComparisonColumnKey,
|
||||
type WorksmobileComparisonColumnVisibility,
|
||||
type WorksmobileComparisonFilter,
|
||||
type WorksmobileComparisonSummary,
|
||||
} from "./worksmobileComparison";
|
||||
|
||||
export function TenantWorksmobilePage() {
|
||||
@@ -1196,13 +1196,7 @@ function ComparisonTable({
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonDomainCell({
|
||||
name,
|
||||
id,
|
||||
}: {
|
||||
name?: string;
|
||||
id?: number;
|
||||
}) {
|
||||
function ComparisonDomainCell({ name, id }: { name?: string; id?: number }) {
|
||||
if (!name && !id) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
|
||||
|
||||
export type TenantViewMode = "tree" | "table";
|
||||
export type TenantViewRow = TenantNode & { depth: number };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
ORG_UNIT_TYPE_OPTIONS,
|
||||
mergeTenantOrgConfig,
|
||||
ORG_UNIT_TYPE_OPTIONS,
|
||||
readTenantOrgConfig,
|
||||
shouldAllowHanmacOrgConfig,
|
||||
} from "./orgConfig";
|
||||
|
||||
@@ -150,7 +150,6 @@ describe("tenantCsvImport", () => {
|
||||
expect(csv).not.toContain("local-tenant-id");
|
||||
});
|
||||
|
||||
|
||||
it("preserves source tenant_id when a create resolution does not override it", () => {
|
||||
const exportedTenantId = "11111111-2222-4333-8444-555555555555";
|
||||
const rows = parseTenantCSV(
|
||||
|
||||
@@ -403,7 +403,6 @@ function createTenantImportId() {
|
||||
.padEnd(12, "0")}`;
|
||||
}
|
||||
|
||||
|
||||
function isUUIDLikeTenantId(value: string) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
value,
|
||||
@@ -596,7 +595,7 @@ function slugify(value: string) {
|
||||
지원: "support",
|
||||
};
|
||||
|
||||
let result = value.trim();
|
||||
const result = value.trim();
|
||||
|
||||
// 1. 전체 매칭 확인
|
||||
if (commonMappings[result]) {
|
||||
|
||||
@@ -21,9 +21,9 @@ import {
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
type TenantSummary,
|
||||
fetchAllTenants,
|
||||
fetchGroups,
|
||||
type TenantSummary,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export default function GlobalUserGroupListPage() {
|
||||
|
||||
@@ -70,17 +70,17 @@ import {
|
||||
} from "../../../components/ui/tabs";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
createUser,
|
||||
exportTenantsCSV,
|
||||
fetchAllTenants,
|
||||
fetchUsers,
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
updateTenant,
|
||||
updateUser,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
|
||||
|
||||
// --- Icons & Helpers ---
|
||||
const getTenantIcon = (type?: string) => {
|
||||
@@ -482,8 +482,10 @@ function TenantUserGroupsTab() {
|
||||
mutationFn: ({
|
||||
id,
|
||||
parentId,
|
||||
}: { id: string; parentId: string | undefined }) =>
|
||||
updateTenant(id, { parentId: parentId || "" }),
|
||||
}: {
|
||||
id: string;
|
||||
parentId: string | undefined;
|
||||
}) => updateTenant(id, { parentId: parentId || "" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
||||
toast.success(
|
||||
|
||||
@@ -574,9 +574,9 @@ export function UserGroupDetailPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupRoles.map((role, idx) => (
|
||||
groupRoles.map((role) => (
|
||||
<TableRow
|
||||
key={`${role.tenantId}-${role.relation}-${idx}`}
|
||||
key={`${role.tenantId}-${role.relation}`}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
|
||||
@@ -40,21 +40,21 @@ import {
|
||||
TabsTrigger,
|
||||
} from "../../components/ui/tabs";
|
||||
import {
|
||||
type TenantSummary,
|
||||
type UserAppointment,
|
||||
type UserCreateRequest,
|
||||
type UserCreateResponse,
|
||||
createUser,
|
||||
fetchAllTenants,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
type TenantSummary,
|
||||
type UserAppointment,
|
||||
type UserCreateRequest,
|
||||
type UserCreateResponse,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
type OrgChartTenantSelection,
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
type OrgChartTenantSelection,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
@@ -111,7 +111,10 @@ function createEmptyAppointment(): AppointmentDraft {
|
||||
tenantId: "",
|
||||
tenantName: "",
|
||||
tenantSlug: "",
|
||||
isPrimary: false,
|
||||
isOwner: false,
|
||||
isAdmin: false,
|
||||
isManager: false,
|
||||
grade: "",
|
||||
jobTitle: "",
|
||||
position: "",
|
||||
@@ -347,8 +350,8 @@ function UserCreatePage() {
|
||||
if (currentIndex === index) {
|
||||
return { ...appointment, ...patch };
|
||||
}
|
||||
if (patch.isOwner === true) {
|
||||
return { ...appointment, isOwner: false };
|
||||
if (patch.isPrimary === true) {
|
||||
return { ...appointment, isPrimary: false };
|
||||
}
|
||||
return appointment;
|
||||
}),
|
||||
@@ -463,8 +466,10 @@ function UserCreatePage() {
|
||||
tenantId: appointment.tenantId,
|
||||
tenantSlug: appointment.tenantSlug,
|
||||
tenantName: appointment.tenantName,
|
||||
isPrimary: appointment.isOwner,
|
||||
isOwner: appointment.isOwner,
|
||||
isPrimary: appointment.isPrimary === true,
|
||||
...(appointment.isOwner === true ? { isOwner: true } : {}),
|
||||
...(appointment.isAdmin === true ? { isAdmin: true } : {}),
|
||||
...(appointment.isManager === true ? { isManager: true } : {}),
|
||||
grade: appointment.grade,
|
||||
jobTitle: appointment.jobTitle,
|
||||
position: appointment.position,
|
||||
@@ -480,12 +485,11 @@ function UserCreatePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const primary = appointments.find((a) => a.isOwner);
|
||||
const primary = appointments.find((a) => a.isPrimary);
|
||||
if (primary) {
|
||||
metadata.primaryTenantId = primary.tenantId;
|
||||
metadata.primaryTenantSlug = primary.tenantSlug;
|
||||
metadata.primaryTenantName = primary.tenantName;
|
||||
metadata.primaryTenantIsOwner = true;
|
||||
}
|
||||
|
||||
payload.additionalAppointments = appointments;
|
||||
@@ -916,10 +920,10 @@ function UserCreatePage() {
|
||||
)}
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Switch
|
||||
checked={appointment.isOwner}
|
||||
checked={appointment.isPrimary === true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isOwner: checked === true,
|
||||
isPrimary: checked === true,
|
||||
})
|
||||
}
|
||||
aria-label={t(
|
||||
@@ -932,6 +936,24 @@ function UserCreatePage() {
|
||||
"대표 조직",
|
||||
)}
|
||||
</label>
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Switch
|
||||
checked={appointment.isManager === true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isManager: checked === true,
|
||||
})
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.users.detail.form.appointment_manager",
|
||||
"조직장",
|
||||
)}
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.appointment_manager",
|
||||
"조직장",
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -60,10 +60,8 @@ import {
|
||||
TabsTrigger,
|
||||
} from "../../components/ui/tabs";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||
import {
|
||||
type TenantSummary,
|
||||
type UserAppointment,
|
||||
type UserUpdateRequest,
|
||||
deleteUser,
|
||||
fetchAllTenants,
|
||||
fetchMe,
|
||||
@@ -71,18 +69,20 @@ import {
|
||||
fetchTenant,
|
||||
fetchUser,
|
||||
fetchUserRpHistory,
|
||||
type TenantSummary,
|
||||
type UserAppointment,
|
||||
type UserUpdateRequest,
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../lib/roles";
|
||||
import { generateSecurePassword } from "../../lib/utils";
|
||||
import {
|
||||
type OrgChartTenantSelection,
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
isHanmacFamilyTenant,
|
||||
isHanmacFamilyUser,
|
||||
type OrgChartTenantSelection,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
@@ -142,6 +142,8 @@ function createEmptyAppointment(): AppointmentDraft {
|
||||
tenantSlug: "",
|
||||
isPrimary: false,
|
||||
isOwner: false,
|
||||
isAdmin: false,
|
||||
isManager: false,
|
||||
grade: "",
|
||||
jobTitle: "",
|
||||
position: "",
|
||||
@@ -579,8 +581,8 @@ function UserDetailPage() {
|
||||
if (currentIndex === index) {
|
||||
return { ...appointment, ...patch };
|
||||
}
|
||||
if (patch.isOwner === true) {
|
||||
return { ...appointment, isOwner: false };
|
||||
if (patch.isPrimary === true) {
|
||||
return { ...appointment, isPrimary: false };
|
||||
}
|
||||
return appointment;
|
||||
}),
|
||||
@@ -701,6 +703,9 @@ function UserDetailPage() {
|
||||
isPrimary:
|
||||
appointment.isPrimary === true ||
|
||||
appointment.tenantId === primaryFromMetadata?.id,
|
||||
isOwner: appointment.isOwner === true,
|
||||
isAdmin: appointment.isAdmin === true,
|
||||
isManager: appointment.isManager === true,
|
||||
draftId: createDraftId(),
|
||||
}))
|
||||
: isUserHanmacFamily
|
||||
@@ -714,6 +719,8 @@ function UserDetailPage() {
|
||||
isOwner:
|
||||
metadata.primaryTenantIsOwner === true &&
|
||||
tenant.id === fallbackAppointment?.id,
|
||||
isAdmin: false,
|
||||
isManager: false,
|
||||
grade: user.grade,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
@@ -727,6 +734,8 @@ function UserDetailPage() {
|
||||
tenantSlug: fallbackAppointment.slug,
|
||||
isPrimary: true,
|
||||
isOwner: metadata.primaryTenantIsOwner === true,
|
||||
isAdmin: false,
|
||||
isManager: false,
|
||||
grade: user.grade,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
@@ -836,23 +845,23 @@ function UserDetailPage() {
|
||||
tenantId: appointment.tenantId,
|
||||
tenantSlug: appointment.tenantSlug,
|
||||
tenantName: appointment.tenantName,
|
||||
isPrimary: appointment.isOwner,
|
||||
isOwner: appointment.isOwner,
|
||||
isPrimary: appointment.isPrimary === true,
|
||||
...(appointment.isOwner === true ? { isOwner: true } : {}),
|
||||
...(appointment.isAdmin === true ? { isAdmin: true } : {}),
|
||||
...(appointment.isManager === true ? { isManager: true } : {}),
|
||||
grade: appointment.grade,
|
||||
jobTitle: appointment.jobTitle,
|
||||
position: appointment.position,
|
||||
}));
|
||||
|
||||
const primary = appointments.find((a) => a.isOwner);
|
||||
const primary = appointments.find((a) => a.isPrimary);
|
||||
if (primary) {
|
||||
payload.tenantSlug = primary.tenantSlug;
|
||||
payload.primaryTenantId = primary.tenantId;
|
||||
payload.primaryTenantName = primary.tenantName;
|
||||
payload.primaryTenantIsOwner = true;
|
||||
metadata.primaryTenantId = primary.tenantId;
|
||||
metadata.primaryTenantSlug = primary.tenantSlug;
|
||||
metadata.primaryTenantName = primary.tenantName;
|
||||
metadata.primaryTenantIsOwner = true;
|
||||
} else {
|
||||
payload.tenantSlug = undefined;
|
||||
}
|
||||
@@ -868,12 +877,10 @@ function UserDetailPage() {
|
||||
primaryTenantId: primary?.tenantId,
|
||||
primaryTenantName: primary?.tenantName,
|
||||
primaryTenantSlug: primary?.tenantSlug,
|
||||
primaryTenantIsOwner: primary?.isOwner ?? false,
|
||||
};
|
||||
payload.tenantSlug = primary?.tenantSlug;
|
||||
payload.primaryTenantId = primary?.tenantId;
|
||||
payload.primaryTenantName = primary?.tenantName;
|
||||
payload.primaryTenantIsOwner = primary?.isOwner ?? false;
|
||||
}
|
||||
|
||||
mutation.mutate(payload);
|
||||
@@ -1362,13 +1369,13 @@ function UserDetailPage() {
|
||||
)}
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Switch
|
||||
checked={appointment.isOwner}
|
||||
checked={appointment.isPrimary === true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isOwner: checked === true,
|
||||
isPrimary: checked === true,
|
||||
})
|
||||
}
|
||||
disabled={appointment.isPrimary}
|
||||
disabled={appointment.isPrimary === true}
|
||||
aria-label={t(
|
||||
"ui.admin.users.detail.form.appointment_owner",
|
||||
"대표 조직",
|
||||
@@ -1379,6 +1386,24 @@ function UserDetailPage() {
|
||||
"대표 조직",
|
||||
)}
|
||||
</label>
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Switch
|
||||
checked={appointment.isManager === true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isManager: checked === true,
|
||||
})
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.users.detail.form.appointment_manager",
|
||||
"조직장",
|
||||
)}
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.appointment_manager",
|
||||
"조직장",
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
192
adminfront/src/features/users/UserListPage.render.test.tsx
Normal file
192
adminfront/src/features/users/UserListPage.render.test.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import UserListPage from "./UserListPage";
|
||||
|
||||
const selectRenderCounter = vi.hoisted(() => ({ count: 0 }));
|
||||
|
||||
const users = Array.from({ length: 200 }, (_, index) => ({
|
||||
id: `user-${index}`,
|
||||
name: `User ${index}`,
|
||||
email: `user${index}@example.com`,
|
||||
phone: `010-${String(index).padStart(4, "0")}-0000`,
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "hanmac",
|
||||
tenant: { id: "tenant-1", name: "한맥", slug: "hanmac" },
|
||||
metadata: {},
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
}));
|
||||
|
||||
const fetchUsersMock = vi.hoisted(() => vi.fn());
|
||||
const searchRenderBudgetMs =
|
||||
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 200;
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-user",
|
||||
role: "super_admin",
|
||||
name: "Admin",
|
||||
email: "admin@example.com",
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
|
||||
total: 1,
|
||||
})),
|
||||
fetchTenant: vi.fn(async () => ({
|
||||
id: "tenant-1",
|
||||
name: "한맥",
|
||||
slug: "hanmac",
|
||||
config: { userSchema: [] },
|
||||
})),
|
||||
fetchUsers: fetchUsersMock,
|
||||
bulkCreateUsers: vi.fn(),
|
||||
bulkDeleteUsers: vi.fn(),
|
||||
bulkUpdateUsers: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
exportUsersCSV: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ui/select", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectTrigger: ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
selectRenderCounter.count += 1;
|
||||
return (
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
SelectValue: () => <span />,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value: _value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
function renderUserListPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<UserListPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve: (value: T) => void = () => {};
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("UserListPage search rendering", () => {
|
||||
beforeEach(() => {
|
||||
selectRenderCounter.count = 0;
|
||||
fetchUsersMock.mockReset();
|
||||
fetchUsersMock.mockImplementation(
|
||||
async (_limit: number, _offset: number, search?: string) => {
|
||||
const normalizedSearch = search?.trim().toLowerCase();
|
||||
const items = normalizedSearch
|
||||
? users.filter((user) =>
|
||||
`${user.name} ${user.email}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedSearch),
|
||||
)
|
||||
: users;
|
||||
return { items, total: items.length };
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rerender user table controls while typing a draft search", async () => {
|
||||
renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
|
||||
const renderCountBeforeTyping = selectRenderCounter.count;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "u" } });
|
||||
|
||||
expect(searchInput).toHaveValue("u");
|
||||
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
|
||||
});
|
||||
|
||||
it("keeps rendered row controls below the full 200-user result set", async () => {
|
||||
renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
|
||||
expect(screen.getAllByTestId(/^user-status-select-/).length).toBeLessThan(
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders compact vertically centered user table headers", async () => {
|
||||
renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
const nameHeader = screen.getByRole("columnheader", { name: /이름/ });
|
||||
const content = nameHeader.firstElementChild;
|
||||
|
||||
expect(nameHeader).toHaveClass("h-9", "py-1", "align-middle", "text-xs");
|
||||
expect(content).toHaveClass("flex", "h-full", "items-center");
|
||||
});
|
||||
|
||||
it("centers the initial loading message across the user table", async () => {
|
||||
const deferred = createDeferred<{ items: typeof users; total: number }>();
|
||||
fetchUsersMock.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
renderUserListPage();
|
||||
|
||||
const loadingCell = await screen.findByTestId("user-table-loading-cell");
|
||||
expect(loadingCell).toHaveClass(
|
||||
"flex",
|
||||
"items-center",
|
||||
"justify-center",
|
||||
"text-center",
|
||||
);
|
||||
expect(loadingCell).toHaveStyle({ gridColumn: "1 / -1" });
|
||||
|
||||
deferred.resolve({ items: users, total: users.length });
|
||||
});
|
||||
|
||||
it("renders a 200-user search result update within 200ms after search submit", async () => {
|
||||
renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
|
||||
const startedAt = performance.now();
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "user 19" } });
|
||||
fireEvent.keyDown(searchInput, { key: "Enter" });
|
||||
|
||||
expect(screen.getByText("User 19")).toBeInTheDocument();
|
||||
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
|
||||
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
observeElementRect,
|
||||
type Rect,
|
||||
useVirtualizer,
|
||||
type Virtualizer,
|
||||
} from "@tanstack/react-virtual";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -7,7 +13,6 @@ import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Users,
|
||||
Download,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
@@ -19,13 +24,13 @@ import {
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Upload,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import {
|
||||
SortableTableHead,
|
||||
sortableTableHeadBaseClassName,
|
||||
sortableTableHeaderClassName,
|
||||
} from "../../../../common/core/components/sort";
|
||||
import {
|
||||
@@ -81,7 +86,6 @@ import {
|
||||
} from "../../components/ui/table";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
type UserSummary,
|
||||
bulkDeleteUsers,
|
||||
bulkUpdateUsers,
|
||||
deleteUser,
|
||||
@@ -90,13 +94,15 @@ import {
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchUsers,
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
UserBulkUploadModal,
|
||||
downloadUserTemplate,
|
||||
UserBulkUploadModal,
|
||||
} from "./components/UserBulkUploadModal";
|
||||
import {
|
||||
normalizeUserStatusValue,
|
||||
@@ -113,6 +119,23 @@ type UserSchemaField = {
|
||||
|
||||
type UserSortKey = string;
|
||||
|
||||
const USER_ROW_ESTIMATED_HEIGHT = 64;
|
||||
const USER_ROW_OVERSCAN = 8;
|
||||
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
|
||||
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
|
||||
const userMetadataColumnWidth = 160;
|
||||
const userCreatedColumnWidth = 150;
|
||||
type UserRowVirtualizer = Virtualizer<HTMLDivElement, HTMLTableRowElement>;
|
||||
const userTableHeadClassName =
|
||||
"h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap";
|
||||
const userTableHeadInteractiveClassName = `${userTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`;
|
||||
const userTableHeadContentClassName = "flex h-full items-center gap-1";
|
||||
const userSortableTableHeadClassName =
|
||||
"!h-9 !px-3 !py-1 leading-tight whitespace-nowrap";
|
||||
const userSortableTableHeadContentClassName = "h-full items-center";
|
||||
const userTableStateCellClassName =
|
||||
"flex h-24 items-center justify-center p-0 text-center text-sm text-muted-foreground";
|
||||
|
||||
const bulkPermissionOptions = [
|
||||
{
|
||||
value: "super_admin",
|
||||
@@ -130,11 +153,124 @@ function assignableSystemRoleValue(role?: string | null) {
|
||||
return isSuperAdminRole(role) ? "super_admin" : "user";
|
||||
}
|
||||
|
||||
function userMatchesSearch(user: UserSummary, search: string) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
user.name?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.email?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.phone?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.id?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.tenantSlug?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.tenant?.name?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.department?.toLowerCase().includes(normalizedSearch) ||
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
|
||||
return {
|
||||
width: rect.width > 0 ? rect.width : fallbackWidth,
|
||||
height:
|
||||
rect.height > 0 ? rect.height : USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
type UserListSearchControlsProps = {
|
||||
search: string;
|
||||
selectedCompany: string;
|
||||
tenants: TenantSummary[];
|
||||
profileRole?: string | null;
|
||||
onSearch: (value: string) => void;
|
||||
onCompanyChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
search,
|
||||
selectedCompany,
|
||||
tenants,
|
||||
profileRole,
|
||||
onSearch,
|
||||
onCompanyChange,
|
||||
}: UserListSearchControlsProps) {
|
||||
const [searchDraft, setSearchDraft] = React.useState(search);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSearchDraft(search);
|
||||
}, [search]);
|
||||
|
||||
const handleSearch = React.useCallback(() => {
|
||||
onSearch(searchDraft);
|
||||
}, [onSearch, searchDraft]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
|
||||
const tenantOptions = React.useMemo(
|
||||
() =>
|
||||
tenants.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.slug}>
|
||||
{tenant.name}
|
||||
</option>
|
||||
)),
|
||||
[tenants],
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(event) => setSearchDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(event) => onCompanyChange(event.target.value)}
|
||||
disabled={profileRole === "tenant_admin"}
|
||||
>
|
||||
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
|
||||
{tenantOptions}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
className="h-9"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function UserListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [searchDraft, setSearchDraft] = React.useState("");
|
||||
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
||||
const [visibleColumns, setVisibleColumns] = React.useState<
|
||||
Record<string, boolean>
|
||||
@@ -148,6 +284,7 @@ function UserListPage() {
|
||||
const [sortConfig, setSortConfig] =
|
||||
React.useState<SortConfig<UserSortKey> | null>(null);
|
||||
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
|
||||
const userTableViewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -254,16 +391,15 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearch(searchDraft);
|
||||
const handleSearch = React.useCallback((nextSearch: string) => {
|
||||
setSearch(nextSearch);
|
||||
setPage(1);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
const handleCompanyChange = React.useCallback((nextCompany: string) => {
|
||||
setSelectedCompany(nextCompany);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleExport = (includeIds = false) => {
|
||||
exportMutation.mutate(includeIds);
|
||||
@@ -279,7 +415,14 @@ function UserListPage() {
|
||||
)
|
||||
: null;
|
||||
|
||||
const rawItems = query.data?.items ?? [];
|
||||
const serverItems = query.data?.items ?? [];
|
||||
const rawItems = React.useMemo(() => {
|
||||
if (!query.isFetching || search.trim() === "") {
|
||||
return serverItems;
|
||||
}
|
||||
|
||||
return serverItems.filter((user) => userMatchesSearch(user, search));
|
||||
}, [query.isFetching, search, serverItems]);
|
||||
const userSortResolvers = React.useMemo<
|
||||
SortResolverMap<UserSummary, UserSortKey>
|
||||
>(
|
||||
@@ -306,8 +449,55 @@ function UserListPage() {
|
||||
[userSchema],
|
||||
);
|
||||
const items = React.useMemo(() => {
|
||||
if (!sortConfig) {
|
||||
return rawItems;
|
||||
}
|
||||
|
||||
return sortItems(rawItems, sortConfig, userSortResolvers);
|
||||
}, [rawItems, sortConfig, userSortResolvers]);
|
||||
const visibleUserSchemaFields = React.useMemo(
|
||||
() => userSchema.filter((field) => visibleColumns[field.key] !== false),
|
||||
[userSchema, visibleColumns],
|
||||
);
|
||||
const userTableColumnWidths = React.useMemo(
|
||||
() => [
|
||||
...userFixedColumnWidths,
|
||||
...visibleUserSchemaFields.map(() => userMetadataColumnWidth),
|
||||
userCreatedColumnWidth,
|
||||
],
|
||||
[visibleUserSchemaFields],
|
||||
);
|
||||
const userTableGridTemplateColumns = React.useMemo(
|
||||
() => userTableColumnWidths.map((width) => `${width}px`).join(" "),
|
||||
[userTableColumnWidths],
|
||||
);
|
||||
const userTableMinWidth = React.useMemo(
|
||||
() => userTableColumnWidths.reduce((sum, width) => sum + width, 0),
|
||||
[userTableColumnWidths],
|
||||
);
|
||||
const observeUserTableElementRect = React.useCallback(
|
||||
(instance: UserRowVirtualizer, callback: (rect: Rect) => void) =>
|
||||
observeElementRect(instance, (rect) => {
|
||||
callback(normalizeUserTableRect(rect, userTableMinWidth));
|
||||
}),
|
||||
[userTableMinWidth],
|
||||
);
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => userTableViewportRef.current,
|
||||
estimateSize: () => USER_ROW_ESTIMATED_HEIGHT,
|
||||
measureElement: (element) =>
|
||||
element.getBoundingClientRect().height || USER_ROW_ESTIMATED_HEIGHT,
|
||||
observeElementRect: observeUserTableElementRect,
|
||||
overscan: USER_ROW_OVERSCAN,
|
||||
initialRect: {
|
||||
width: userTableMinWidth,
|
||||
height: USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT,
|
||||
},
|
||||
});
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const shouldVirtualizeRows = !query.isLoading && items.length > 0;
|
||||
const tableColumnCount = 9 + visibleUserSchemaFields.length;
|
||||
|
||||
const requestSort = (key: UserSortKey) => {
|
||||
setSortConfig((current) => toggleSort(current, key));
|
||||
@@ -436,52 +626,13 @@ function UserListPage() {
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(e) => {
|
||||
setSelectedCompany(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">
|
||||
{t("ui.common.all", "전체 테넌트")}
|
||||
</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
className="h-9"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
<UserListSearchControls
|
||||
search={search}
|
||||
selectedCompany={selectedCompany}
|
||||
tenants={tenants}
|
||||
profileRole={profile?.role}
|
||||
onSearch={handleSearch}
|
||||
onCompanyChange={handleCompanyChange}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -643,82 +794,92 @@ function UserListPage() {
|
||||
)}
|
||||
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table>
|
||||
<div
|
||||
ref={userTableViewportRef}
|
||||
data-testid="user-table-viewport"
|
||||
className={commonTableViewportClass}
|
||||
>
|
||||
<Table style={{ display: "grid", minWidth: userTableMinWidth }}>
|
||||
<TableHeader className={sortableTableHeaderClassName}>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className={`${sortableTableHeadBaseClassName} w-12`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={
|
||||
items.length > 0 &&
|
||||
selectedUserIds.length === items.length
|
||||
}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
<TableRow
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: userTableGridTemplateColumns,
|
||||
minWidth: userTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableHead className={`${userTableHeadClassName} w-12`}>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={
|
||||
items.length > 0 &&
|
||||
selectedUserIds.length === items.length
|
||||
}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[120px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={`${userTableHeadInteractiveClassName} min-w-[120px]`}
|
||||
onClick={() => requestSort("name")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.name", "이름")}
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[180px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={`${userTableHeadInteractiveClassName} min-w-[180px]`}
|
||||
onClick={() => requestSort("email")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.email", "이메일")}
|
||||
{getSortIcon("email")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[140px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={`${userTableHeadInteractiveClassName} min-w-[140px]`}
|
||||
onClick={() => requestSort("phone")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.phone", "전화번호")}
|
||||
{getSortIcon("phone")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[220px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={`${userTableHeadInteractiveClassName} min-w-[220px]`}
|
||||
onClick={() => requestSort("id")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.id", "ID")}
|
||||
{getSortIcon("id")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={userTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={userTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("role")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||
{getSortIcon("role")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={userTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("tenant_dept")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t(
|
||||
"ui.admin.users.list.table.tenant_dept",
|
||||
"TENANT / DEPT",
|
||||
@@ -727,21 +888,20 @@ function UserListPage() {
|
||||
</div>
|
||||
</TableHead>
|
||||
{/* Dynamic Columns from Schema */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<SortableTableHead
|
||||
key={field.key}
|
||||
className="whitespace-nowrap"
|
||||
label={field.label}
|
||||
onSort={requestSort}
|
||||
sortConfig={sortConfig}
|
||||
sortKey={field.key}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{visibleUserSchemaFields.map((field) => (
|
||||
<SortableTableHead
|
||||
key={field.key}
|
||||
className={userSortableTableHeadClassName}
|
||||
contentClassName={userSortableTableHeadContentClassName}
|
||||
label={field.label}
|
||||
onSort={requestSort}
|
||||
sortConfig={sortConfig}
|
||||
sortKey={field.key}
|
||||
/>
|
||||
))}
|
||||
<SortableTableHead
|
||||
className="whitespace-nowrap"
|
||||
className={userSortableTableHeadClassName}
|
||||
contentClassName={userSortableTableHeadContentClassName}
|
||||
label={t("ui.admin.users.list.table.created", "CREATED")}
|
||||
onSort={requestSort}
|
||||
sortConfig={sortConfig}
|
||||
@@ -749,22 +909,51 @@ function UserListPage() {
|
||||
/>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableBody
|
||||
style={
|
||||
shouldVirtualizeRows
|
||||
? {
|
||||
display: "grid",
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
minWidth: userTableMinWidth,
|
||||
position: "relative",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableRow
|
||||
data-testid="user-table-loading-row"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: userTableGridTemplateColumns,
|
||||
minWidth: userTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
colSpan={tableColumnCount}
|
||||
data-testid="user-table-loading-cell"
|
||||
className={userTableStateCellClassName}
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableRow
|
||||
data-testid="user-table-empty-row"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: userTableGridTemplateColumns,
|
||||
minWidth: userTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
colSpan={tableColumnCount}
|
||||
data-testid="user-table-empty-cell"
|
||||
className={userTableStateCellClassName}
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
>
|
||||
{t(
|
||||
"msg.admin.users.list.empty",
|
||||
@@ -773,145 +962,162 @@ function UserListPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={
|
||||
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => toggleSelectUser(user.id)}
|
||||
disabled={user.id === profile?.id}
|
||||
title={
|
||||
user.id === profile?.id
|
||||
? t(
|
||||
"msg.admin.users.self_delete_blocked",
|
||||
"본인 계정은 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
{shouldVirtualizeRows &&
|
||||
virtualRows.map((virtualRow) => {
|
||||
const user = items[virtualRow.index];
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
className={
|
||||
selectedUserIds.includes(user.id)
|
||||
? "bg-primary/5"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
|
||||
title={user.name}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: userTableGridTemplateColumns,
|
||||
height: `${virtualRow.size}px`,
|
||||
minWidth: userTableMinWidth,
|
||||
position: "absolute",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-sm text-muted-foreground truncate max-w-[200px]"
|
||||
title={user.email}
|
||||
>
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{user.phone || "-"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[220px] break-all font-mono text-xs text-muted-foreground"
|
||||
data-testid={`user-internal-id-${user.id}`}
|
||||
>
|
||||
{user.id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={normalizeUserStatusValue(user.status)}
|
||||
onValueChange={(status) =>
|
||||
statusMutation.mutate({
|
||||
userId: user.id,
|
||||
status,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending || user.id === profile?.id
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-[150px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium"
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.change_status",
|
||||
"{{name}} 상태 변경",
|
||||
{ name: user.name },
|
||||
)}
|
||||
data-testid={`user-status-select-${user.id}`}
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => toggleSelectUser(user.id)}
|
||||
disabled={user.id === profile?.id}
|
||||
title={
|
||||
user.id === profile?.id
|
||||
? t(
|
||||
"msg.admin.users.self_delete_blocked",
|
||||
"본인 계정은 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
|
||||
title={user.name}
|
||||
>
|
||||
{user.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-sm text-muted-foreground truncate max-w-[200px]"
|
||||
title={user.email}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={assignableSystemRoleValue(user.role)}
|
||||
onValueChange={(value) =>
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: [user.id],
|
||||
role: value,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
bulkUpdateMutation.isPending ||
|
||||
!isSuperAdminRole(profile?.role) ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bulkPermissionOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{user.phone || "-"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[220px] break-all font-mono text-xs text-muted-foreground"
|
||||
data-testid={`user-internal-id-${user.id}`}
|
||||
>
|
||||
{user.id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={normalizeUserStatusValue(user.status)}
|
||||
onValueChange={(status) =>
|
||||
statusMutation.mutate({
|
||||
userId: user.id,
|
||||
status,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-[150px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium"
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.change_status",
|
||||
"{{name}} 상태 변경",
|
||||
{ name: user.name },
|
||||
)}
|
||||
data-testid={`user-status-select-${user.id}`}
|
||||
>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{user.tenant?.name ||
|
||||
user.tenantSlug ||
|
||||
t("ui.common.unassigned", "미배정")}
|
||||
</span>
|
||||
{user.department && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={assignableSystemRoleValue(user.role)}
|
||||
onValueChange={(value) =>
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: [user.id],
|
||||
role: value,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
bulkUpdateMutation.isPending ||
|
||||
!isSuperAdminRole(profile?.role) ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bulkPermissionOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{user.tenant?.name ||
|
||||
user.tenantSlug ||
|
||||
t("ui.common.unassigned", "미배정")}
|
||||
</span>
|
||||
{user.department && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
{visibleUserSchemaFields.map((field) => (
|
||||
<TableCell key={field.key} className="text-sm">
|
||||
{String(user.metadata?.[field.key] ?? "-")}
|
||||
</TableCell>
|
||||
),
|
||||
)}
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
))}
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -16,12 +16,12 @@ import { Input } from "../../../components/ui/input";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type GroupSummary,
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
bulkUpdateUsers,
|
||||
fetchAllTenants,
|
||||
fetchGroups,
|
||||
type GroupSummary,
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
|
||||
@@ -30,17 +30,17 @@ import {
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
buildTenantImportPreview,
|
||||
type TenantCSVRow,
|
||||
type TenantImportPreviewRow,
|
||||
buildTenantImportPreview,
|
||||
} from "../../tenants/utils/tenantCsvImport";
|
||||
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
|
||||
import { parseUserCSV } from "../utils/csvParser";
|
||||
import {
|
||||
type HanmacImportEmailPreview,
|
||||
buildHanmacImportEmailPreview,
|
||||
} from "../utils/hanmacImportEmail";
|
||||
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
|
||||
import {
|
||||
buildHanmacImportEmailPreview,
|
||||
type HanmacImportEmailPreview,
|
||||
} from "../utils/hanmacImportEmail";
|
||||
|
||||
interface UserBulkUploadModalProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -551,7 +551,10 @@ export function UserBulkUploadModal({
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewData.slice(0, 10).map((u, index) => (
|
||||
<tr key={`${u.email}-${index}`} className="border-t">
|
||||
<tr
|
||||
key={`${u.email}-${u.tenantSlug ?? ""}-${u.name}`}
|
||||
className="border-t"
|
||||
>
|
||||
<td className="p-2">
|
||||
<input
|
||||
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
|
||||
|
||||
@@ -59,9 +59,7 @@ describe("orgChartPicker", () => {
|
||||
buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", {
|
||||
includeInternal: false,
|
||||
}),
|
||||
).toBe(
|
||||
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
|
||||
);
|
||||
).toBe("https://orgchart.example.com/login?auto=1&returnTo=%2Fchart");
|
||||
});
|
||||
|
||||
it("parses the first tenant id and name from orgfront confirm messages", () => {
|
||||
|
||||
@@ -12,7 +12,9 @@ export const userStatusValues = [
|
||||
|
||||
export type UserStatusValue = (typeof userStatusValues)[number];
|
||||
|
||||
export function normalizeUserStatusValue(status?: string | null): UserStatusValue {
|
||||
export function normalizeUserStatusValue(
|
||||
status?: string | null,
|
||||
): UserStatusValue {
|
||||
switch ((status ?? "").trim().toLowerCase()) {
|
||||
case "active":
|
||||
return "active";
|
||||
|
||||
@@ -206,6 +206,12 @@ function cleanAdditionalAppointment(
|
||||
...(appointment.isOwner !== undefined
|
||||
? { isOwner: appointment.isOwner }
|
||||
: {}),
|
||||
...(appointment.isAdmin !== undefined
|
||||
? { isAdmin: appointment.isAdmin }
|
||||
: {}),
|
||||
...(appointment.isManager !== undefined
|
||||
? { isManager: appointment.isManager }
|
||||
: {}),
|
||||
...(appointment.department ? { department: appointment.department } : {}),
|
||||
...(appointment.grade ? { grade: appointment.grade } : {}),
|
||||
...(appointment.position ? { position: appointment.position } : {}),
|
||||
@@ -232,9 +238,7 @@ function normalizeHeader(header: string) {
|
||||
"worksmobile_alias_email",
|
||||
"worksmobile_alias_emails",
|
||||
].includes(separatorNormalized) ||
|
||||
["보조이메일", "보조메일", "추가이메일", "추가메일"].includes(
|
||||
compactKorean,
|
||||
)
|
||||
["보조이메일", "보조메일", "추가이메일", "추가메일"].includes(compactKorean)
|
||||
) {
|
||||
return "secondary_emails";
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
--input: 215 25% 24%;
|
||||
--ring: 209 79% 52%;
|
||||
--radius: 0.75rem;
|
||||
--app-background-image: radial-gradient(
|
||||
--app-background-image:
|
||||
radial-gradient(
|
||||
circle at 10% 18%,
|
||||
rgba(54, 211, 153, 0.16),
|
||||
transparent 28%
|
||||
|
||||
@@ -701,7 +701,9 @@ export type UserAppointment = {
|
||||
tenantSlug?: string;
|
||||
tenantName: string;
|
||||
isPrimary?: boolean;
|
||||
isOwner: boolean;
|
||||
isOwner?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isManager?: boolean;
|
||||
jobTitle?: string;
|
||||
grade?: string;
|
||||
position?: string;
|
||||
@@ -713,6 +715,8 @@ export type BulkUserAppointment = {
|
||||
tenantName?: string;
|
||||
isPrimary?: boolean;
|
||||
isOwner?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isManager?: boolean;
|
||||
department?: string;
|
||||
grade?: string;
|
||||
position?: string;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import axios from "axios";
|
||||
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
||||
import {
|
||||
shouldSuppressDevelopmentSessionRedirect,
|
||||
} from "../../../common/core/session";
|
||||
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
|
||||
import { userManager } from "./auth";
|
||||
|
||||
let isRedirectingToLogin = false;
|
||||
|
||||
@@ -38,9 +38,11 @@ describe("common cursor pagination fetch", () => {
|
||||
expect(response.items).toEqual([{ id: "tenant-1" }, { id: "tenant-2" }]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0][0].toString()).toContain(
|
||||
"/api/v1/admin/tenants?parentId=parent-1&limit=1&offset=0",
|
||||
"/api/v1/admin/tenants?parentId=parent-1&limit=1",
|
||||
);
|
||||
expect(fetchMock.mock.calls[0][0].toString()).not.toContain("offset=");
|
||||
expect(fetchMock.mock.calls[1][0].toString()).toContain("cursor=cursor-1");
|
||||
expect(fetchMock.mock.calls[1][0].toString()).not.toContain("offset=");
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({
|
||||
headers: { Authorization: "Bearer token" },
|
||||
credentials: "same-origin",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const CLIENT_DEBUG_LOG_ENABLED = new Set(["1", "true", "yes", "y", "on"]).has(
|
||||
String(import.meta.env.VITE_CLIENT_LOG_DEBUG ?? "").trim().toLowerCase(),
|
||||
String(import.meta.env.VITE_CLIENT_LOG_DEBUG ?? "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
|
||||
export function debugLog(...args: Parameters<typeof console.debug>) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, type Locale } from "../../../common/core/i18n";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_STORAGE_KEY,
|
||||
type Locale,
|
||||
} from "../../../common/core/i18n";
|
||||
|
||||
function isLocale(value: string): value is Locale {
|
||||
return value === "ko" || value === "en";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isSuperAdminRole,
|
||||
normalizeAdminRole,
|
||||
ROLE_RP_ADMIN,
|
||||
ROLE_SUPER_ADMIN,
|
||||
ROLE_TENANT_ADMIN,
|
||||
ROLE_USER,
|
||||
isSuperAdminRole,
|
||||
normalizeAdminRole,
|
||||
} from "./roles";
|
||||
|
||||
describe("admin role helpers", () => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
readSessionExpiryEnabled,
|
||||
SESSION_RENEW_THRESHOLD_MS,
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
shouldSuppressDevelopmentSessionRedirect,
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
shouldSuppressDevelopmentSessionRedirect,
|
||||
writeSessionExpiryEnabled,
|
||||
} from "./sessionSliding";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export {
|
||||
DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS,
|
||||
DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS,
|
||||
DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS,
|
||||
readSessionExpiryEnabled,
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
shouldSuppressDevelopmentSessionRedirect,
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
shouldSuppressDevelopmentSessionRedirect,
|
||||
writeSessionExpiryEnabled,
|
||||
} from "../../../common/core/session";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type SortConfig,
|
||||
compareNullableValues,
|
||||
type SortConfig,
|
||||
sortItems,
|
||||
toggleSort,
|
||||
} from "../../../common/core/utils";
|
||||
|
||||
@@ -3,9 +3,9 @@ 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 LocaleRefreshBoundary from "./components/common/LocaleRefreshBoundary";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { oidcConfig } from "./lib/auth";
|
||||
import "./index.css";
|
||||
|
||||
@@ -74,8 +74,7 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
||||
"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.running": "정합성 검사를 실행 중입니다.",
|
||||
"msg.admin.integrity.recheck.success": "검사가 완료되었습니다.",
|
||||
"msg.admin.user_projection.forbidden.description":
|
||||
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
|
||||
@@ -103,7 +102,8 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
||||
"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.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",
|
||||
@@ -173,7 +173,8 @@ function format(template: string, vars?: Vars) {
|
||||
export function createI18nMock() {
|
||||
return {
|
||||
t(key: string, fallback?: string, vars?: Vars) {
|
||||
const locale = window.localStorage.getItem("locale") === "en" ? "en" : "ko";
|
||||
const locale =
|
||||
window.localStorage.getItem("locale") === "en" ? "en" : "ko";
|
||||
const template = translations[locale][key] ?? fallback ?? key;
|
||||
return format(template, vars);
|
||||
},
|
||||
|
||||
@@ -164,7 +164,9 @@ test.describe("Tenants Management", () => {
|
||||
await expect(page.locator("table")).toContainText("Platform");
|
||||
await expect(page.locator("table")).toContainText("Acme");
|
||||
|
||||
await page.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i).fill("");
|
||||
await page
|
||||
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
|
||||
.fill("");
|
||||
await page
|
||||
.locator("tbody tr")
|
||||
.filter({ hasText: "Planning" })
|
||||
@@ -538,7 +540,10 @@ test.describe("Tenants Management", () => {
|
||||
test("should create a hanmac-family child tenant with org config", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(true, "브라우저별 org picker 상호작용이 불안정하여 unit 테스트로 커버합니다.");
|
||||
test.skip(
|
||||
true,
|
||||
"브라우저별 org picker 상호작용이 불안정하여 unit 테스트로 커버합니다.",
|
||||
);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
let createBody = "";
|
||||
const tenants = [
|
||||
|
||||
@@ -470,6 +470,193 @@ test.describe("User Management", () => {
|
||||
.toMatchObject({ status: "preboarding" });
|
||||
});
|
||||
|
||||
test("should center users table loading state and use compact headers", async ({
|
||||
page,
|
||||
}) => {
|
||||
let resolveUsers: (() => void) | undefined;
|
||||
const usersGate = new Promise<void>((resolve) => {
|
||||
resolveUsers = resolve;
|
||||
});
|
||||
|
||||
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
await usersGate;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/users");
|
||||
|
||||
const loadingCell = page.getByTestId("user-table-loading-cell");
|
||||
await expect(loadingCell).toBeVisible();
|
||||
await expect(loadingCell).toHaveCSS("display", "flex");
|
||||
await expect(loadingCell).toHaveCSS("align-items", "center");
|
||||
await expect(loadingCell).toHaveCSS("justify-content", "center");
|
||||
|
||||
const nameHeader = page.getByRole("columnheader", { name: /이름|Name/i });
|
||||
await expect(nameHeader).toHaveClass(/h-9/);
|
||||
await expect(nameHeader.locator("> div")).toHaveClass(/h-full/);
|
||||
|
||||
resolveUsers?.();
|
||||
await expect(page.getByTestId("user-table-empty-cell")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should virtualize large user result rows in the users table", async ({
|
||||
page,
|
||||
}) => {
|
||||
const manyUsers = Array.from({ length: 500 }, (_, index) => ({
|
||||
id: `u-${index}`,
|
||||
name: `User ${index}`,
|
||||
email: `user${index}@test.com`,
|
||||
phone: "010-1111-2222",
|
||||
loginId: `user${index}`,
|
||||
role: "user",
|
||||
status: "active",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
}));
|
||||
|
||||
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: manyUsers,
|
||||
total: manyUsers.length,
|
||||
limit: manyUsers.length,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/users");
|
||||
await expect(page.getByText("User 0")).toBeVisible();
|
||||
|
||||
const renderedStatusControls = await page
|
||||
.getByTestId(/^user-status-select-/)
|
||||
.count();
|
||||
expect(renderedStatusControls).toBeLessThan(manyUsers.length);
|
||||
await expect(page.getByText("User 499")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("user-table-viewport").evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
|
||||
await expect(page.getByText("User 499")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should keep large user search rendering under 200ms", async ({
|
||||
page,
|
||||
}) => {
|
||||
const manyUsers = Array.from({ length: 20_000 }, (_, index) => ({
|
||||
id: `load-u-${index}`,
|
||||
name: `Load User ${index}`,
|
||||
email: `load-user-${index}@test.com`,
|
||||
phone: "010-1111-2222",
|
||||
loginId: `load-user-${index}`,
|
||||
role: "user",
|
||||
status: "active",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
}));
|
||||
|
||||
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
const url = new URL(route.request().url());
|
||||
const normalizedSearch = url.searchParams
|
||||
.get("search")
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
const items = normalizedSearch
|
||||
? manyUsers.filter((user) =>
|
||||
`${user.name} ${user.email}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedSearch),
|
||||
)
|
||||
: manyUsers;
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items,
|
||||
total: items.length,
|
||||
limit: items.length,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const initialStartedAt = performance.now();
|
||||
await page.goto("/users");
|
||||
await expect(page.getByText("Load User 0")).toBeVisible();
|
||||
const initialMs = performance.now() - initialStartedAt;
|
||||
|
||||
const searchInput = page.getByPlaceholder("이름 또는 이메일 검색...");
|
||||
await searchInput.fill("Load User 19999");
|
||||
const searchMs = await page.evaluate(async () => {
|
||||
const input = Array.from(document.querySelectorAll("input")).find(
|
||||
(candidate) => candidate.placeholder === "이름 또는 이메일 검색...",
|
||||
);
|
||||
|
||||
if (!input) {
|
||||
throw new Error("User search input was not found.");
|
||||
}
|
||||
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const startedAt = performance.now();
|
||||
const timeout = window.setTimeout(() => {
|
||||
observer.disconnect();
|
||||
reject(new Error("Timed out waiting for large user search result."));
|
||||
}, 1000);
|
||||
const observer = new MutationObserver(() => {
|
||||
const bodyText = document.body.textContent ?? "";
|
||||
if (
|
||||
bodyText.includes("Load User 19999") &&
|
||||
!bodyText.includes("Load User 0")
|
||||
) {
|
||||
window.clearTimeout(timeout);
|
||||
observer.disconnect();
|
||||
resolve(performance.now() - startedAt);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
});
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: "Enter",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await expect(page.getByText("Load User 19999")).toBeVisible();
|
||||
await expect(page.getByText("Load User 0")).toHaveCount(0);
|
||||
|
||||
console.log(
|
||||
`[perf] users initial render with ${manyUsers.length} rows: ${initialMs.toFixed(1)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`[perf] users search update with ${manyUsers.length} rows: ${searchMs.toFixed(1)}ms`,
|
||||
);
|
||||
expect(searchMs).toBeLessThan(200);
|
||||
});
|
||||
|
||||
test("should expose internal user uuid in the users table", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -782,12 +969,10 @@ test.describe("User Management", () => {
|
||||
tenantSlug: "hanmac-team",
|
||||
primaryTenantId: "hanmac-team-id",
|
||||
primaryTenantName: "한맥팀",
|
||||
primaryTenantIsOwner: true,
|
||||
metadata: {
|
||||
primaryTenantId: "hanmac-team-id",
|
||||
primaryTenantName: "한맥팀",
|
||||
primaryTenantSlug: "hanmac-team",
|
||||
primaryTenantIsOwner: true,
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||
@@ -797,6 +982,14 @@ test.describe("User Management", () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(updatePayload?.primaryTenantIsOwner).toBeUndefined();
|
||||
expect(
|
||||
(updatePayload?.metadata as Record<string, unknown>)
|
||||
?.primaryTenantIsOwner,
|
||||
).toBeUndefined();
|
||||
const appointments = (updatePayload?.metadata as Record<string, unknown>)
|
||||
?.additionalAppointments as Array<Record<string, unknown>>;
|
||||
expect(appointments[1].isOwner).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should show conflict error when creating with an existing Login ID", async ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const buildOutDir = process.env.ADMINFRONT_BUILD_OUT_DIR ?? "dist";
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const commonRoot = fileURLToPath(new URL("../common", import.meta.url)).replace(
|
||||
/\\/g,
|
||||
"/",
|
||||
);
|
||||
const commonCoverageIncludes = ["core", "shell", "theme", "ui"].map(
|
||||
(directory) => `${commonRoot}/${directory}/**/*.{ts,tsx}`,
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
esbuild: {
|
||||
@@ -11,6 +20,29 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "html", "lcov", "json-summary"],
|
||||
reportsDirectory: "coverage",
|
||||
all: true,
|
||||
allowExternal: true,
|
||||
include: ["src/**/*.{ts,tsx}", ...commonCoverageIncludes],
|
||||
exclude: [
|
||||
"**/*.{test,spec}.{ts,tsx}",
|
||||
"**/*.d.ts",
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/coverage/**",
|
||||
"src/test/**",
|
||||
"src/main.tsx",
|
||||
"src/vite-env.d.ts",
|
||||
"../common/**/node_modules/**",
|
||||
"../common/.pnpm-store/**",
|
||||
`${commonRoot}/theme/**`,
|
||||
`${commonRoot}/core/pagination/*.worker.ts`,
|
||||
`${commonRoot}/core/query/queryClient.ts`,
|
||||
],
|
||||
},
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
|
||||
Reference in New Issue
Block a user