diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index 315a20aa..e877659a 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -4,6 +4,8 @@ on: push: branches: - dev + paths-ignore: + - "docs/badges/**" pull_request: branches: - dev @@ -49,6 +51,14 @@ on: required: true type: boolean default: true + run_front_coverage: + description: "Run adminfront/devfront/orgfront Vitest coverage and upload reports" + required: true + type: boolean + default: true + +permissions: + contents: write jobs: lint: @@ -92,34 +102,37 @@ jobs: run: | cd adminfront npx pnpm install -C ../common --no-frozen-lockfile + npx pnpm install --no-frozen-lockfile - name: Biome check adminfront (lint + format) run: | cd adminfront - npx biome check . --formatter-enabled=false --organize-imports-enabled=false - npx biome check . --linter-enabled=false --organize-imports-enabled=false + npx biome check . --formatter-enabled=false --assist-enabled=false + npx biome check . --linter-enabled=false --assist-enabled=false - name: Install devfront dependencies run: | cd devfront npx pnpm install -C ../common --no-frozen-lockfile + npx pnpm install --no-frozen-lockfile - name: Biome check devfront (lint + format) run: | cd devfront - npx biome check . --formatter-enabled=false --organize-imports-enabled=false - npx biome check . --linter-enabled=false --organize-imports-enabled=false + npx biome check . --formatter-enabled=false --assist-enabled=false + npx biome check . --linter-enabled=false --assist-enabled=false - name: Install orgfront dependencies run: | cd orgfront npx pnpm install -C ../common --no-frozen-lockfile + npx pnpm install --no-frozen-lockfile - name: Biome check orgfront (lint + format) run: | cd orgfront - npx biome check . --formatter-enabled=false --organize-imports-enabled=false - npx biome check . --linter-enabled=false --organize-imports-enabled=false + npx biome check . --formatter-enabled=false --assist-enabled=false + npx biome check . --linter-enabled=false --assist-enabled=false - name: Lint Go backend run: | @@ -148,6 +161,54 @@ jobs: cd userfront flutter analyze --no-fatal-warnings --no-fatal-infos + biome-check: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_lint == true }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install adminfront dependencies + run: | + cd adminfront + npx pnpm install -C ../common --no-frozen-lockfile + npx pnpm install --no-frozen-lockfile + + - name: Biome check adminfront + run: | + cd adminfront + npx biome check . --formatter-enabled=false --assist-enabled=false + npx biome check . --linter-enabled=false --assist-enabled=false + + - name: Install devfront dependencies + run: | + cd devfront + npx pnpm install -C ../common --no-frozen-lockfile + npx pnpm install --no-frozen-lockfile + + - name: Biome check devfront + run: | + cd devfront + npx biome check . --formatter-enabled=false --assist-enabled=false + npx biome check . --linter-enabled=false --assist-enabled=false + + - name: Install orgfront dependencies + run: | + cd orgfront + npx pnpm install -C ../common --no-frozen-lockfile + npx pnpm install --no-frozen-lockfile + + - name: Biome check orgfront + run: | + cd orgfront + npx biome check . --formatter-enabled=false --assist-enabled=false + npx biome check . --linter-enabled=false --assist-enabled=false + backend-tests: needs: lint if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_backend_tests == true) }} @@ -570,6 +631,189 @@ jobs: userfront-e2e/test-results if-no-files-found: ignore + front-vitest-coverage: + needs: lint + if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install front workspace dependencies + run: | + mkdir -p reports + set +e + npm install -g pnpm + cd common + pnpm install --no-frozen-lockfile --shamefully-hoist 2>&1 | tee ../reports/front-coverage-install.log + install_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$install_exit_code" -ne 0 ]; then + { + echo "# Front Vitest Coverage Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`front-vitest-coverage\`" + echo "- Reason: \`Dependency install failed\`" + echo "- Exit Code: \`$install_exit_code\`" + echo + echo "## Command" + echo "\`cd common && pnpm install --no-frozen-lockfile --shamefully-hoist\`" + echo + echo "## Install Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/front-coverage-install.log + echo '```' + } > reports/front-vitest-coverage-failure-report.md + exit 1 + fi + + for app in adminfront devfront orgfront; do + set +e + cd "$app" + pnpm install --no-frozen-lockfile --shamefully-hoist 2>&1 | tee -a ../reports/front-coverage-install.log + app_install_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$app_install_exit_code" -ne 0 ]; then + { + echo "# Front Vitest Coverage Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`front-vitest-coverage\`" + echo "- Package: \`$app\`" + echo "- Reason: \`Dependency install failed\`" + echo "- Exit Code: \`$app_install_exit_code\`" + echo + echo "## Command" + echo "\`cd $app && pnpm install --no-frozen-lockfile --shamefully-hoist\`" + echo + echo "## Install Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/front-coverage-install.log + echo '```' + } > reports/front-vitest-coverage-failure-report.md + exit 1 + fi + done + + - name: Run adminfront Vitest coverage + run: | + set +e + cd adminfront + pnpm run test:coverage 2>&1 | tee ../reports/adminfront-vitest-coverage.log + test_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$test_exit_code" -ne 0 ]; then + { + echo "# Front Vitest Coverage Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`front-vitest-coverage\`" + echo "- Package: \`adminfront\`" + echo "- Exit Code: \`$test_exit_code\`" + echo + echo "## Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/adminfront-vitest-coverage.log + echo '```' + } > reports/front-vitest-coverage-failure-report.md + exit 1 + fi + + - name: Run devfront Vitest coverage + run: | + set +e + cd devfront + pnpm run test:coverage 2>&1 | tee ../reports/devfront-vitest-coverage.log + test_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$test_exit_code" -ne 0 ]; then + { + echo "# Front Vitest Coverage Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`front-vitest-coverage\`" + echo "- Package: \`devfront\`" + echo "- Exit Code: \`$test_exit_code\`" + echo + echo "## Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/devfront-vitest-coverage.log + echo '```' + } > reports/front-vitest-coverage-failure-report.md + exit 1 + fi + + - name: Run orgfront Vitest coverage + run: | + set +e + cd orgfront + pnpm run test:coverage 2>&1 | tee ../reports/orgfront-vitest-coverage.log + test_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$test_exit_code" -ne 0 ]; then + { + echo "# Front Vitest Coverage Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`front-vitest-coverage\`" + echo "- Package: \`orgfront\`" + echo "- Exit Code: \`$test_exit_code\`" + echo + echo "## Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/orgfront-vitest-coverage.log + echo '```' + } > reports/front-vitest-coverage-failure-report.md + exit 1 + fi + + - name: Generate Vitest coverage summary + run: | + node scripts/summarize_vitest_coverage.mjs + cat reports/vitest-coverage-summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Publish front Vitest coverage failure summary + if: ${{ failure() }} + run: | + if [ -f reports/front-vitest-coverage-failure-report.md ]; then + cat reports/front-vitest-coverage-failure-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload front Vitest coverage report artifact + if: ${{ always() }} + uses: actions/upload-artifact@v3 + continue-on-error: true + with: + name: front-vitest-coverage-report + path: | + reports/vitest-coverage-summary.md + reports/vitest-coverage-summary.json + reports/front-vitest-coverage-failure-report.md + reports/front-coverage-install.log + reports/adminfront-vitest-coverage.log + reports/devfront-vitest-coverage.log + reports/orgfront-vitest-coverage.log + adminfront/coverage + devfront/coverage + orgfront/coverage + if-no-files-found: ignore + adminfront-tests: needs: lint if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }} @@ -696,7 +940,10 @@ jobs: run: | mkdir -p ../reports set +e - pnpm install -C ../common --no-frozen-lockfile 2>&1 | tee ../reports/devfront-install.log + { + pnpm install -C ../common --no-frozen-lockfile + pnpm install --no-frozen-lockfile + } 2>&1 | tee ../reports/devfront-install.log install_exit_code=${PIPESTATUS[0]} set -e @@ -874,7 +1121,10 @@ jobs: set +e cd orgfront npm install -g pnpm - pnpm install -C ../common --no-frozen-lockfile 2>&1 | tee ../reports/orgfront-install.log + { + pnpm install -C ../common --no-frozen-lockfile + pnpm install --no-frozen-lockfile + } 2>&1 | tee ../reports/orgfront-install.log install_exit_code=${PIPESTATUS[0]} cd .. set -e @@ -1021,3 +1271,63 @@ jobs: orgfront/playwright-report orgfront/test-results if-no-files-found: ignore + + badge-updater: + needs: + - lint + - biome-check + - backend-tests + - userfront-tests + - userfront-e2e-tests + - front-vitest-coverage + - adminfront-tests + - devfront-tests + - orgfront-tests + if: ${{ always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/dev' }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Download Vitest coverage report artifact + uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: front-vitest-coverage-report + path: badge-artifacts/front-vitest-coverage-report + + - name: Update badge files + env: + LINT_RESULT: ${{ needs.lint.result }} + BIOME_RESULT: ${{ needs['biome-check'].result }} + BACKEND_RESULT: ${{ needs['backend-tests'].result }} + USERFRONT_RESULT: ${{ needs['userfront-tests'].result }} + USERFRONT_E2E_RESULT: ${{ needs['userfront-e2e-tests'].result }} + USERFRONT_E2E_FULL: ${{ github.event_name == 'workflow_dispatch' && inputs.run_userfront_e2e_full == true }} + COVERAGE_RESULT: ${{ needs['front-vitest-coverage'].result }} + ADMINFRONT_RESULT: ${{ needs['adminfront-tests'].result }} + DEVFRONT_RESULT: ${{ needs['devfront-tests'].result }} + ORGFRONT_RESULT: ${{ needs['orgfront-tests'].result }} + run: | + node scripts/update_code_check_badges.mjs + cat docs/badges/badges.json + + - name: Commit badge updates + run: | + if [ -z "$(git status --porcelain docs/badges)" ]; then + echo "No badge changes." + exit 0 + fi + + git config user.name "gitea-actions" + git config user.email "gitea-actions@hmac.kr" + git add docs/badges + git commit -m "chore: update code check badges [skip ci]" + git push diff --git a/.gitignore b/.gitignore index f4f54941..5bb72c22 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,9 @@ orgfront/test-results/ adminfront/playwright-report/ devfront/playwright-report/ orgfront/playwright-report/ +adminfront/coverage/ +devfront/coverage/ +orgfront/coverage/ orgfront/node_modules/ orgfront/dist/ orgfront/.vite/ diff --git a/README.md b/README.md index 7922dc66..bbd8d6e7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,15 @@ # Baron SSO +[![Code Check](docs/badges/code-check.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) +[![Biome](docs/badges/biome.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) +[![userfront e2e fast](docs/badges/userfront-e2e-fast.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) +[![userfront e2e full](docs/badges/userfront-e2e-full.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) +[![adminfront coverage](docs/badges/adminfront-coverage.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) +[![devfront coverage](docs/badges/devfront-coverage.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) +[![orgfront coverage](docs/badges/orgfront-coverage.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) + +badge는 `Code Check`가 dev 브랜치에서 갱신합니다. 최신 HTML/LCOV/JSON summary는 Gitea `Code Check`의 `front-vitest-coverage-report` artifact에서 확인할 수 있습니다. + **Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다. ## 📂 프로젝트 구조 (Project Structure) diff --git a/adminfront/biome.json b/adminfront/biome.json index fb68b4da..66e0edd1 100644 --- a/adminfront/biome.json +++ b/adminfront/biome.json @@ -1,6 +1,4 @@ { - "extends": ["../common/config/biome.base.json"], - "files": { - "ignore": [".vite"] - } + "root": true, + "extends": ["../common/config/biome.base.json"] } diff --git a/adminfront/package-lock.json b/adminfront/package-lock.json index 61eddb69..24178a8f 100644 --- a/adminfront/package-lock.json +++ b/adminfront/package-lock.json @@ -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": { diff --git a/adminfront/package.json b/adminfront/package.json index 2ab225b3..ea962d3e 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -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", diff --git a/adminfront/pnpm-lock.yaml b/adminfront/pnpm-lock.yaml index d706b86e..f402328a 100644 --- a/adminfront/pnpm-lock.yaml +++ b/adminfront/pnpm-lock.yaml @@ -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 diff --git a/adminfront/pnpm-workspace.yaml b/adminfront/pnpm-workspace.yaml index 49c0ad74..59f9a7d3 100644 --- a/adminfront/pnpm-workspace.yaml +++ b/adminfront/pnpm-workspace.yaml @@ -1,2 +1,3 @@ allowBuilds: + '@biomejs/biome': true esbuild: false diff --git a/adminfront/scripts/serve-prod.mjs b/adminfront/scripts/serve-prod.mjs index 804a7052..a69d7bdb 100644 --- a/adminfront/scripts/serve-prod.mjs +++ b/adminfront/scripts/serve-prod.mjs @@ -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}`, + ); }); diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 7c17cf9e..5e231dbc 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -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"; diff --git a/adminfront/src/components/common/LanguageSelector.test.tsx b/adminfront/src/components/common/LanguageSelector.test.tsx index 0d505b96..d49d6546 100644 --- a/adminfront/src/components/common/LanguageSelector.test.tsx +++ b/adminfront/src/components/common/LanguageSelector.test.tsx @@ -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"; diff --git a/adminfront/src/components/common/LanguageSelector.tsx b/adminfront/src/components/common/LanguageSelector.tsx index b4e33c67..c1716ef4 100644 --- a/adminfront/src/components/common/LanguageSelector.tsx +++ b/adminfront/src/components/common/LanguageSelector.tsx @@ -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]; diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index e50e6f9d..2013f0e0 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -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() {

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

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

diff --git a/adminfront/src/components/ui/avatar.tsx b/adminfront/src/components/ui/avatar.tsx index 23e88913..ab415563 100644 --- a/adminfront/src/components/ui/avatar.tsx +++ b/adminfront/src/components/ui/avatar.tsx @@ -44,4 +44,4 @@ const AvatarFallback = React.forwardRef< )); AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; -export { Avatar, AvatarImage, AvatarFallback }; +export { Avatar, AvatarFallback, AvatarImage }; diff --git a/adminfront/src/components/ui/card.tsx b/adminfront/src/components/ui/card.tsx index 33685c34..246b5289 100644 --- a/adminfront/src/components/ui/card.tsx +++ b/adminfront/src/components/ui/card.tsx @@ -50,9 +50,9 @@ function CardFooter({ export { Card, + CardContent, + CardDescription, + CardFooter, CardHeader, CardTitle, - CardDescription, - CardContent, - CardFooter, }; diff --git a/adminfront/src/components/ui/dialog.tsx b/adminfront/src/components/ui/dialog.tsx index 69d9e2a2..b1a70ec6 100644 --- a/adminfront/src/components/ui/dialog.tsx +++ b/adminfront/src/components/ui/dialog.tsx @@ -144,18 +144,20 @@ const DialogClose = React.forwardRef( DialogClose.displayName = "DialogClose"; const DialogOverlay = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLButtonElement, + React.ButtonHTMLAttributes >(({ className, onMouseDown, ...props }, ref) => { const { setOpen } = useDialogContext("DialogOverlay"); return ( -
{ 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, }; diff --git a/adminfront/src/components/ui/dropdown-menu.tsx b/adminfront/src/components/ui/dropdown-menu.tsx index c58f322e..bde62785 100644 --- a/adminfront/src/components/ui/dropdown-menu.tsx +++ b/adminfront/src/components/ui/dropdown-menu.tsx @@ -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, }; diff --git a/adminfront/src/components/ui/select.tsx b/adminfront/src/components/ui/select.tsx index 696a5fa7..abd90f46 100644 --- a/adminfront/src/components/ui/select.tsx +++ b/adminfront/src/components/ui/select.tsx @@ -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, }; diff --git a/adminfront/src/components/ui/table.tsx b/adminfront/src/components/ui/table.tsx index 0b0022a5..33663b2e 100644 --- a/adminfront/src/components/ui/table.tsx +++ b/adminfront/src/components/ui/table.tsx @@ -92,11 +92,11 @@ TableCaption.displayName = "TableCaption"; export { Table, - TableHeader, TableBody, + TableCaption, + TableCell, TableFooter, TableHead, + TableHeader, TableRow, - TableCell, - TableCaption, }; diff --git a/adminfront/src/components/ui/tabs.tsx b/adminfront/src/components/ui/tabs.tsx index 71364fda..15646582 100644 --- a/adminfront/src/components/ui/tabs.tsx +++ b/adminfront/src/components/ui/tabs.tsx @@ -84,4 +84,4 @@ const TabsContent = React.forwardRef< }); TabsContent.displayName = "TabsContent"; -export { Tabs, TabsList, TabsTrigger, TabsContent }; +export { Tabs, TabsContent, TabsList, TabsTrigger }; diff --git a/adminfront/src/features/auth/AuthPage.test.tsx b/adminfront/src/features/auth/AuthPage.test.tsx index 5d13b0eb..032a122d 100644 --- a/adminfront/src/features/auth/AuthPage.test.tsx +++ b/adminfront/src/features/auth/AuthPage.test.tsx @@ -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(); }); }); diff --git a/adminfront/src/features/auth/LoginPage.test.tsx b/adminfront/src/features/auth/LoginPage.test.tsx index 1b7db6a7..06ef6eeb 100644 --- a/adminfront/src/features/auth/LoginPage.test.tsx +++ b/adminfront/src/features/auth/LoginPage.test.tsx @@ -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: { diff --git a/adminfront/src/features/auth/components/PermissionChecker.tsx b/adminfront/src/features/auth/components/PermissionChecker.tsx index eb7e9511..f94544da 100644 --- a/adminfront/src/features/auth/components/PermissionChecker.tsx +++ b/adminfront/src/features/auth/components/PermissionChecker.tsx @@ -48,10 +48,7 @@ function PermissionChecker() { - {t( - "ui.admin.auth_guard.checker.title", - "ReBAC permission checker", - )} + {t("ui.admin.auth_guard.checker.title", "ReBAC permission checker")} {t( @@ -92,7 +89,9 @@ function PermissionChecker() {
- +
- +
- {t( - "ui.admin.auth_guard.checker.allowed", - "Access ALLOWED", - )} + {t("ui.admin.auth_guard.checker.allowed", "Access ALLOWED")}

{t( @@ -171,10 +166,7 @@ function PermissionChecker() { <>

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

{t( diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx index f6c31bed..06a138d4 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -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)).", diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx index 7f449d28..1d4ca4ef 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -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"; diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 1f2488a7..90c12801 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -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() { )}

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

@@ -466,7 +463,7 @@ function GlobalOverviewPage() { const metric = (value: number | undefined) => value === undefined ? "-" : value.toLocaleString(); const periodControls = ( -
+
{[ ["day", t("ui.common.chart.period.day", "일")], ["week", t("ui.common.chart.period.week", "주")], @@ -486,7 +483,7 @@ function GlobalOverviewPage() { {label} ))} -
+ ); const chartFilters = (
diff --git a/adminfront/src/features/projections/UserProjectionPage.test.tsx b/adminfront/src/features/projections/UserProjectionPage.test.tsx index efe19903..2ec44387 100644 --- a/adminfront/src/features/projections/UserProjectionPage.test.tsx +++ b/adminfront/src/features/projections/UserProjectionPage.test.tsx @@ -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(); }); diff --git a/adminfront/src/features/projections/UserProjectionPage.tsx b/adminfront/src/features/projections/UserProjectionPage.tsx index 85e3bf86..9ec98255 100644 --- a/adminfront/src/features/projections/UserProjectionPage.tsx +++ b/adminfront/src/features/projections/UserProjectionPage.tsx @@ -133,10 +133,7 @@ function UserProjectionContent() { disabled={isWorking} > - {t( - "ui.admin.user_projection.actions.reset", - "Reset and rebuild", - )} + {t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
@@ -230,10 +227,7 @@ function UserProjectionContent() {
- {t( - "ui.admin.user_projection.summary.updated_at", - "Updated at", - )} + {t("ui.admin.user_projection.summary.updated_at", "Updated at")}
{formatDateTime(data?.updatedAt)} @@ -258,22 +252,19 @@ export default function UserProjectionPage() { -
-

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

-

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

-
- +
+
+

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

+

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

+
+
} > diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index 4f73f0ed..bac4fe1c 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -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"; diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index 69a2782b..6dc881a9 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -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(null); const [pendingOwners, setPendingOwners] = useState([]); const [pendingAdmins, setPendingAdmins] = useState([]); - 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); diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index e85e38cd..2fca4b48 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -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 = { diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index e4d2d470..87d226d2 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -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"; diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 10b7a5ee..14a4b9c0 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -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 ( -
-

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

- -
- ); - } - - 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 ( +
+

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

+ +
+ ); + } + + if ( + profileRole === "tenant_admin" && + (profile?.manageableTenants?.length ?? 0) <= 1 + ) { + return null; + } + const handleSelectAll = (checked: boolean) => { if (checked) { setSelectedIds(deletableTenants.map((t) => t.id)); diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index f06afcb4..3cb672c2 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -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 ( -
{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}
- ); - } - 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 ( +
{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}
+ ); + } + const handleDelete = () => { if (isProtectedSeedTenant) { return; diff --git a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx index 93b47cc0..55fa65c6 100644 --- a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx @@ -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() { diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx index a8af2063..a9002610 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx @@ -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 -; } diff --git a/adminfront/src/features/tenants/routes/tenantListView.ts b/adminfront/src/features/tenants/routes/tenantListView.ts index d5ebf895..b20bb36c 100644 --- a/adminfront/src/features/tenants/routes/tenantListView.ts +++ b/adminfront/src/features/tenants/routes/tenantListView.ts @@ -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 }; diff --git a/adminfront/src/features/tenants/utils/orgConfig.test.ts b/adminfront/src/features/tenants/utils/orgConfig.test.ts index 35c0f457..7f210d15 100644 --- a/adminfront/src/features/tenants/utils/orgConfig.test.ts +++ b/adminfront/src/features/tenants/utils/orgConfig.test.ts @@ -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"; diff --git a/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts b/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts index 42a282d0..f2b12103 100644 --- a/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts +++ b/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts @@ -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( diff --git a/adminfront/src/features/tenants/utils/tenantCsvImport.ts b/adminfront/src/features/tenants/utils/tenantCsvImport.ts index 133e1a1c..8f94b27d 100644 --- a/adminfront/src/features/tenants/utils/tenantCsvImport.ts +++ b/adminfront/src/features/tenants/utils/tenantCsvImport.ts @@ -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]) { diff --git a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx index f408b9aa..a4533e21 100644 --- a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx +++ b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx @@ -21,9 +21,9 @@ import { TableRow, } from "../../../components/ui/table"; import { - type TenantSummary, fetchAllTenants, fetchGroups, + type TenantSummary, } from "../../../lib/adminApi"; export default function GlobalUserGroupListPage() { diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index c5a682d9..b46d00ae 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -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( diff --git a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx index 79f6c966..18d6f500 100644 --- a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx +++ b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx @@ -574,9 +574,9 @@ export function UserGroupDetailPage() { ) : ( - groupRoles.map((role, idx) => ( + groupRoles.map((role) => ( diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index c2cb8c6a..d47a49c5 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -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() { )} +
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 35ff6068..d2857613 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -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() { )} +
diff --git a/adminfront/src/features/users/UserListPage.render.test.tsx b/adminfront/src/features/users/UserListPage.render.test.tsx new file mode 100644 index 00000000..72c5145f --- /dev/null +++ b/adminfront/src/features/users/UserListPage.render.test.tsx @@ -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 }) => ( +
{children}
+ ), + SelectTrigger: ({ + children, + ...props + }: React.ButtonHTMLAttributes) => { + selectRenderCounter.count += 1; + return ( + + ); + }, + SelectValue: () => , + SelectContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectItem: ({ + children, + value: _value, + }: { + children: React.ReactNode; + value: string; + }) =>
{children}
, +})); + +function renderUserListPage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + + + , + ); +} + +function createDeferred() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((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); + }); +}); diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 6c63cc1d..fa5d4795 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -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; +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) => { + if (event.key === "Enter") { + handleSearch(); + } + }, + [handleSearch], + ); + + const tenantOptions = React.useMemo( + () => + tenants.map((tenant) => ( + + )), + [tenants], + ); + + return ( + +
+ + setSearchDraft(event.target.value)} + onKeyDown={handleKeyDown} + /> +
+ + + + + + } + /> + ); +}); + 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(""); const [visibleColumns, setVisibleColumns] = React.useState< Record @@ -148,6 +284,7 @@ function UserListPage() { const [sortConfig, setSortConfig] = React.useState | null>(null); const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false); + const userTableViewportRef = React.useRef(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 >( @@ -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={ <> - -
- - setSearchDraft(e.target.value)} - onKeyDown={handleKeyDown} - /> -
- - - - - - } +