forked from baron/baron-sso
Compare commits
164 Commits
feature/87
...
feature/rb
| Author | SHA1 | Date | |
|---|---|---|---|
| d707cdf850 | |||
| 5c46727fb5 | |||
| e5ac333efa | |||
| 5377401574 | |||
| f76321c8ac | |||
| b2f155e35b | |||
| 6d3f128282 | |||
| 8f2e351875 | |||
| 91e983b315 | |||
| 499b5d65da | |||
| 5ba0d0fb86 | |||
| c6c79f7306 | |||
| fbdfb97c3e | |||
| 8cdd73d31a | |||
| 243b852591 | |||
| 80aa60fdf1 | |||
| af1f45cc25 | |||
| a125b1d7ae | |||
| 322fd13d67 | |||
| fcb246ea9e | |||
| 719f408e7e | |||
| ab6cb1331e | |||
| b7c963b672 | |||
| e5f1c85e29 | |||
| 74068503bb | |||
| 1f3d56933f | |||
| f76dd4e60d | |||
| bf64f82507 | |||
| ae8c2ee06f | |||
| 802bf3e91d | |||
| d32ca69eee | |||
| d6d39ca300 | |||
| 2c5eed1774 | |||
| 38605ac8a3 | |||
| a4d457073a | |||
| d0f44de2d1 | |||
| d2a7ebd82f | |||
| d40e443d48 | |||
| c4487b9334 | |||
| 57f05e2694 | |||
| 565ef6b685 | |||
| 75f192fb24 | |||
| 5c8a338085 | |||
| af55e3dbb8 | |||
| 31d107ff2e | |||
| 6574fb54b9 | |||
| 4a1e89e421 | |||
| c59ec5ce83 | |||
| 90457394b0 | |||
| 6259fb074b | |||
| 90740ffb22 | |||
| 4aa0ada012 | |||
| 22e2cc1f0f | |||
| 3c741ad0e3 | |||
| e8d76e5e95 | |||
| 520d7404cf | |||
| 86940cce9e | |||
| cadb0631fd | |||
| 420f2429c3 | |||
| bb87034898 | |||
| 07b0c055cc | |||
| 59514f4cf3 | |||
| 0f06fbc901 | |||
| 2c93bd8dfb | |||
| b4dfbe0480 | |||
| 23e3738b80 | |||
| 5648b7ec45 | |||
| a156713db7 | |||
| 041b0724be | |||
| f8d0cf411a | |||
| addded8942 | |||
| 262c5959cf | |||
| 939bf68f85 | |||
| 73ba79b015 | |||
| 955d0fb6da | |||
| 36cd693b4f | |||
| 509029f8f3 | |||
| 6512fea8fe | |||
| 7fe86e8aa4 | |||
| a010bd44c0 | |||
| 4ca492b31c | |||
| cb8c7d78c3 | |||
| bf94c7a3d6 | |||
| bdca346baa | |||
| 4c56c28481 | |||
| b74bab4161 | |||
| 16b2c97ddc | |||
| 23cd316c23 | |||
| f85def288d | |||
| d43787a96d | |||
| 5ddfc6c81b | |||
| 0448b86443 | |||
| b65d916a83 | |||
| 58a3be9a34 | |||
| 8d2e2c58fe | |||
| 2da470922b | |||
| b33aabbb68 | |||
| 5b345fcf6a | |||
| 5a98b8490c | |||
| 3e31fdfa0c | |||
| 9040d22ad2 | |||
| 29675d9cea | |||
| fcbd936053 | |||
| 963b0835ea | |||
| 00b89c04d6 | |||
| 2808c68871 | |||
| deed33aad2 | |||
| b245dd3111 | |||
| 592c1d1741 | |||
| 0e83561994 | |||
| faf6db204d | |||
| b81edb8a64 | |||
| d2270765f2 | |||
| a830242947 | |||
| 8d9ba3cfea | |||
| 62b1938c42 | |||
| 00310448e9 | |||
| 6e610c553f | |||
| c489c7c38f | |||
| 6a6730b544 | |||
| 731ae9251e | |||
| da01f63c54 | |||
| dc16958804 | |||
| 27caf27416 | |||
| 615d204678 | |||
| 2595d9ab74 | |||
| 6143569f7a | |||
| e0e60295f3 | |||
| 92c3905558 | |||
| 177a319407 | |||
| 7401454bc0 | |||
| bb5438bf8d | |||
| d88524b0f7 | |||
| 62d3923dee | |||
| 14b916fec8 | |||
| 3b073a4e11 | |||
| 200411a701 | |||
| e09e83351e | |||
| 45e49cf595 | |||
| d7a56e7352 | |||
| c7053c2c51 | |||
| d25b5bc61d | |||
| 1808cf9f33 | |||
| 899365de9d | |||
| dda1df9c48 | |||
| 35e51910c6 | |||
| 0e7ab2a22f | |||
| e240470d04 | |||
| 368f4bbad8 | |||
| 53830b20d8 | |||
| 57d92fa748 | |||
| e54802140a | |||
| e481ae2821 | |||
| 0eb6dabdc1 | |||
| dc68b7da41 | |||
| 9fc6459636 | |||
| 7c809fb478 | |||
| dbb5ad93b8 | |||
| e54cc121c7 | |||
| 66687a4c73 | |||
| c4f8d939d2 | |||
| 710f1a865c | |||
| eb46918397 | |||
| d56c041b67 |
File diff suppressed because it is too large
Load Diff
272
.gitea/workflows/userfront_e2e_full_nightly.yml
Normal file
272
.gitea/workflows/userfront_e2e_full_nightly.yml
Normal file
@@ -0,0 +1,272 @@
|
||||
name: Userfront E2E Full Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 18 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
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: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Run common lint checks
|
||||
run: |
|
||||
make code-check-lint
|
||||
|
||||
full-test-policy:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.policy.outputs.should_run }}
|
||||
reason: ${{ steps.policy.outputs.reason }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Decide whether full E2E is needed
|
||||
id: policy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
target_sha="${GITHUB_SHA}"
|
||||
should_run="true"
|
||||
reason="manual-dispatch"
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then
|
||||
reason="missing-full-result"
|
||||
git fetch origin "+refs/heads/badges:refs/remotes/origin/badges" || true
|
||||
if git show-ref --verify --quiet refs/remotes/origin/badges && \
|
||||
git cat-file -e "refs/remotes/origin/badges:dev/${target_sha}/badges.json" 2>/dev/null; then
|
||||
full_message="$(
|
||||
git show "refs/remotes/origin/badges:dev/${target_sha}/badges.json" |
|
||||
node -e "let input=''; process.stdin.on('data', c => input += c); process.stdin.on('end', () => { const data = JSON.parse(input); const keys = ['userfront-chrome', 'userfront-firefox', 'userfront-safari']; const messages = keys.map((key) => data.badges?.[key]?.message || 'unknown'); process.stdout.write(messages.join(',')); });"
|
||||
)"
|
||||
if [ -n "${full_message}" ] && ! printf '%s' "${full_message}" | grep -q "unknown"; then
|
||||
should_run="false"
|
||||
reason="full-result-exists:${full_message}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "should_run=${should_run}" >> "$GITHUB_OUTPUT"
|
||||
echo "reason=${reason}" >> "$GITHUB_OUTPUT"
|
||||
echo "target_sha=${target_sha}"
|
||||
echo "should_run=${should_run}"
|
||||
echo "reason=${reason}"
|
||||
|
||||
userfront-e2e-full:
|
||||
needs:
|
||||
- lint
|
||||
- full-test-policy
|
||||
if: ${{ needs.lint.result == 'success' && needs.full-test-policy.outputs.should_run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 80
|
||||
outputs:
|
||||
chromium_desktop: ${{ steps.full-results.outputs.chromium_desktop }}
|
||||
chromium_mobile: ${{ steps.full-results.outputs.chromium_mobile }}
|
||||
firefox_desktop: ${{ steps.full-results.outputs.firefox_desktop }}
|
||||
firefox_mobile: ${{ steps.full-results.outputs.firefox_mobile }}
|
||||
webkit_desktop: ${{ steps.full-results.outputs.webkit_desktop }}
|
||||
webkit_mobile: ${{ steps.full-results.outputs.webkit_mobile }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
cache-dependency-path: userfront-e2e/package-lock.json
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Sync userfront locales
|
||||
run: |
|
||||
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||
|
||||
- name: Install userfront-e2e dependencies
|
||||
run: |
|
||||
cd userfront-e2e
|
||||
npm ci
|
||||
|
||||
- name: Build userfront WASM
|
||||
run: |
|
||||
cd userfront
|
||||
flutter build web --wasm --release
|
||||
cd ..
|
||||
node userfront/scripts/optimize-web-build.mjs userfront/build/web
|
||||
|
||||
- name: Provision full browser matrix
|
||||
run: |
|
||||
cd userfront-e2e
|
||||
npx playwright install --with-deps
|
||||
|
||||
- name: Run full userfront-e2e tests
|
||||
id: full-results
|
||||
run: |
|
||||
mkdir -p reports
|
||||
cd userfront-e2e
|
||||
workers="${PLAYWRIGHT_WORKERS:-4}"
|
||||
case "$workers" in
|
||||
''|*[!0-9]*|0) workers=4 ;;
|
||||
esac
|
||||
any_failure=0
|
||||
|
||||
run_project() {
|
||||
output_name="$1"
|
||||
project_name="$2"
|
||||
log_path="../reports/userfront-e2e-full-${project_name}.log"
|
||||
|
||||
set +e
|
||||
echo "[userfront-e2e-full] PLAYWRIGHT_WORKERS=${workers} npx playwright test --project=${project_name}" | tee "$log_path"
|
||||
PLAYWRIGHT_WORKERS="$workers" npx playwright test --project="$project_name" --reporter=list 2>&1 | tee -a "$log_path"
|
||||
exit_code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
if [ "$exit_code" -eq 0 ]; then
|
||||
result="success"
|
||||
else
|
||||
result="failure"
|
||||
any_failure=1
|
||||
fi
|
||||
echo "${output_name}=${result}" >> "$GITHUB_OUTPUT"
|
||||
}
|
||||
|
||||
run_project chromium_desktop chromium-desktop
|
||||
run_project chromium_mobile chromium-mobile-webapp
|
||||
run_project firefox_desktop firefox-desktop
|
||||
echo "firefox_mobile=skipped" >> "$GITHUB_OUTPUT"
|
||||
run_project webkit_desktop webkit-desktop
|
||||
run_project webkit_mobile webkit-mobile-webapp
|
||||
|
||||
exit "$any_failure"
|
||||
|
||||
- name: Upload userfront-e2e full artifacts
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: userfront-e2e-full-report
|
||||
path: |
|
||||
reports/userfront-e2e-full-*.log
|
||||
userfront-e2e/playwright-report
|
||||
userfront-e2e/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
badge-updater:
|
||||
needs:
|
||||
- lint
|
||||
- full-test-policy
|
||||
- userfront-e2e-full
|
||||
if: ${{ always() && needs.lint.result == 'success' && needs.full-test-policy.outputs.should_run == 'true' && 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: Restore published badge state
|
||||
run: |
|
||||
git fetch origin "+refs/heads/badges:refs/remotes/origin/badges" || true
|
||||
if git show-ref --verify --quiet refs/remotes/origin/badges && \
|
||||
git cat-file -e refs/remotes/origin/badges:latest/badges.json 2>/dev/null; then
|
||||
mkdir -p docs/badges
|
||||
git archive --format=tar refs/remotes/origin/badges latest | tar -x
|
||||
cp latest/* docs/badges/
|
||||
rm -rf latest
|
||||
else
|
||||
echo "No published badge state found."
|
||||
fi
|
||||
|
||||
- name: Update full E2E badge files
|
||||
env:
|
||||
USERFRONT_E2E_RESULT: ${{ needs.userfront-e2e-full.result }}
|
||||
USERFRONT_E2E_FULL: "true"
|
||||
USERFRONT_E2E_CHROMIUM_DESKTOP_RESULT: ${{ needs.userfront-e2e-full.outputs.chromium_desktop }}
|
||||
USERFRONT_E2E_CHROMIUM_MOBILE_RESULT: ${{ needs.userfront-e2e-full.outputs.chromium_mobile }}
|
||||
USERFRONT_E2E_FIREFOX_DESKTOP_RESULT: ${{ needs.userfront-e2e-full.outputs.firefox_desktop }}
|
||||
USERFRONT_E2E_FIREFOX_MOBILE_RESULT: ${{ needs.userfront-e2e-full.outputs.firefox_mobile }}
|
||||
USERFRONT_E2E_WEBKIT_DESKTOP_RESULT: ${{ needs.userfront-e2e-full.outputs.webkit_desktop }}
|
||||
USERFRONT_E2E_WEBKIT_MOBILE_RESULT: ${{ needs.userfront-e2e-full.outputs.webkit_mobile }}
|
||||
BADGE_UPDATE_CODE_CHECK: "false"
|
||||
BADGE_SOURCE_BRANCH: dev
|
||||
BADGE_SOURCE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
node scripts/update_code_check_badges.mjs
|
||||
cat docs/badges/badges.json
|
||||
|
||||
- name: Publish full E2E badge assets
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain docs/badges)" ]; then
|
||||
echo "No badge changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BADGE_BRANCH=badges
|
||||
BADGE_WORKTREE="$(mktemp -d)"
|
||||
BADGE_LATEST_DIR="${BADGE_WORKTREE}/latest"
|
||||
BADGE_SHA_DIR="${BADGE_WORKTREE}/dev/${GITHUB_SHA}"
|
||||
trap 'rm -rf "${BADGE_WORKTREE}"' EXIT
|
||||
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "gitea-actions@hmac.kr"
|
||||
|
||||
git fetch origin "+refs/heads/${BADGE_BRANCH}:refs/remotes/origin/${BADGE_BRANCH}" || true
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/${BADGE_BRANCH}"; then
|
||||
git worktree add --detach "${BADGE_WORKTREE}" "origin/${BADGE_BRANCH}"
|
||||
else
|
||||
git worktree add --detach "${BADGE_WORKTREE}"
|
||||
git -C "${BADGE_WORKTREE}" checkout --orphan "${BADGE_BRANCH}"
|
||||
git -C "${BADGE_WORKTREE}" rm -rf . || true
|
||||
fi
|
||||
|
||||
find "${BADGE_WORKTREE}" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
|
||||
mkdir -p "${BADGE_LATEST_DIR}" "${BADGE_SHA_DIR}"
|
||||
cp docs/badges/*.svg "${BADGE_LATEST_DIR}/"
|
||||
cp docs/badges/badges.json "${BADGE_LATEST_DIR}/badges.json"
|
||||
cp docs/badges/*.svg "${BADGE_SHA_DIR}/"
|
||||
cp docs/badges/badges.json "${BADGE_SHA_DIR}/badges.json"
|
||||
|
||||
git -C "${BADGE_WORKTREE}" add .
|
||||
if [ -z "$(git -C "${BADGE_WORKTREE}" status --porcelain)" ]; then
|
||||
echo "No published badge changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git -C "${BADGE_WORKTREE}" commit -m "chore: publish userfront e2e full badge [skip ci]"
|
||||
git -C "${BADGE_WORKTREE}" push origin HEAD:${BADGE_BRANCH}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -50,7 +50,12 @@ 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/
|
||||
.pnpm-store
|
||||
.playwright-mcp
|
||||
node_modules
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
- generic [ref=e4]:
|
||||
- generic [ref=e5]:
|
||||
- img [ref=e7]
|
||||
- generic [ref=e9]:
|
||||
- heading "Baron SSO" [level=1] [ref=e10]
|
||||
- paragraph [ref=e11]: Developer Control Plane
|
||||
- generic [ref=e12]:
|
||||
- generic [ref=e13]:
|
||||
- heading "개발자 포털 로그인" [level=3] [ref=e14]:
|
||||
- img [ref=e15]
|
||||
- text: 개발자 포털 로그인
|
||||
- paragraph [ref=e18]: Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
|
||||
- generic [ref=e19]:
|
||||
- button "SSO 계정으로 로그인" [ref=e20] [cursor=pointer]:
|
||||
- img [ref=e21]
|
||||
- text: SSO 계정으로 로그인
|
||||
- img [ref=e23]
|
||||
- paragraph [ref=e27]:
|
||||
- text: 개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
|
||||
- text: 민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||
- paragraph [ref=e32]:
|
||||
- text: 인증 정보가 없거나 로그인이 되지 않는 경우
|
||||
- text: 시스템 관리자에게 문의하세요.
|
||||
@@ -1,23 +0,0 @@
|
||||
- generic [ref=e4]:
|
||||
- generic [ref=e5]:
|
||||
- img [ref=e7]
|
||||
- generic [ref=e9]:
|
||||
- heading "Baron SSO" [level=1] [ref=e10]
|
||||
- paragraph [ref=e11]: Admin Control Plane
|
||||
- generic [ref=e12]:
|
||||
- generic [ref=e13]:
|
||||
- heading "관리자 로그인" [level=3] [ref=e14]:
|
||||
- img [ref=e15]
|
||||
- text: 관리자 로그인
|
||||
- paragraph [ref=e18]: Baron 통합 인증(SSO)을 통해 관리자 페이지에 접속합니다.
|
||||
- generic [ref=e19]:
|
||||
- button "SSO 계정으로 로그인" [ref=e20] [cursor=pointer]:
|
||||
- img [ref=e21]
|
||||
- text: SSO 계정으로 로그인
|
||||
- img [ref=e23]
|
||||
- paragraph [ref=e27]:
|
||||
- text: 관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
|
||||
- text: 민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||
- paragraph [ref=e32]:
|
||||
- text: 인증 정보가 없거나 로그인이 되지 않는 경우
|
||||
- text: 시스템 관리자에게 문의하세요.
|
||||
65
Makefile
65
Makefile
@@ -253,29 +253,45 @@ code-check-sync-userfront-locales:
|
||||
|
||||
code-check-userfront-install:
|
||||
@echo "==> install userfront dependencies"
|
||||
cd userfront && flutter pub get
|
||||
@if command -v flutter >/dev/null 2>&1; then \
|
||||
cd userfront && flutter pub get; \
|
||||
else \
|
||||
echo "WARNING: flutter not found, skipping userfront dependencies install."; \
|
||||
fi
|
||||
|
||||
code-check-userfront-lint:
|
||||
@echo "==> userfront format/analyze"
|
||||
cd userfront && dart format --output=none --set-exit-if-changed lib test
|
||||
cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos
|
||||
@if command -v dart >/dev/null 2>&1; then \
|
||||
cd userfront && dart format --output=none --set-exit-if-changed lib test; \
|
||||
else \
|
||||
echo "WARNING: dart not found, skipping userfront format check."; \
|
||||
fi
|
||||
@if command -v flutter >/dev/null 2>&1; then \
|
||||
cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos; \
|
||||
else \
|
||||
echo "WARNING: flutter not found, skipping userfront analyze."; \
|
||||
fi
|
||||
|
||||
code-check-front-lint:
|
||||
@echo "==> adminfront biome lint/format check"
|
||||
rm -rf adminfront/playwright-report adminfront/test-results
|
||||
cd adminfront && pnpm install --frozen-lockfile --ignore-scripts
|
||||
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
cd adminfront && CI=true npx pnpm install --frozen-lockfile --ignore-scripts
|
||||
cd adminfront && npx biome lint .
|
||||
cd adminfront && npx biome format .
|
||||
@echo "==> devfront biome lint/format check"
|
||||
rm -rf devfront/playwright-report devfront/test-results
|
||||
cd devfront && npm ci --ignore-scripts
|
||||
cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
@if [ -d devfront/node_modules ]; then \
|
||||
echo "devfront/node_modules already present; skipping npm install."; \
|
||||
else \
|
||||
cd devfront && npm ci --ignore-scripts; \
|
||||
fi
|
||||
cd devfront && npx biome lint .
|
||||
cd devfront && npx biome format .
|
||||
@echo "==> orgfront biome lint/format check"
|
||||
rm -rf orgfront/playwright-report orgfront/test-results
|
||||
cd orgfront && npm ci --ignore-scripts
|
||||
cd orgfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd orgfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
cd orgfront && npx biome lint .
|
||||
cd orgfront && npx biome format .
|
||||
|
||||
code-check-backend-tests:
|
||||
@echo "==> backend tests"
|
||||
@@ -283,7 +299,11 @@ code-check-backend-tests:
|
||||
|
||||
code-check-userfront-tests:
|
||||
@echo "==> userfront tests (isolated workspace)"
|
||||
@tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-tests.XXXXXX)"; \
|
||||
@if ! command -v flutter >/dev/null 2>&1; then \
|
||||
echo "WARNING: flutter not found, skipping userfront tests."; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-tests.XXXXXX)"; \
|
||||
trap 'rm -rf "$$tmp_dir"' EXIT INT TERM; \
|
||||
mkdir -p "$$tmp_dir/scripts"; \
|
||||
cp scripts/sync_userfront_locales.sh "$$tmp_dir/scripts/"; \
|
||||
@@ -312,7 +332,14 @@ code-check-devfront-tests:
|
||||
@mkdir -p reports/devfront
|
||||
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
|
||||
@status=0; \
|
||||
(cd devfront && npm ci --ignore-scripts) || status=$$?; \
|
||||
preview_pattern='[v]ite preview --host 127.0.0.1 --strictPort --port 4174'; \
|
||||
pkill -f "$$preview_pattern" >/dev/null 2>&1 || true; \
|
||||
trap 'pkill -f "$$preview_pattern" >/dev/null 2>&1 || true' EXIT INT TERM; \
|
||||
if [ -d devfront/node_modules ]; then \
|
||||
echo "devfront/node_modules already present; skipping npm install."; \
|
||||
else \
|
||||
(cd devfront && npm ci --ignore-scripts) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||
fi; \
|
||||
@@ -341,9 +368,13 @@ code-check-orgfront-tests:
|
||||
|
||||
code-check-userfront-e2e-tests:
|
||||
@echo "==> userfront wasm playwright e2e tests (isolated workspace)"
|
||||
@mkdir -p reports/userfront-e2e
|
||||
@rm -rf reports/userfront-e2e/playwright-report reports/userfront-e2e/test-results
|
||||
@tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-e2e-tests.XXXXXX)"; \
|
||||
@if ! command -v flutter >/dev/null 2>&1; then \
|
||||
echo "WARNING: flutter not found, skipping userfront e2e tests."; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
mkdir -p reports/userfront-e2e; \
|
||||
rm -rf reports/userfront-e2e/playwright-report reports/userfront-e2e/test-results; \
|
||||
tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-e2e-tests.XXXXXX)"; \
|
||||
trap 'rm -rf "$$tmp_dir"' EXIT INT TERM; \
|
||||
mkdir -p "$$tmp_dir/scripts"; \
|
||||
cp scripts/sync_userfront_locales.sh "$$tmp_dir/scripts/"; \
|
||||
@@ -376,7 +407,7 @@ code-check-userfront-e2e-tests:
|
||||
(cd "$$tmp_dir/userfront" && flutter build web --wasm --release) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_CHROMIUM)) || status=$$?; \
|
||||
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Baron SSO
|
||||
|
||||
[](https://gitea.hmac.kr/baron/baron-sso/src/branch/dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
||||
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
||||
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/userfront_e2e_full_nightly.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/userfront_e2e_full_nightly.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/userfront_e2e_full_nightly.yml?branch=dev)
|
||||
|
||||
badge는 `Code Check`가 `badges` 브랜치의 `latest/`와 `dev/<commit-sha>/`에 발행합니다. 최신 HTML/LCOV/JSON summary는 Gitea `Code Check`의 패키지별 `*-vitest-coverage-report` artifact에서 확인할 수 있습니다.
|
||||
|
||||
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
||||
|
||||
## 📂 프로젝트 구조 (Project Structure)
|
||||
|
||||
@@ -2,15 +2,19 @@ FROM node:lts
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Set CI environment variable to true to avoid TTY issues with pnpm
|
||||
ENV CI=true
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.5.2 --activate
|
||||
|
||||
# Copy workspace configs and common package
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY common ./common
|
||||
COPY adminfront ./adminfront
|
||||
|
||||
# Install dependencies for the workspace
|
||||
RUN cd common && pnpm install --no-frozen-lockfile --ignore-scripts
|
||||
RUN pnpm install --filter adminfront... --filter baron-sso... --no-frozen-lockfile --ignore-scripts
|
||||
|
||||
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||
RUN npm install -g serve
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["../common/config/biome.base.json"],
|
||||
"files": {
|
||||
"ignore": [".vite"]
|
||||
"includes": [".vite"]
|
||||
}
|
||||
}
|
||||
|
||||
577
adminfront/package-lock.json
generated
577
adminfront/package-lock.json
generated
@@ -32,6 +32,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -41,13 +42,15 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"playwright": "1.60.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.12",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"engines": {
|
||||
@@ -130,14 +133,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -145,17 +148,42 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
@@ -166,6 +194,205 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz",
|
||||
"integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
"biome": "bin/biome"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.16",
|
||||
"@biomejs/cli-darwin-x64": "2.4.16",
|
||||
"@biomejs/cli-linux-arm64": "2.4.16",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.16",
|
||||
"@biomejs/cli-linux-x64": "2.4.16",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.16",
|
||||
"@biomejs/cli-win32-arm64": "2.4.16",
|
||||
"@biomejs/cli-win32-x64": "2.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz",
|
||||
"integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz",
|
||||
"integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz",
|
||||
"integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz",
|
||||
"integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz",
|
||||
"integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz",
|
||||
"integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz",
|
||||
"integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz",
|
||||
"integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
@@ -506,9 +733,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.130.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
|
||||
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
|
||||
"version": "0.132.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
|
||||
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2002,9 +2229,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2019,9 +2246,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2036,9 +2263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2053,9 +2280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2070,9 +2297,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
|
||||
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
|
||||
"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2087,13 +2314,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2104,13 +2334,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2121,13 +2354,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2138,13 +2374,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2155,13 +2394,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2172,13 +2414,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2189,9 +2434,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2206,9 +2451,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
|
||||
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
|
||||
"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -2225,9 +2470,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2242,9 +2487,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2572,6 +2817,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
|
||||
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.2",
|
||||
"obug": "^2.1.1",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.6",
|
||||
"vitest": "4.1.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||
@@ -2782,6 +3058,25 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz",
|
||||
"integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -3541,6 +3836,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -3593,6 +3898,13 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -3709,6 +4021,45 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -4122,6 +4473,34 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
|
||||
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@babel/types": "^7.29.0",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -4390,9 +4769,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4410,7 +4789,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -4854,13 +5233,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
||||
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.130.0",
|
||||
"@oxc-project/types": "=0.132.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4870,21 +5249,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.1",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.1",
|
||||
"@rolldown/binding-darwin-x64": "1.0.1",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.1",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.1",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.1",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.1",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.1",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
||||
"@rolldown/binding-android-arm64": "1.0.2",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.2",
|
||||
"@rolldown/binding-darwin-x64": "1.0.2",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.2",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.2",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.2",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.2",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.2",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.2",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
@@ -4930,6 +5309,19 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
@@ -5003,6 +5395,19 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -5373,16 +5778,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.13",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||
"version": "8.0.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
||||
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.14",
|
||||
"rolldown": "1.0.1",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.2",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"format": "biome format . --write",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:unit": "vitest run",
|
||||
"test:coverage": "vitest run --coverage --bail 1",
|
||||
"test:unit": "vitest run --bail 1",
|
||||
"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,13 +54,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +85,10 @@ export default defineConfig({
|
||||
? undefined
|
||||
: {
|
||||
command: process.env.CI
|
||||
? `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`
|
||||
: `npm run dev -- --host 127.0.0.1 --port ${port}`,
|
||||
url: defaultBaseUrl,
|
||||
? `pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort`
|
||||
: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
reuseExistingServer,
|
||||
timeout: 120 * 1000,
|
||||
timeout: 180 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
419
adminfront/pnpm-lock.yaml
generated
419
adminfront/pnpm-lock.yaml
generated
@@ -75,6 +75,9 @@ importers:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.4.16
|
||||
version: 2.4.16
|
||||
'@playwright/test':
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
@@ -101,13 +104,19 @@ importers:
|
||||
version: 5.3.3
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.2(vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7))
|
||||
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
|
||||
@@ -121,11 +130,11 @@ importers:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.12
|
||||
version: 8.0.13(@types/node@25.8.0)(jiti@1.21.7)
|
||||
specifier: ^8.0.14
|
||||
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.13(@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
|
||||
@@ -269,8 +356,8 @@ packages:
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@oxc-project/types@0.130.0':
|
||||
resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
|
||||
'@oxc-project/types@0.132.0':
|
||||
resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
|
||||
@@ -673,97 +760,97 @@ packages:
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.1':
|
||||
resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==}
|
||||
'@rolldown/binding-android-arm64@1.0.2':
|
||||
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.1':
|
||||
resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==}
|
||||
'@rolldown/binding-darwin-arm64@1.0.2':
|
||||
resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.1':
|
||||
resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==}
|
||||
'@rolldown/binding-darwin-x64@1.0.2':
|
||||
resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.1':
|
||||
resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==}
|
||||
'@rolldown/binding-freebsd-x64@1.0.2':
|
||||
resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.1':
|
||||
resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==}
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
|
||||
resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.1':
|
||||
resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==}
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.2':
|
||||
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.1':
|
||||
resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==}
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.2':
|
||||
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.1':
|
||||
resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==}
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
|
||||
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.1':
|
||||
resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==}
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.2':
|
||||
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.1':
|
||||
resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==}
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.2':
|
||||
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.1':
|
||||
resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==}
|
||||
'@rolldown/binding-linux-x64-musl@1.0.2':
|
||||
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.1':
|
||||
resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==}
|
||||
'@rolldown/binding-openharmony-arm64@1.0.2':
|
||||
resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.1':
|
||||
resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==}
|
||||
'@rolldown/binding-wasm32-wasi@1.0.2':
|
||||
resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.1':
|
||||
resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==}
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.2':
|
||||
resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.1':
|
||||
resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==}
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.2':
|
||||
resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -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'}
|
||||
@@ -1515,6 +1643,10 @@ packages:
|
||||
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.15:
|
||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
pretty-format@27.5.1:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
@@ -1626,8 +1758,8 @@ packages:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rolldown@1.0.1:
|
||||
resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==}
|
||||
rolldown@1.0.2:
|
||||
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -1641,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==}
|
||||
|
||||
@@ -1666,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'}
|
||||
@@ -1779,8 +1920,8 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
vite@8.0.13:
|
||||
resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==}
|
||||
vite@8.0.14:
|
||||
resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -1928,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
|
||||
@@ -2028,7 +2219,7 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@oxc-project/types@0.130.0': {}
|
||||
'@oxc-project/types@0.132.0': {}
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
dependencies:
|
||||
@@ -2416,53 +2607,53 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.1':
|
||||
'@rolldown/binding-android-arm64@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.1':
|
||||
'@rolldown/binding-darwin-arm64@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.1':
|
||||
'@rolldown/binding-darwin-x64@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.1':
|
||||
'@rolldown/binding-freebsd-x64@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.1':
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.1':
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.1':
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.1':
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.1':
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.1':
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.1':
|
||||
'@rolldown/binding-linux-x64-musl@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.1':
|
||||
'@rolldown/binding-openharmony-arm64@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.1':
|
||||
'@rolldown/binding-wasm32-wasi@1.0.2':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.1':
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.1':
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.2':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
@@ -2567,10 +2758,24 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7))':
|
||||
'@vitejs/plugin-react@6.0.2(vite@8.0.14(@types/node@25.8.0)(jiti@1.21.7))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.0.13(@types/node@25.8.0)(jiti@1.21.7)
|
||||
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:
|
||||
@@ -2581,13 +2786,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.6(vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7))':
|
||||
'@vitest/mocker@4.1.6(vite@8.0.14(@types/node@25.8.0)(jiti@1.21.7))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.6
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.13(@types/node@25.8.0)(jiti@1.21.7)
|
||||
vite: 8.0.14(@types/node@25.8.0)(jiti@1.21.7)
|
||||
|
||||
'@vitest/pretty-format@4.1.6':
|
||||
dependencies:
|
||||
@@ -2646,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):
|
||||
@@ -2878,6 +3089,8 @@ snapshots:
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
@@ -2894,6 +3107,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -2935,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:
|
||||
@@ -3033,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: {}
|
||||
@@ -3139,6 +3379,12 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.15:
|
||||
dependencies:
|
||||
nanoid: 3.3.12
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
pretty-format@27.5.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
@@ -3234,26 +3480,26 @@ snapshots:
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rolldown@1.0.1:
|
||||
rolldown@1.0.2:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.130.0
|
||||
'@oxc-project/types': 0.132.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm64': 1.0.1
|
||||
'@rolldown/binding-darwin-arm64': 1.0.1
|
||||
'@rolldown/binding-darwin-x64': 1.0.1
|
||||
'@rolldown/binding-freebsd-x64': 1.0.1
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.1
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.0.1
|
||||
'@rolldown/binding-linux-arm64-musl': 1.0.1
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.0.1
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.0.1
|
||||
'@rolldown/binding-linux-x64-gnu': 1.0.1
|
||||
'@rolldown/binding-linux-x64-musl': 1.0.1
|
||||
'@rolldown/binding-openharmony-arm64': 1.0.1
|
||||
'@rolldown/binding-wasm32-wasi': 1.0.1
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.1
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.1
|
||||
'@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
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
@@ -3265,6 +3511,8 @@ snapshots:
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
semver@7.8.1: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
@@ -3289,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: {}
|
||||
@@ -3401,22 +3653,22 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7):
|
||||
vite@8.0.14(@types/node@25.8.0)(jiti@1.21.7):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.14
|
||||
rolldown: 1.0.1
|
||||
postcss: 8.5.15
|
||||
rolldown: 1.0.2
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
'@types/node': 25.8.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
|
||||
vitest@4.1.6(@types/node@25.8.0)(jsdom@28.1.0)(vite@8.0.13(@types/node@25.8.0)(jiti@1.21.7)):
|
||||
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.13(@types/node@25.8.0)(jiti@1.21.7))
|
||||
'@vitest/mocker': 4.1.6(vite@8.0.14(@types/node@25.8.0)(jiti@1.21.7))
|
||||
'@vitest/pretty-format': 4.1.6
|
||||
'@vitest/runner': 4.1.6
|
||||
'@vitest/snapshot': 4.1.6
|
||||
@@ -3433,10 +3685,11 @@ snapshots:
|
||||
tinyexec: 1.1.2
|
||||
tinyglobby: 0.2.16
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.13(@types/node@25.8.0)(jiti@1.21.7)
|
||||
vite: 8.0.14(@types/node@25.8.0)(jiti@1.21.7)
|
||||
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
|
||||
|
||||
7
adminfront/public/LINE_WORKS_Appicon.svg
Normal file
7
adminfront/public/LINE_WORKS_Appicon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.5556 0H6.44444C2.88528 0 0 2.88528 0 6.44444V23.5556C0 27.1147 2.88528 30 6.44444 30H23.5556C27.1147 30 30 27.1147 30 23.5556V6.44444C30 2.88528 27.1147 0 23.5556 0Z" fill="white"/>
|
||||
<path d="M9.01667 23.2633H12.3633C12.4481 23.2637 12.5307 23.2363 12.5985 23.1853C12.6663 23.1344 12.7156 23.0627 12.7389 22.9811L17.0489 8.12111C17.0658 8.06285 17.0689 8.00146 17.058 7.9418C17.047 7.88214 17.0224 7.82583 16.986 7.77733C16.9495 7.72883 16.9023 7.68947 16.8481 7.66236C16.7938 7.63525 16.734 7.62112 16.6733 7.62111H13.3267C13.2419 7.62113 13.1595 7.64866 13.0918 7.69955C13.0241 7.75045 12.9747 7.82196 12.9511 7.90333L8.64222 22.7633C8.62512 22.8215 8.62182 22.8829 8.63258 22.9425C8.64334 23.0022 8.66787 23.0586 8.70422 23.1071C8.74057 23.1556 8.78773 23.195 8.84197 23.2222C8.89621 23.2493 8.95603 23.2634 9.01667 23.2633Z" fill="#028B3A"/>
|
||||
<path d="M18.0122 23.2633H21.3589C21.4436 23.2633 21.526 23.2358 21.5938 23.1849C21.6615 23.134 21.7109 23.0625 21.7344 22.9811L26.0433 8.12111C26.0602 8.06285 26.0633 8.00146 26.0524 7.9418C26.0415 7.88214 26.0168 7.82583 25.9804 7.77733C25.944 7.72883 25.8968 7.68947 25.8425 7.66236C25.7883 7.63525 25.7284 7.62112 25.6678 7.62111H22.3211C22.2364 7.62131 22.1541 7.64891 22.0864 7.69977C22.0187 7.75064 21.9693 7.82205 21.9456 7.90333L17.6367 22.7633C17.6195 22.8216 17.6163 22.8831 17.6271 22.9428C17.6379 23.0026 17.6625 23.059 17.699 23.1076C17.7355 23.1561 17.7828 23.1955 17.8372 23.2225C17.8915 23.2496 17.9515 23.2635 18.0122 23.2633Z" fill="#88E518"/>
|
||||
<path d="M12.3633 23.2633H8.64222C8.55741 23.2637 8.47481 23.2363 8.40701 23.1853C8.33921 23.1344 8.28993 23.0627 8.26666 22.9811L3.95666 8.12111C3.93977 8.06285 3.93667 8.00146 3.94759 7.9418C3.95851 7.88214 3.98316 7.82583 4.01959 7.77733C4.05602 7.72883 4.10322 7.68947 4.15748 7.66236C4.21174 7.63525 4.27156 7.62112 4.33222 7.62111H8.05444C8.13911 7.62131 8.22145 7.64891 8.28915 7.69977C8.35684 7.75064 8.40625 7.82205 8.43 7.90333L12.7389 22.7633C12.756 22.8216 12.7593 22.8831 12.7485 22.9428C12.7377 23.0026 12.713 23.059 12.6765 23.1076C12.6401 23.1561 12.5928 23.1955 12.5384 23.2225C12.484 23.2496 12.4241 23.2635 12.3633 23.2633Z" fill="#7EE3A1"/>
|
||||
<path d="M21.3589 23.2633H17.6367C17.5519 23.2637 17.4693 23.2363 17.4015 23.1853C17.3337 23.1344 17.2844 23.0627 17.2611 22.9811L12.9511 8.12111C12.9342 8.06285 12.9311 8.00146 12.942 7.9418C12.953 7.88214 12.9776 7.82583 13.014 7.77733C13.0505 7.72883 13.0977 7.68947 13.1519 7.66236C13.2062 7.63525 13.266 7.62112 13.3267 7.62111H17.0489C17.1336 7.62113 17.216 7.64866 17.2838 7.69955C17.3515 7.75045 17.4009 7.82196 17.4244 7.90333L21.7344 22.7633C21.7513 22.8216 21.7544 22.883 21.7435 22.9426C21.7326 23.0023 21.7079 23.0586 21.6715 23.1071C21.6351 23.1556 21.5879 23.195 21.5336 23.2221C21.4794 23.2492 21.4195 23.2633 21.3589 23.2633Z" fill="#03C75A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -36,15 +36,30 @@ if [ "${1:-}" = "--print-mode" ]; then
|
||||
fi
|
||||
|
||||
ensure_frontend_dependencies() {
|
||||
APP_WORKSPACE_FILTER="../adminfront"
|
||||
APP_PACKAGE_NAME="adminfront"
|
||||
|
||||
# If common workspace exists, manage dependencies from the real workspace tree.
|
||||
if [ -d /workspace/common ] && [ -f /workspace/common/package.json ]; then
|
||||
WORKSPACE_DIR="/workspace/common"
|
||||
LOCK_FILE="/workspace/common/pnpm-lock.yaml"
|
||||
# Detect workspace root
|
||||
if [ -f "/workspace/pnpm-workspace.yaml" ]; then
|
||||
WORKSPACE_ROOT="/workspace"
|
||||
elif [ -f "../../pnpm-workspace.yaml" ]; then
|
||||
WORKSPACE_ROOT="../.."
|
||||
else
|
||||
WORKSPACE_ROOT=""
|
||||
fi
|
||||
|
||||
# Manage dependencies from the real workspace tree if possible, otherwise use current dir.
|
||||
if [ -n "$WORKSPACE_ROOT" ]; then
|
||||
WORKSPACE_DIR="$WORKSPACE_ROOT"
|
||||
LOCK_FILE="$WORKSPACE_ROOT/pnpm-lock.yaml"
|
||||
INSTALL_CMD="cd $WORKSPACE_ROOT && CI=true pnpm install --filter ${APP_PACKAGE_NAME}... --frozen-lockfile --ignore-scripts"
|
||||
elif [ -f "pnpm-lock.yaml" ]; then
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="pnpm-lock.yaml"
|
||||
INSTALL_CMD="CI=true pnpm install --frozen-lockfile --ignore-scripts"
|
||||
else
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="package-lock.json"
|
||||
INSTALL_CMD="npm ci"
|
||||
fi
|
||||
|
||||
if [ ! -f "$WORKSPACE_DIR/package.json" ]; then
|
||||
@@ -85,9 +100,9 @@ ensure_frontend_dependencies() {
|
||||
}
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | cksum | awk '{print $1}')"
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
fi
|
||||
deps_stamp="node_modules/.baron-deps-hash"
|
||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||
@@ -96,20 +111,18 @@ ensure_frontend_dependencies() {
|
||||
echo "Installing frontend dependencies..."
|
||||
acquire_install_lock
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | cksum | awk '{print $1}')"
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
fi
|
||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||
if [ "$installed_hash" = "$deps_hash" ]; then
|
||||
release_install_lock
|
||||
return 0
|
||||
fi
|
||||
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
||||
(cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
|
||||
else
|
||||
npm ci
|
||||
fi
|
||||
|
||||
eval "$INSTALL_CMD"
|
||||
|
||||
mkdir -p node_modules
|
||||
printf '%s\n' "$deps_hash" > "$deps_stamp"
|
||||
release_install_lock
|
||||
@@ -120,6 +133,7 @@ ensure_frontend_dependencies
|
||||
|
||||
if [ "$mode" = "production" ]; then
|
||||
echo "Running in production mode with custom static server..."
|
||||
export ADMINFRONT_BUILD_OUT_DIR="${ADMINFRONT_BUILD_OUT_DIR:-/tmp/baron-sso-adminfront-dist}"
|
||||
exec sh -c "npm run build && node ./scripts/serve-prod.mjs"
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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";
|
||||
|
||||
const rootDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
const _rootDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
const distDir = resolve(
|
||||
process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist",
|
||||
);
|
||||
@@ -24,7 +24,9 @@ const contentTypes = {
|
||||
};
|
||||
|
||||
function getContentType(filePath) {
|
||||
return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
||||
return (
|
||||
contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, body) {
|
||||
@@ -132,7 +134,10 @@ async function serveStatic(req, res, pathname) {
|
||||
|
||||
createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
const url = new URL(
|
||||
req.url ?? "/",
|
||||
`http://${req.headers.host ?? "localhost"}`,
|
||||
);
|
||||
const { pathname, search } = url;
|
||||
|
||||
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
||||
@@ -149,5 +154,7 @@ createServer(async (req, res) => {
|
||||
});
|
||||
}
|
||||
}).listen(port, host, () => {
|
||||
console.log(`Adminfront production server listening on http://${host}:${port}`);
|
||||
console.log(
|
||||
`Adminfront production server listening on http://${host}:${port}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
id,name,type,parent_tenant_slug,slug,memo,email_domain
|
||||
038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,
|
||||
9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com
|
||||
369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr
|
||||
5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr
|
||||
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,
|
||||
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com
|
||||
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr
|
||||
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,baron-group,hanlla,,hanllasanup.co.kr
|
||||
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr
|
||||
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,
|
||||
id,name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync
|
||||
038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,,,,
|
||||
5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr,,,
|
||||
9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com,,,
|
||||
369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr,,,
|
||||
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,brsw.kr,,,
|
||||
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,hanmac-family,halla,네이버웍스 한라 HALLA_DOMAIN_ID,hallasanup.com,,,
|
||||
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com,,,
|
||||
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr,,,
|
||||
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr,,,
|
||||
4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,MH_manager,USER_GROUP,hanmac-family,mhd,맨아워 대시보드 권한 보유자그룹,,private,,no
|
||||
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,,
|
||||
|
||||
|
@@ -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";
|
||||
@@ -19,7 +19,6 @@ import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"
|
||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
|
||||
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
||||
import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage";
|
||||
import UserCreatePage from "../features/users/UserCreatePage";
|
||||
import UserDetailPage from "../features/users/UserDetailPage";
|
||||
import UserListPage from "../features/users/UserListPage";
|
||||
@@ -49,6 +48,7 @@ export const adminRoutes: RouteObject[] = [
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
@@ -57,7 +57,6 @@ export const adminRoutes: RouteObject[] = [
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||
|
||||
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
170
adminfront/src/components/layout/AppLayout.test.tsx
Normal file
170
adminfront/src/components/layout/AppLayout.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import AppLayout from "./AppLayout";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 120,
|
||||
profile: {
|
||||
sub: "admin-1",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
},
|
||||
},
|
||||
signinSilent: vi.fn(async () => undefined),
|
||||
removeUser: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-1",
|
||||
name: "Fetched Admin",
|
||||
email: "fetched@example.com",
|
||||
role: "super_admin",
|
||||
tenantId: "tenant-1",
|
||||
manageableTenants: [
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
type: "COMPANY",
|
||||
},
|
||||
{
|
||||
id: "tenant-2",
|
||||
name: "기술연구팀",
|
||||
slug: "gpdtdc-rnd",
|
||||
type: "ORGANIZATION",
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderLayout(entry = "/users") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route path="users" element={<div>Users outlet</div>} />
|
||||
<Route path="users/:id" element={<div>User detail outlet</div>} />
|
||||
<Route
|
||||
path="tenants/:tenantId"
|
||||
element={<div>Tenant outlet</div>}
|
||||
/>
|
||||
<Route path="worksmobile" element={<div>Worksmobile outlet</div>} />
|
||||
<Route path="login" element={<div>Login outlet</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin AppLayout", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
authState.isAuthenticated = true;
|
||||
authState.isLoading = false;
|
||||
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
|
||||
authState.signinSilent.mockClear();
|
||||
authState.removeUser.mockClear();
|
||||
window.localStorage.clear();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("renders admin navigation, fetched profile, and outlet content", async () => {
|
||||
renderLayout();
|
||||
|
||||
expect(await screen.findByText("Fetched Admin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Admin Control")).toBeInTheDocument();
|
||||
expect(screen.getByText("Users outlet")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tenants")).toBeInTheDocument();
|
||||
expect(screen.getByText("Org Chart")).toBeInTheDocument();
|
||||
expect(screen.getByText("Worksmobile")).toBeInTheDocument();
|
||||
expect(screen.getByText("User Projection")).toBeInTheDocument();
|
||||
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
|
||||
const navigation = screen.getByRole("navigation");
|
||||
const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) =>
|
||||
link.textContent?.trim(),
|
||||
);
|
||||
expect(navLabels).toEqual([
|
||||
"Overview",
|
||||
"Tenants",
|
||||
"Org Chart",
|
||||
"Worksmobile",
|
||||
"User Projection",
|
||||
"Data Integrity",
|
||||
"Users",
|
||||
"Auth Guard",
|
||||
"API Keys",
|
||||
"Audit Logs",
|
||||
]);
|
||||
const worksmobileIcon = screen.getByTestId("worksmobile-nav-icon");
|
||||
expect(worksmobileIcon.tagName.toLowerCase()).toBe("svg");
|
||||
expect(worksmobileIcon).toHaveAttribute("fill", "none");
|
||||
expect(worksmobileIcon.querySelectorAll("path")).toHaveLength(4);
|
||||
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
|
||||
renderLayout();
|
||||
|
||||
const themeButton = await screen.findByRole("button", {
|
||||
name: "테마 전환",
|
||||
});
|
||||
fireEvent.click(themeButton);
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||
expect(screen.getByText("Manageable Tenants")).toBeInTheDocument();
|
||||
|
||||
const sessionSwitch = screen.getByRole("switch");
|
||||
fireEvent.click(sessionSwitch);
|
||||
expect(window.localStorage.getItem("baron_session_expiry_enabled")).toBe(
|
||||
"false",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("기술연구팀"));
|
||||
expect(await screen.findByText("Tenant outlet")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||
fireEvent.click(screen.getAllByText("내 정보")[0]);
|
||||
expect(await screen.findByText("User detail outlet")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||
fireEvent.click(screen.getAllByText("Logout")[1]);
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(authState.removeUser).toHaveBeenCalled();
|
||||
}, 10_000);
|
||||
|
||||
it("attempts silent renewal on user activity when session is near expiry", async () => {
|
||||
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
|
||||
|
||||
renderLayout();
|
||||
await screen.findByText("Fetched Admin");
|
||||
fireEvent.keyDown(window, { key: "Tab" });
|
||||
|
||||
expect(authState.signinSilent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,16 +22,17 @@ 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";
|
||||
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
|
||||
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { debugLog } from "../../lib/debugLog";
|
||||
@@ -42,10 +43,8 @@ import {
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
} from "../../lib/sessionSliding";
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import RoleSwitcher from "./RoleSwitcher";
|
||||
|
||||
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
|
||||
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
|
||||
|
||||
const staticNavItems: ShellSidebarNavItem[] = [
|
||||
{
|
||||
@@ -61,6 +60,12 @@ const staticNavItems: ShellSidebarNavItem[] = [
|
||||
to: "/users",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.auth_guard",
|
||||
labelFallback: "Auth Guard",
|
||||
to: "/auth",
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.api_keys",
|
||||
labelFallback: "API Keys",
|
||||
@@ -73,12 +78,6 @@ const staticNavItems: ShellSidebarNavItem[] = [
|
||||
to: "/audit-logs",
|
||||
icon: NotebookTabs,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.auth_guard",
|
||||
labelFallback: "Auth Guard",
|
||||
to: "/auth",
|
||||
icon: KeyRound,
|
||||
},
|
||||
];
|
||||
|
||||
type SessionStatusProps = {
|
||||
@@ -123,6 +122,38 @@ function SessionStatusText(props: SessionStatusProps) {
|
||||
return <>{sessionStatus.text}</>;
|
||||
}
|
||||
|
||||
function LineWorksNavIcon({ size = 18 }: { size?: number | string }) {
|
||||
const iconSize = typeof size === "number" ? size : Number.parseFloat(size);
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-testid="worksmobile-nav-icon"
|
||||
width={Number.isFinite(iconSize) ? iconSize : size}
|
||||
height={Number.isFinite(iconSize) ? iconSize : size}
|
||||
viewBox="0 0 30 30"
|
||||
fill="none"
|
||||
className="shrink-0 text-current"
|
||||
>
|
||||
<path
|
||||
d="M9.01667 23.2633H12.3633C12.4481 23.2637 12.5307 23.2363 12.5985 23.1853C12.6663 23.1344 12.7156 23.0627 12.7389 22.9811L17.0489 8.12111C17.0658 8.06285 17.0689 8.00146 17.058 7.9418C17.047 7.88214 17.0224 7.82583 16.986 7.77733C16.9495 7.72883 16.9023 7.68947 16.8481 7.66236C16.7938 7.63525 16.734 7.62112 16.6733 7.62111H13.3267C13.2419 7.62113 13.1595 7.64866 13.0918 7.69955C13.0241 7.75045 12.9747 7.82196 12.9511 7.90333L8.64222 22.7633C8.62512 22.8215 8.62182 22.8829 8.63258 22.9425C8.64334 23.0022 8.66787 23.0586 8.70422 23.1071C8.74057 23.1556 8.78773 23.195 8.84197 23.2222C8.89621 23.2493 8.95603 23.2634 9.01667 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M18.0122 23.2633H21.3589C21.4436 23.2633 21.526 23.2358 21.5938 23.1849C21.6615 23.134 21.7109 23.0625 21.7344 22.9811L26.0433 8.12111C26.0602 8.06285 26.0633 8.00146 26.0524 7.9418C26.0415 7.88214 26.0168 7.82583 25.9804 7.77733C25.944 7.72883 25.8968 7.68947 25.8425 7.66236C25.7883 7.63525 25.7284 7.62112 25.6678 7.62111H22.3211C22.2364 7.62131 22.1541 7.64891 22.0864 7.69977C22.0187 7.75064 21.9693 7.82205 21.9456 7.90333L17.6367 22.7633C17.6195 22.8216 17.6163 22.8831 17.6271 22.9428C17.6379 23.0026 17.6625 23.059 17.699 23.1076C17.7355 23.1561 17.7828 23.1955 17.8372 23.2225C17.8915 23.2496 17.9515 23.2635 18.0122 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M12.3633 23.2633H8.64222C8.55741 23.2637 8.47481 23.2363 8.40701 23.1853C8.33921 23.1344 8.28993 23.0627 8.26666 22.9811L3.95666 8.12111C3.93977 8.06285 3.93667 8.00146 3.94759 7.9418C3.95851 7.88214 3.98316 7.82583 4.01959 7.77733C4.05602 7.72883 4.10322 7.68947 4.15748 7.66236C4.21174 7.63525 4.27156 7.62112 4.33222 7.62111H8.05444C8.13911 7.62131 8.22145 7.64891 8.28915 7.69977C8.35684 7.75064 8.40625 7.82205 8.43 7.90333L12.7389 22.7633C12.756 22.8216 12.7593 22.8831 12.7485 22.9428C12.7377 23.0026 12.713 23.059 12.6765 23.1076C12.6401 23.1561 12.5928 23.1955 12.5384 23.2225C12.484 23.2496 12.4241 23.2635 12.3633 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M21.3589 23.2633H17.6367C17.5519 23.2637 17.4693 23.2363 17.4015 23.1853C17.3337 23.1344 17.2844 23.0627 17.2611 22.9811L12.9511 8.12111C12.9342 8.06285 12.9311 8.00146 12.942 7.9418C12.953 7.88214 12.9776 7.82583 13.014 7.77733C13.0505 7.72883 13.0977 7.68947 13.1519 7.66236C13.2062 7.63525 13.266 7.62112 13.3267 7.62111H17.0489C17.1336 7.62113 17.216 7.64866 17.2838 7.69955C17.3515 7.75045 17.4009 7.82196 17.4244 7.90333L21.7344 22.7633C21.7513 22.8216 21.7544 22.883 21.7435 22.9426C21.7326 23.0023 21.7079 23.0586 21.6715 23.1071C21.6351 23.1556 21.5879 23.195 21.5336 23.2221C21.4794 23.2492 21.4195 23.2633 21.3589 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppLayout() {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
@@ -132,27 +163,12 @@ function AppLayout() {
|
||||
const lastRenewAttemptAtRef = useRef(0);
|
||||
const lastVisitedRouteRef = useRef<string | null>(null);
|
||||
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||
const isDevRoleOverrideEnabled =
|
||||
import.meta.env.MODE === "development" ||
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
const isMockRoleEnabled =
|
||||
isDevRoleOverrideEnabled &&
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
const mockRoleOverride = isMockRoleEnabled
|
||||
? window.localStorage.getItem("X-Mock-Role")
|
||||
: null;
|
||||
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
const [, setDevelopmentRenderRevision] = useState(0);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||
);
|
||||
const {
|
||||
data: profile,
|
||||
isLoading: isProfileLoading,
|
||||
error: profileError,
|
||||
} = useQuery({
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: async () => {
|
||||
debugLog("[AppLayout] Fetching profile...");
|
||||
@@ -174,25 +190,27 @@ function AppLayout() {
|
||||
|
||||
const navItems = React.useMemo<ShellSidebarNavItem[]>(() => {
|
||||
const items = [...staticNavItems];
|
||||
const isTest =
|
||||
const _isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
const effectiveRole = mockRoleOverride || profile?.role;
|
||||
const effectiveRole = profile?.role;
|
||||
|
||||
const isSuperAdmin = isSuperAdminRole(effectiveRole);
|
||||
const _manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||
const showWorksmobile = canAccessWorksmobile({
|
||||
...profile,
|
||||
role: effectiveRole ?? profile?.role,
|
||||
});
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (item.to === "/api-keys") return isSuperAdmin;
|
||||
return true;
|
||||
});
|
||||
|
||||
const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
|
||||
const isTenantAdmin = effectiveRole === "tenant_admin";
|
||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||
{ includeInternal: true },
|
||||
);
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (isTest) return true;
|
||||
if (item.to === "/api-keys") return isSuperAdmin;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (isSuperAdmin) {
|
||||
filteredItems.splice(1, 0, {
|
||||
labelKey: "ui.admin.nav.tenants",
|
||||
@@ -207,6 +225,14 @@ function AppLayout() {
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
if (showWorksmobile) {
|
||||
filteredItems.splice(3, 0, {
|
||||
labelKey: "ui.admin.nav.worksmobile",
|
||||
labelFallback: "Worksmobile",
|
||||
to: "/worksmobile",
|
||||
icon: LineWorksNavIcon,
|
||||
});
|
||||
}
|
||||
filteredItems.splice(4, 0, {
|
||||
labelKey: "ui.admin.nav.user_projection",
|
||||
labelFallback: "User Projection",
|
||||
@@ -219,35 +245,8 @@ function AppLayout() {
|
||||
to: "/system/data-integrity",
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
} else if (isTenantAdmin || manageableCount > 0) {
|
||||
if (manageableCount <= 1 && profile?.tenantId) {
|
||||
filteredItems.splice(1, 0, {
|
||||
labelKey: "ui.admin.nav.my_tenant",
|
||||
labelFallback: "My Tenant",
|
||||
to: `/tenants/${profile.tenantId}`,
|
||||
icon: Building2,
|
||||
});
|
||||
} else if (manageableCount > 1) {
|
||||
filteredItems.splice(1, 0, {
|
||||
labelKey: "ui.admin.nav.tenants",
|
||||
labelFallback: "Tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
}
|
||||
filteredItems.splice(
|
||||
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
|
||||
0,
|
||||
{
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
||||
// Non-superadmins
|
||||
filteredItems.splice(1, 0, {
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
@@ -255,10 +254,18 @@ function AppLayout() {
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
if (showWorksmobile) {
|
||||
filteredItems.splice(2, 0, {
|
||||
labelKey: "ui.admin.nav.worksmobile",
|
||||
labelFallback: "Worksmobile",
|
||||
to: "/worksmobile",
|
||||
icon: LineWorksNavIcon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filteredItems;
|
||||
}, [mockRoleOverride, profile]);
|
||||
}, [profile]);
|
||||
|
||||
const handleLogout = () => {
|
||||
if (
|
||||
@@ -303,20 +310,18 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
const rerenderDevelopmentShell = () => {
|
||||
setDevelopmentRenderRevision((value) => value + 1);
|
||||
// Re-render when locale changes
|
||||
};
|
||||
|
||||
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||
window.addEventListener(DEV_ROLE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||
window.removeEventListener(
|
||||
DEV_ROLE_CHANGED_EVENT,
|
||||
LOCALE_CHANGED_EVENT,
|
||||
rerenderDevelopmentShell,
|
||||
);
|
||||
};
|
||||
}, [isDevelopmentRuntime]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -429,7 +434,6 @@ function AppLayout() {
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isDevelopmentRuntime,
|
||||
isSessionExpiryEnabled,
|
||||
]);
|
||||
|
||||
@@ -496,7 +500,7 @@ function AppLayout() {
|
||||
fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
|
||||
fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
|
||||
});
|
||||
const profileRoleKey = mockRoleOverride || profile?.role || "user";
|
||||
const profileRoleKey = profile?.role || "user";
|
||||
const handleSessionExpiryToggle = () => {
|
||||
setIsSessionExpiryEnabled((prev) => {
|
||||
const next = !prev;
|
||||
@@ -668,7 +672,10 @@ function AppLayout() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("ui.shell.session.auto_extend", "세션 만료 관리")}
|
||||
{t(
|
||||
"ui.shell.session.auto_extend",
|
||||
"세션 만료 관리",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled ? (
|
||||
@@ -677,7 +684,10 @@ function AppLayout() {
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
t("ui.shell.session.disabled", "세션 만료 비활성화")
|
||||
t(
|
||||
"ui.shell.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -777,7 +787,6 @@ function AppLayout() {
|
||||
<main className={shellLayoutClasses.mainMinWidth}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<RoleSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { ChevronDown, ChevronUp, Wrench } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
|
||||
|
||||
const RoleSwitcher: FC = () => {
|
||||
const [currentRole, setCurrentRole] = useState<string>("");
|
||||
const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
return window.localStorage.getItem("RoleSwitcher-Collapsed") === "true";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const savedRole = window.localStorage.getItem("X-Mock-Role");
|
||||
const savedEnabled =
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
setIsOverrideEnabled(savedEnabled);
|
||||
if (savedRole) {
|
||||
setCurrentRole(savedRole);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleCollapse = () => {
|
||||
const nextState = !isCollapsed;
|
||||
setIsCollapsed(nextState);
|
||||
window.localStorage.setItem("RoleSwitcher-Collapsed", String(nextState));
|
||||
};
|
||||
|
||||
const switchRole = (role: string) => {
|
||||
window.localStorage.setItem("X-Mock-Role", role);
|
||||
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
|
||||
setCurrentRole(role);
|
||||
setIsOverrideEnabled(true);
|
||||
window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
|
||||
};
|
||||
|
||||
const clearRoleOverride = () => {
|
||||
window.localStorage.removeItem("X-Mock-Role-Enabled");
|
||||
setIsOverrideEnabled(false);
|
||||
window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
|
||||
};
|
||||
|
||||
if (import.meta.env.MODE === "production") return null;
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
super_admin: t("ui.admin.role.super_admin", "SUPER ADMIN"),
|
||||
tenant_admin: t("ui.admin.role.tenant_admin", "TENANT ADMIN"),
|
||||
rp_admin: t("ui.admin.role.rp_admin", "RP ADMIN"),
|
||||
user: t("ui.admin.role.user", "TENANT MEMBER"),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
zIndex: 9999,
|
||||
background: "#1A1F2C",
|
||||
color: "white",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: isCollapsed ? "0" : "8px",
|
||||
fontSize: "12px",
|
||||
transition: "all 0.3s ease",
|
||||
border: "1px solid #333",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
cursor: "pointer",
|
||||
fontWeight: "bold",
|
||||
paddingBottom: isCollapsed ? "0" : "4px",
|
||||
borderBottom: isCollapsed ? "none" : "1px solid #444",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
width: "100%",
|
||||
color: "inherit",
|
||||
textAlign: "inherit",
|
||||
}}
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<Wrench size={14} className="text-blue-400" />
|
||||
{!isCollapsed && (
|
||||
<span>{t("ui.admin.dev_role_switcher", "DEV Role Switcher")}</span>
|
||||
)}
|
||||
{isCollapsed && (
|
||||
<span style={{ fontSize: "10px", color: "#888" }}>
|
||||
{isOverrideEnabled && currentRole
|
||||
? currentRole.toUpperCase()
|
||||
: "REAL ROLE"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isCollapsed ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearRoleOverride}
|
||||
style={{
|
||||
background: !isOverrideEnabled ? "#3b82f6" : "#333",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "background 0.2s",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{t("ui.admin.dev_role_switcher_real", "실제 역할 사용")}
|
||||
</span>
|
||||
{!isOverrideEnabled && (
|
||||
<span style={{ marginLeft: "8px" }}>✅</span>
|
||||
)}
|
||||
</button>
|
||||
{(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map(
|
||||
(role) => (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
onClick={() => switchRole(role)}
|
||||
style={{
|
||||
background: currentRole === role ? "#3b82f6" : "#333",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "background 0.2s",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
|
||||
</span>
|
||||
{isOverrideEnabled && currentRole === role && (
|
||||
<span style={{ marginLeft: "8px" }}>✅</span>
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoleSwitcher;
|
||||
49
adminfront/src/components/ui/avatar.test.tsx
Normal file
49
adminfront/src/components/ui/avatar.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
const render = async (element: React.ReactElement) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(element);
|
||||
});
|
||||
return root;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (container) {
|
||||
container.remove();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("Avatar", () => {
|
||||
it("renders image and fallback with merged classes", async () => {
|
||||
const root = await render(
|
||||
<Avatar className="custom-root" data-testid="avatar">
|
||||
<AvatarImage
|
||||
alt="Admin user"
|
||||
className="custom-image"
|
||||
src="/avatar.png"
|
||||
/>
|
||||
<AvatarFallback className="custom-fallback">AU</AvatarFallback>
|
||||
</Avatar>,
|
||||
);
|
||||
|
||||
const avatar = container?.querySelector("[data-testid='avatar']");
|
||||
const fallback = container?.textContent;
|
||||
|
||||
expect(avatar?.className).toContain("custom-root");
|
||||
expect(fallback).toContain("AU");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,4 +44,4 @@ const AvatarFallback = React.forwardRef<
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
export { Avatar, AvatarFallback, AvatarImage };
|
||||
|
||||
@@ -50,9 +50,9 @@ function CardFooter({
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
};
|
||||
|
||||
@@ -144,18 +144,20 @@ const DialogClose = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
DialogClose.displayName = "DialogClose";
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
>(({ className, onMouseDown, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogOverlay");
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 border-0 bg-black/80 p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
data-state="open"
|
||||
aria-label="Close dialog"
|
||||
onMouseDown={composeEventHandlers(onMouseDown, (event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setOpen(false);
|
||||
@@ -273,13 +275,13 @@ DialogDescription.displayName = "DialogDescription";
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
@@ -183,18 +183,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -146,13 +146,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
||||
41
adminfront/src/components/ui/separator.test.tsx
Normal file
41
adminfront/src/components/ui/separator.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Separator } from "./separator";
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
const render = async (element: React.ReactElement) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(element);
|
||||
});
|
||||
return root;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (container) {
|
||||
container.remove();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("Separator", () => {
|
||||
it("renders a horizontal separator with custom classes", async () => {
|
||||
const root = await render(
|
||||
<Separator className="custom-separator" data-testid="separator" />,
|
||||
);
|
||||
|
||||
const separator = container?.querySelector("[data-testid='separator']");
|
||||
|
||||
expect(separator?.className).toContain("h-px");
|
||||
expect(separator?.className).toContain("custom-separator");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -92,11 +92,11 @@ TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
|
||||
@@ -84,4 +84,4 @@ const TabsContent = React.forwardRef<
|
||||
});
|
||||
TabsContent.displayName = "TabsContent";
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
|
||||
@@ -2,13 +2,6 @@ import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
formatAuditValue,
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
} from "../../../../common/core/audit";
|
||||
import { AuditLogTable } from "../../../../common/core/components/audit";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
@@ -24,6 +17,7 @@ import { Input } from "../../components/ui/input";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable";
|
||||
|
||||
function AuditLogsPage() {
|
||||
const [searchActorId, setSearchActorId] = React.useState("");
|
||||
@@ -41,8 +35,23 @@ function AuditLogsPage() {
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["audit-logs"],
|
||||
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
|
||||
queryKey: [
|
||||
"audit-logs",
|
||||
deferredSearchActorId,
|
||||
deferredSearchAction,
|
||||
statusFilter,
|
||||
],
|
||||
queryFn: ({ pageParam }) => {
|
||||
const search = [deferredSearchActorId, deferredSearchAction]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return fetchAuditLogs(
|
||||
50,
|
||||
pageParam,
|
||||
search || undefined,
|
||||
statusFilter === "all" ? undefined : statusFilter,
|
||||
);
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
@@ -52,45 +61,6 @@ function AuditLogsPage() {
|
||||
(page) =>
|
||||
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
||||
) ?? [];
|
||||
const filteredLogs = React.useMemo(
|
||||
() =>
|
||||
logs.filter((row) => {
|
||||
const details = parseAuditDetails(row.details);
|
||||
const actorLabel = resolveAuditActor(row, details).toLowerCase();
|
||||
const actionLabel = resolveAuditAction(row, details).toLowerCase();
|
||||
const matchesActor =
|
||||
deferredSearchActorId === "" ||
|
||||
actorLabel.includes(deferredSearchActorId.toLowerCase());
|
||||
const matchesAction =
|
||||
deferredSearchAction === "" ||
|
||||
actionLabel.includes(deferredSearchAction.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || row.status === statusFilter;
|
||||
return matchesActor && matchesAction && matchesStatus;
|
||||
}),
|
||||
[logs, deferredSearchActorId, deferredSearchAction, statusFilter],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.common.audit.loading", "Loading audit logs...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errMsg =
|
||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(error as Error).message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
|
||||
error: errMsg,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -105,7 +75,7 @@ function AuditLogsPage() {
|
||||
<>
|
||||
<Badge variant="muted">
|
||||
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: filteredLogs.length,
|
||||
count: logs.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Button
|
||||
@@ -138,65 +108,85 @@ function AuditLogsPage() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
}}
|
||||
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center" data-testid="audit-loading">
|
||||
{t("msg.common.audit.loading", "Loading audit logs...")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className="p-8 text-center text-red-500"
|
||||
data-testid="audit-error"
|
||||
>
|
||||
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
|
||||
error:
|
||||
(error as AxiosError<{ error?: string }>).response?.data
|
||||
?.error ?? (error as Error).message,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
}}
|
||||
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
data-testid="audit-search-user-id"
|
||||
value={searchActorId}
|
||||
onChange={(event) => setSearchActorId(event.target.value)}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.user_id",
|
||||
"Filter by User ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchActorId}
|
||||
onChange={(event) => setSearchActorId(event.target.value)}
|
||||
data-testid="audit-search-action"
|
||||
value={searchAction}
|
||||
onChange={(event) =>
|
||||
setSearchAction(event.target.value.toUpperCase())
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.user_id",
|
||||
"Filter by User ID",
|
||||
"ui.common.audit.filters.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(event) =>
|
||||
setSearchAction(event.target.value.toUpperCase())
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.common.audit.filters.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
<AuditLogTable
|
||||
logs={filteredLogs}
|
||||
t={t}
|
||||
loading={isLoading}
|
||||
hasNextPage={Boolean(hasNextPage)}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={() => fetchNextPage()}
|
||||
/>
|
||||
</CardContent>
|
||||
<select
|
||||
data-testid="audit-filter-status"
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.common.audit.filters.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
<VirtualizedAuditLogTable
|
||||
logs={logs}
|
||||
t={t}
|
||||
loading={isLoading}
|
||||
hasNextPage={Boolean(hasNextPage)}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={() => fetchNextPage()}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
475
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal file
475
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
formatAuditDateParts,
|
||||
formatAuditValue,
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
resolveAuditTarget,
|
||||
} from "../../../../common/core/audit";
|
||||
import {
|
||||
type CommonBadgeVariant,
|
||||
getCommonBadgeClasses,
|
||||
} from "../../../../common/ui/badge";
|
||||
import { getCommonButtonClasses } from "../../../../common/ui/button";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableBodyClass,
|
||||
commonTableCellClass,
|
||||
commonTableClass,
|
||||
commonTableHeadClass,
|
||||
commonTableHeaderClass,
|
||||
commonTableRowClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
commonTableWrapperClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
|
||||
type AuditTranslate = (
|
||||
key: string,
|
||||
fallback: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
type VirtualizedAuditLogTableProps = {
|
||||
logs: AuditLog[];
|
||||
t: AuditTranslate;
|
||||
loading: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onLoadMore: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function cx(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function statusVariant(status: string): CommonBadgeVariant {
|
||||
return status === "success" || status === "ok" ? "success" : "warning";
|
||||
}
|
||||
|
||||
export function VirtualizedAuditLogTable({
|
||||
logs,
|
||||
t,
|
||||
loading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
className,
|
||||
}: VirtualizedAuditLogTableProps) {
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const viewportRef = React.useRef<HTMLDivElement>(null);
|
||||
const isTest =
|
||||
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
|
||||
(typeof window !== "undefined" &&
|
||||
(window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE);
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: logs.length,
|
||||
getScrollElement: () => viewportRef.current,
|
||||
estimateSize: () => 80,
|
||||
measureElement: (el) => el.getBoundingClientRect().height,
|
||||
overscan: isTest ? logs.length : 10,
|
||||
initialRect: isTest ? { width: 1010, height: 1000 } : undefined,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isTest) {
|
||||
return;
|
||||
}
|
||||
const lastItem = virtualRows[virtualRows.length - 1];
|
||||
if (!lastItem) return;
|
||||
|
||||
if (
|
||||
lastItem.index >= logs.length - 1 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [
|
||||
virtualRows,
|
||||
logs.length,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
isTest,
|
||||
]);
|
||||
|
||||
const tableMinWidth = 1010;
|
||||
|
||||
const renderRow = (
|
||||
row: AuditLog,
|
||||
index: number,
|
||||
virtualRow?: { start: number; end: number },
|
||||
) => {
|
||||
if (!row) return null;
|
||||
|
||||
const details = parseAuditDetails(row.details);
|
||||
const actorLabel = resolveAuditActor(row, details);
|
||||
const actionLabel = resolveAuditAction(row, details);
|
||||
const targetLabel = resolveAuditTarget(details);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
const { date, time } = formatAuditDateParts(row.timestamp);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowKey}
|
||||
data-index={index}
|
||||
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
|
||||
className={cx(
|
||||
commonTableRowClass,
|
||||
"bg-card/40",
|
||||
virtualRow ? "absolute left-0 w-full" : "",
|
||||
)}
|
||||
style={
|
||||
virtualRow
|
||||
? {
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td colSpan={6} className="p-0">
|
||||
<div className={cx("flex items-center", expanded && "border-b")}>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[190px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(commonTableCellClass, "w-[180px] shrink-0")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{actorLabel}
|
||||
</code>
|
||||
{actorLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.actor_id",
|
||||
"Copy User ID",
|
||||
)}
|
||||
onClick={() => handleCopy(actorLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[180px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="font-semibold text-foreground">{actionLabel}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[260px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetLabel}</span>
|
||||
{targetLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.target",
|
||||
"Copy Client ID",
|
||||
)}
|
||||
onClick={() => handleCopy(targetLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(commonTableCellClass, "w-[120px] shrink-0")}>
|
||||
<span
|
||||
className={getCommonBadgeClasses({
|
||||
variant: statusVariant(row.status),
|
||||
})}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[80px] shrink-0 text-right",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
})}
|
||||
onClick={() => {
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}));
|
||||
// Re-measure after state change
|
||||
setTimeout(() => rowVirtualizer.measure(), 0);
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className={cx(commonTableCellClass, "bg-card/20 text-xs")}>
|
||||
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.request", "Request")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{ value: formatAuditValue(details.request_id) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{ value: formatAuditValue(row.event_id) },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.ip", "IP · {{value}}", {
|
||||
value: formatAuditValue(row.ip_address),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.method", "Method · {{value}}", {
|
||||
value: formatAuditValue(details.method),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.path", "Path · {{value}}", {
|
||||
value: formatAuditValue(details.path),
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.actor_id",
|
||||
"User ID · {{value}}",
|
||||
{ value: actorLabel },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.tenant", "Tenant · {{value}}", {
|
||||
value: formatAuditValue(details.tenant_id),
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.device", "Device · {{value}}", {
|
||||
value: formatAuditValue(row.device_id),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.target",
|
||||
"Client ID · {{value}}",
|
||||
{ value: targetLabel },
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.result", "Result")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.error", "Error · {{value}}", {
|
||||
value: formatAuditValue(details.error),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.before", "Before · {{value}}", {
|
||||
value: formatAuditValue(details.before),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.after", "After · {{value}}", {
|
||||
value: formatAuditValue(details.after),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(commonTableShellClass, className)}>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className={cx(commonTableViewportClass, "flex-1")}
|
||||
data-testid="audit-table-viewport"
|
||||
>
|
||||
<div
|
||||
className={commonTableWrapperClass}
|
||||
style={{ minWidth: tableMinWidth }}
|
||||
>
|
||||
<table
|
||||
className={cx(commonTableClass, "table-fixed w-full")}
|
||||
style={{ borderCollapse: "separate", borderSpacing: 0 }}
|
||||
>
|
||||
<thead
|
||||
className={cx(
|
||||
commonTableHeaderClass,
|
||||
commonStickyTableHeaderClass,
|
||||
)}
|
||||
>
|
||||
<tr className={commonTableRowClass}>
|
||||
<th className={cx(commonTableHeadClass, "w-[190px]")}>
|
||||
{t("ui.common.audit.table.time", "Time")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.user_id", "User ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.action", "Action")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[260px]")}>
|
||||
{t("ui.common.audit.table.client_id", "Client ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[120px]")}>
|
||||
{t("ui.common.audit.table.status", "Status")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[80px]")} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={commonTableBodyClass}
|
||||
style={
|
||||
!isTest
|
||||
? {
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isTest
|
||||
? logs.map((row, index) => renderRow(row, index))
|
||||
: virtualRows.map((virtualRow) =>
|
||||
renderRow(
|
||||
logs[virtualRow.index],
|
||||
virtualRow.index,
|
||||
virtualRow,
|
||||
),
|
||||
)}
|
||||
{logs.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"text-center py-8 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("ui.common.audit.table.no_logs", "No audit logs found")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 border-t bg-background/50 p-4 text-center backdrop-blur-sm">
|
||||
{hasNextPage ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{isFetchingNextPage && (
|
||||
<span className="animate-pulse text-xs text-muted-foreground">
|
||||
{t("msg.common.loading", "Loading more...")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.common.audit.load_more", "더 보기")}
|
||||
</Button>
|
||||
</div>
|
||||
) : logs.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("msg.common.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
76
adminfront/src/features/auth/LoginPage.test.tsx
Normal file
76
adminfront/src/features/auth/LoginPage.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
const mockSigninRedirect = vi.fn();
|
||||
const mockUseAuth = vi.fn();
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
function renderLoginPage(initialEntry: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<LoginPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "crypto", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
mockSigninRedirect.mockReset();
|
||||
mockUseAuth.mockReturnValue({
|
||||
activeNavigator: undefined,
|
||||
error: undefined,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
signinRedirect: mockSigninRedirect,
|
||||
});
|
||||
});
|
||||
|
||||
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 }),
|
||||
);
|
||||
|
||||
expect(mockSigninRedirect).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
/SSO 로그인을 시작할 수 없습니다/,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the returnTo query when starting SSO manually", async () => {
|
||||
Object.defineProperty(window, "crypto", {
|
||||
configurable: true,
|
||||
value: { subtle: {} },
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
renderLoginPage("/login?returnTo=%2Fusers%3Fpage%3D2");
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
|
||||
);
|
||||
|
||||
expect(mockSigninRedirect).toHaveBeenCalledWith({
|
||||
state: {
|
||||
returnTo: "/users?page=2",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AlertTriangle, ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -10,15 +10,36 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { canStartBrowserPkceLogin } from "../../lib/authConfig";
|
||||
import { debugLog } from "../../lib/debugLog";
|
||||
|
||||
const insecurePkceMessage =
|
||||
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
|
||||
|
||||
function isPkceSetupFailure(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message);
|
||||
}
|
||||
|
||||
function LoginPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const autoStartedRef = useRef(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const returnTo = searchParams.get("returnTo") || "/";
|
||||
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||
const authErrorMessage = useMemo(() => {
|
||||
const message = auth.error?.message;
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
if (message.includes("Crypto.subtle")) {
|
||||
return insecurePkceMessage;
|
||||
}
|
||||
return message;
|
||||
}, [auth.error?.message]);
|
||||
const visibleLoginError = loginError || authErrorMessage;
|
||||
|
||||
useEffect(() => {
|
||||
debugLog("[LoginPage] Auth state check:", {
|
||||
@@ -42,21 +63,46 @@ function LoginPage() {
|
||||
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
|
||||
return;
|
||||
}
|
||||
if (!canStartBrowserPkceLogin()) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
autoStartedRef.current = true;
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
void auth
|
||||
.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isPkceSetupFailure(error)) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
console.error("Auto login redirect failed", error);
|
||||
});
|
||||
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo: "/",
|
||||
},
|
||||
});
|
||||
const handleSSOLogin = async () => {
|
||||
try {
|
||||
setLoginError(null);
|
||||
if (!canStartBrowserPkceLogin()) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
await auth.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isPkceSetupFailure(error)) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
console.error("Redirect login failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -85,11 +131,7 @@ function LoginPage() {
|
||||
variant="ghost"
|
||||
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
void handleSSOLogin();
|
||||
}}
|
||||
>
|
||||
다시 시도하기
|
||||
@@ -127,6 +169,16 @@ function LoginPage() {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{visibleLoginError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm leading-5 text-destructive"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{visibleLoginError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||
관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
|
||||
<br />
|
||||
|
||||
@@ -48,10 +48,7 @@ function PermissionChecker() {
|
||||
<Card className="border-primary/20 bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.title",
|
||||
"ReBAC permission checker",
|
||||
)}
|
||||
{t("ui.admin.auth_guard.checker.title", "ReBAC permission checker")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
@@ -92,7 +89,9 @@ function PermissionChecker() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("ui.admin.auth_guard.checker.relation", "Relation")}</Label>
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.relation", "Relation")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.relation_placeholder",
|
||||
@@ -103,7 +102,9 @@ function PermissionChecker() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("ui.admin.auth_guard.checker.object_id", "Object ID")}</Label>
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.object_id", "Object ID")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.object_id_placeholder",
|
||||
@@ -115,10 +116,7 @@ function PermissionChecker() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.subject",
|
||||
"Subject (User:ID)",
|
||||
)}
|
||||
{t("ui.admin.auth_guard.checker.subject", "Subject (User:ID)")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
@@ -155,10 +153,7 @@ function PermissionChecker() {
|
||||
<>
|
||||
<CheckCircle2 size={48} />
|
||||
<div className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.allowed",
|
||||
"Access ALLOWED",
|
||||
)}
|
||||
{t("ui.admin.auth_guard.checker.allowed", "Access ALLOWED")}
|
||||
</div>
|
||||
<p className="text-center text-sm opacity-80">
|
||||
{t(
|
||||
@@ -171,10 +166,7 @@ function PermissionChecker() {
|
||||
<>
|
||||
<XCircle size={48} />
|
||||
<div className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.denied",
|
||||
"Access DENIED",
|
||||
)}
|
||||
{t("ui.admin.auth_guard.checker.denied", "Access DENIED")}
|
||||
</div>
|
||||
<p className="text-center text-sm opacity-80">
|
||||
{t(
|
||||
|
||||
192
adminfront/src/features/coverage/adminAuditAuth.test.tsx
Normal file
192
adminfront/src/features/coverage/adminAuditAuth.test.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import AuditLogsPage from "../audit/AuditLogsPage";
|
||||
import AuthCallbackPage from "../auth/AuthCallbackPage";
|
||||
import AuthGuard from "../auth/AuthGuard";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
activeNavigator: undefined as string | undefined,
|
||||
error: null as Error | null,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
state: undefined as unknown,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../../../common/core/components/audit", () => ({
|
||||
AuditLogTable: ({
|
||||
logs,
|
||||
}: {
|
||||
logs: Array<{ user_id: string; event_type: string }>;
|
||||
}) => (
|
||||
<div>
|
||||
{logs.map((log) => (
|
||||
<div key={`${log.user_id}-${log.event_type}`}>
|
||||
<span>{log.user_id}</span>
|
||||
<span>{log.event_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchAuditLogs: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
event_id: "event-1",
|
||||
timestamp: "2026-05-01T00:00:00Z",
|
||||
user_id: "admin-1",
|
||||
event_type: "USER_UPDATE",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "Vitest",
|
||||
details: JSON.stringify({ action: "USER_UPDATE", actor: "Admin" }),
|
||||
},
|
||||
{
|
||||
event_id: "event-2",
|
||||
timestamp: "2026-05-01T01:00:00Z",
|
||||
user_id: "admin-2",
|
||||
event_type: "LOGIN_FAILED",
|
||||
status: "failure",
|
||||
ip_address: "127.0.0.2",
|
||||
user_agent: "Vitest",
|
||||
details: "{}",
|
||||
},
|
||||
],
|
||||
limit: 50,
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin audit and auth coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = false;
|
||||
authState.isAuthenticated = true;
|
||||
authState.isLoading = false;
|
||||
authState.activeNavigator = undefined;
|
||||
authState.error = null;
|
||||
authState.user = {
|
||||
access_token: "access-token",
|
||||
state: undefined,
|
||||
};
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders audit log table with fetched events", async () => {
|
||||
renderWithProviders(<AuditLogsPage />);
|
||||
|
||||
expect(await screen.findByText("감사 로그")).toBeInTheDocument();
|
||||
expect(await screen.findByText("admin-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("USER_UPDATE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders AuthGuard loading, error, redirect, test, and outlet states", async () => {
|
||||
authState.isLoading = true;
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
"/secure",
|
||||
);
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
|
||||
authState.isLoading = false;
|
||||
authState.error = new Error("OIDC failed");
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
"/secure",
|
||||
);
|
||||
expect(screen.getByText("인증 오류")).toBeInTheDocument();
|
||||
|
||||
authState.error = null;
|
||||
authState.isAuthenticated = false;
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>,
|
||||
"/secure?x=1",
|
||||
);
|
||||
expect(screen.getByText("Login outlet")).toBeInTheDocument();
|
||||
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
"/secure",
|
||||
);
|
||||
expect(screen.getByText("Secure outlet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stores callback token and navigates by auth result", async () => {
|
||||
authState.isAuthenticated = true;
|
||||
authState.user = {
|
||||
access_token: "callback-token",
|
||||
state: { returnTo: "/users" },
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route path="/users" element={<div>Users outlet</div>} />
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>,
|
||||
"/auth/callback",
|
||||
);
|
||||
expect(await screen.findByText("Users outlet")).toBeInTheDocument();
|
||||
expect(window.localStorage.getItem("admin_session")).toBe("callback-token");
|
||||
|
||||
authState.isAuthenticated = false;
|
||||
authState.error = new Error("callback failed");
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>,
|
||||
"/auth/callback",
|
||||
);
|
||||
expect(await screen.findByText("Login outlet")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
520
adminfront/src/features/coverage/adminLargePages.test.tsx
Normal file
520
adminfront/src/features/coverage/adminLargePages.test.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import * as adminApi from "../../lib/adminApi";
|
||||
import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage";
|
||||
import TenantListPage from "../tenants/routes/TenantListPage";
|
||||
import UserCreatePage from "../users/UserCreatePage";
|
||||
import UserDetailPage from "../users/UserDetailPage";
|
||||
|
||||
const tenantItems = [
|
||||
{
|
||||
id: "tenant-root",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
description: "root",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
parentId: "tenant-root",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "company",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
config: {
|
||||
userSchema: [
|
||||
{
|
||||
key: "employee_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-leaf",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "tenant-company",
|
||||
name: "기술연구팀",
|
||||
slug: "gpdtdc-rnd",
|
||||
description: "leaf",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const userDetail = {
|
||||
id: "user-1",
|
||||
email: "engineer@example.com",
|
||||
name: "Engineer User",
|
||||
phone: "010-0000-0000",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "gpdtdc-rnd",
|
||||
tenantId: "tenant-leaf",
|
||||
department: "기술연구팀",
|
||||
grade: "책임",
|
||||
position: "팀장",
|
||||
jobTitle: "Backend",
|
||||
metadata: {
|
||||
employee_id: "EMP001",
|
||||
sub_email: ["engineer.sub@example.com"],
|
||||
},
|
||||
tenant: tenantItems[2],
|
||||
appointments: [
|
||||
{
|
||||
tenantId: "tenant-leaf",
|
||||
tenantSlug: "gpdtdc-rnd",
|
||||
tenantName: "기술연구팀",
|
||||
isPrimary: true,
|
||||
isOwner: false,
|
||||
isAdmin: false,
|
||||
isManager: true,
|
||||
department: "기술연구팀",
|
||||
grade: "책임",
|
||||
position: "팀장",
|
||||
jobTitle: "Backend",
|
||||
metadata: { employee_id: "EMP001" },
|
||||
},
|
||||
],
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-02T00:00:00Z",
|
||||
};
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../components/auth/RoleGuard", () => ({
|
||||
RoleGuard: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-1",
|
||||
role: "super_admin",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: tenantItems,
|
||||
total: tenantItems.length,
|
||||
})),
|
||||
fetchTenants: vi.fn(async () => ({
|
||||
items: tenantItems,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
total: tenantItems.length,
|
||||
nextCursor: null,
|
||||
})),
|
||||
fetchTenant: vi.fn(async (id: string) => {
|
||||
return tenantItems.find((tenant) => tenant.id === id) ?? tenantItems[1];
|
||||
}),
|
||||
createUser: vi.fn(async () => ({
|
||||
id: "created-user",
|
||||
email: "created@example.com",
|
||||
generatedPassword: "GeneratedPassword!1",
|
||||
})),
|
||||
fetchUser: vi.fn(async () => userDetail),
|
||||
fetchUserRpHistory: vi.fn(async () => [
|
||||
{
|
||||
client_id: "orgfront",
|
||||
client_name: "OrgFront",
|
||||
last_login_at: "2026-05-01T00:00:00Z",
|
||||
login_count: 3,
|
||||
},
|
||||
]),
|
||||
fetchPasswordPolicy: vi.fn(async () => ({
|
||||
minLength: 12,
|
||||
lowercase: true,
|
||||
uppercase: true,
|
||||
number: true,
|
||||
nonAlphanumeric: true,
|
||||
minCharacterTypes: 3,
|
||||
})),
|
||||
updateUser: vi.fn(async () => userDetail),
|
||||
deleteUser: vi.fn(async () => undefined),
|
||||
updateTenant: vi.fn(async () => tenantItems[1]),
|
||||
deleteTenantsBulk: vi.fn(async () => ({ deleted: 1 })),
|
||||
exportTenantsCSV: vi.fn(async () => new Blob(["name,slug\nGPDTDC,gpdtdc"])),
|
||||
importTenantsCSV: vi.fn(async () => ({
|
||||
created: 1,
|
||||
updated: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
})),
|
||||
fetchWorksmobileOverview: vi.fn(async () => ({
|
||||
tenant: tenantItems[1],
|
||||
config: {
|
||||
enabled: true,
|
||||
tokenConfigured: true,
|
||||
adminTenantId: "works-admin",
|
||||
domainMappings: { "example.com": 1001 },
|
||||
},
|
||||
recentJobs: [
|
||||
{
|
||||
id: "job-1",
|
||||
resourceType: "USER",
|
||||
resourceId: "user-1",
|
||||
action: "SYNC",
|
||||
status: "failed",
|
||||
retryCount: 1,
|
||||
lastError: "temporary failure",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:10:00Z",
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchWorksmobileComparison: vi.fn(async () => ({
|
||||
users: [
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-1",
|
||||
baronName: "Engineer User",
|
||||
baronEmail: "engineer@example.com",
|
||||
baronPrimaryOrgId: "tenant-leaf",
|
||||
baronPrimaryOrgName: "기술연구팀",
|
||||
worksmobileId: "works-user-1",
|
||||
worksmobileName: "Engineer User",
|
||||
worksmobileEmail: "engineer@example.com",
|
||||
worksmobilePrimaryOrgId: "works-org-1",
|
||||
worksmobilePrimaryOrgName: "기술연구팀",
|
||||
status: "matched",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-2",
|
||||
baronName: "New User",
|
||||
baronEmail: "new@example.com",
|
||||
worksmobileJobStatus: "failed",
|
||||
worksmobileJobRetryCount: 2,
|
||||
worksmobileLastError: "worksmobile api failed",
|
||||
status: "missing_in_worksmobile",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-3",
|
||||
baronName: "Next User",
|
||||
baronEmail: "next@example.com",
|
||||
status: "missing_in_worksmobile",
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
resourceType: "ORG_UNIT",
|
||||
baronId: "tenant-leaf",
|
||||
baronSlug: "gpdtdc-rnd",
|
||||
baronName: "기술연구팀",
|
||||
worksmobileId: "works-org-1",
|
||||
worksmobileName: "기술연구팀",
|
||||
status: "needs_update",
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchWorksmobileCredentialBatches: vi.fn(async () => [
|
||||
{
|
||||
batchId: "credential-batch-1",
|
||||
operation: "worksmobile_user_sync",
|
||||
userCount: 1,
|
||||
processedCount: 1,
|
||||
failedCount: 1,
|
||||
hasPasswords: true,
|
||||
failures: [
|
||||
{
|
||||
userId: "failed-user",
|
||||
email: "failed-user@samaneng.com",
|
||||
status: "failed",
|
||||
retryCount: 2,
|
||||
lastError: "worksmobile api failed",
|
||||
updatedAt: "2026-06-01T04:05:00Z",
|
||||
},
|
||||
],
|
||||
createdAt: "2026-06-01T04:00:00Z",
|
||||
updatedAt: "2026-06-01T04:00:00Z",
|
||||
},
|
||||
{
|
||||
batchId: "credential-batch-pending",
|
||||
operation: "worksmobile_user_sync",
|
||||
userCount: 2,
|
||||
pendingCount: 1,
|
||||
processingCount: 1,
|
||||
processedCount: 0,
|
||||
failedCount: 0,
|
||||
hasPasswords: true,
|
||||
createdAt: "2026-06-01T04:10:00Z",
|
||||
updatedAt: "2026-06-01T04:10:00Z",
|
||||
},
|
||||
]),
|
||||
enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })),
|
||||
retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })),
|
||||
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => ({
|
||||
blob: new Blob(["id"]),
|
||||
filename: "worksmobile_initial_passwords.csv",
|
||||
})),
|
||||
enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })),
|
||||
enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })),
|
||||
enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })),
|
||||
resetWorksmobileUserPassword: vi.fn(async () => ({ id: "job-reset" })),
|
||||
deleteWorksmobileCredentialBatchPasswords: vi.fn(async () => ({
|
||||
batchId: "credential-batch-1",
|
||||
userCount: 1,
|
||||
hasPasswords: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("adminfront large page coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any)._IS_TEST_MODE = true;
|
||||
}
|
||||
});
|
||||
|
||||
it("renders user creation form with tenant context", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/users/new" element={<UserCreatePage />} />
|
||||
</Routes>,
|
||||
"/users/new?tenantSlug=gpdtdc-rnd",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("사용자 추가")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders user detail form and RP history", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/users/:id" element={<UserDetailPage />} />
|
||||
</Routes>,
|
||||
"/users/user-1",
|
||||
);
|
||||
|
||||
expect(await screen.findByDisplayValue("Engineer User")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
|
||||
expect(screen.getByDisplayValue("engineer@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant list hierarchy", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/tenants" element={<TenantListPage />} />
|
||||
</Routes>,
|
||||
"/tenants",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("GPDTDC")).toBeInTheDocument();
|
||||
expect(screen.getByText("기술연구팀")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders worksmobile comparison screens", async () => {
|
||||
cleanup();
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Baron / Works 비교")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("최근 실패: worksmobile api failed"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "초기 비밀번호 CSV" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not automatically download the selected Worksmobile user credential batch after create enqueue", async () => {
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("New User");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
expect.any(String),
|
||||
),
|
||||
);
|
||||
const credentialBatchId = vi.mocked(
|
||||
adminApi.enqueueWorksmobileUserSync,
|
||||
).mock.calls[0][2];
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues selected Worksmobile user create enqueue after one row fails", async () => {
|
||||
vi.mocked(adminApi.enqueueWorksmobileUserSync)
|
||||
.mockRejectedValueOnce(new Error("sync failed"))
|
||||
.mockResolvedValueOnce({ id: "job-user-3" } as never);
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("New User");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Next User 선택" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tenant-company",
|
||||
"user-3",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("downloads or deletes Worksmobile credential batches from history", async () => {
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "이력" }));
|
||||
await screen.findByText("credential-batch-1");
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-pending 비밀번호 CSV 다운로드",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 비밀번호 CSV 다운로드",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
adminApi.downloadWorksmobileInitialPasswordsCSV,
|
||||
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 비밀번호 값 삭제",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
adminApi.deleteWorksmobileCredentialBatchPasswords,
|
||||
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 실패 사유 보기",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("failed-user@samaneng.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("worksmobile api failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enqueues Worksmobile password reset as a credential batch", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("Worksmobile 연동");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "양쪽 다 있음" })[0]);
|
||||
await screen.findAllByText("Engineer User");
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Engineer User 비밀번호 재설정",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.resetWorksmobileUserPassword).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"user-1",
|
||||
expect.any(String),
|
||||
),
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
129
adminfront/src/features/coverage/adminTenantDetailPages.test.tsx
Normal file
129
adminfront/src/features/coverage/adminTenantDetailPages.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import TenantCreatePage from "../tenants/routes/TenantCreatePage";
|
||||
import { TenantProfilePage } from "../tenants/routes/TenantProfilePage";
|
||||
import { TenantSchemaPage } from "../tenants/routes/TenantSchemaPage";
|
||||
|
||||
const tenants = [
|
||||
{
|
||||
id: "tenant-root",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
domains: ["hmac.kr"],
|
||||
config: { visibility: "public" },
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
parentId: "tenant-root",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "실 조직",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
domains: ["gpdtdc.example.com"],
|
||||
config: {
|
||||
visibility: "public",
|
||||
userSchema: [
|
||||
{
|
||||
key: "employee_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
isLoginId: true,
|
||||
indexed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-1",
|
||||
role: "super_admin",
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: tenants,
|
||||
total: tenants.length,
|
||||
})),
|
||||
fetchTenant: vi.fn(async (id: string) => {
|
||||
return tenants.find((tenant) => tenant.id === id) ?? tenants[1];
|
||||
}),
|
||||
createTenant: vi.fn(async () => tenants[1]),
|
||||
updateTenant: vi.fn(async () => tenants[1]),
|
||||
deleteTenant: vi.fn(async () => undefined),
|
||||
approveTenant: vi.fn(async () => tenants[1]),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin tenant detail page coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("renders tenant create page with parent context", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/tenants/new" element={<TenantCreatePage />} />
|
||||
</Routes>,
|
||||
"/tenants/new?parentId=tenant-root",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("테넌트 생성")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tenant Profile")).toBeInTheDocument();
|
||||
expect(screen.getByText("정책 메모")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant profile and schema management pages", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId"
|
||||
element={
|
||||
<>
|
||||
<TenantProfilePage />
|
||||
<TenantSchemaPage />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company",
|
||||
);
|
||||
|
||||
expect(await screen.findByDisplayValue("GPDTDC")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("gpdtdc")).toBeInTheDocument();
|
||||
expect(await screen.findByText("사용자 스키마 확장")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("employee_id")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
116
adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx
Normal file
116
adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import TenantGroupsPage from "../tenants/routes/TenantGroupsPage";
|
||||
|
||||
const tenant = {
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const members = [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Member User",
|
||||
email: "member@example.com",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchTenant: vi.fn(async () => tenant),
|
||||
fetchUsers: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Member User",
|
||||
email: "member@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
name: "Candidate User",
|
||||
email: "candidate@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
})),
|
||||
fetchGroups: vi.fn(async () => [
|
||||
{
|
||||
id: "group-root",
|
||||
tenantId: "tenant-company",
|
||||
name: "연구소",
|
||||
description: "root group",
|
||||
members,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "group-child",
|
||||
tenantId: "tenant-company",
|
||||
parentId: "group-root",
|
||||
name: "플랫폼팀",
|
||||
description: "child group",
|
||||
members: [],
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
createGroup: vi.fn(async () => undefined),
|
||||
deleteGroup: vi.fn(async () => undefined),
|
||||
addGroupMember: vi.fn(async () => undefined),
|
||||
removeGroupMember: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TenantGroupsPage coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("renders group hierarchy and selected group members", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/groups"
|
||||
element={<TenantGroupsPage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/groups",
|
||||
);
|
||||
|
||||
expect((await screen.findAllByText("연구소")).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("플랫폼팀").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("새 그룹 생성")).toBeInTheDocument();
|
||||
expect(screen.getByText("조직 단위 레벨")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
162
adminfront/src/features/coverage/adminTenantTabs.test.tsx
Normal file
162
adminfront/src/features/coverage/adminTenantTabs.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
||||
|
||||
const tenants = [
|
||||
{
|
||||
id: "tenant-root",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
parentId: "tenant-root",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-leaf",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "tenant-company",
|
||||
name: "기술연구팀",
|
||||
slug: "gpdtdc-rnd",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const users = [
|
||||
{
|
||||
id: "user-owner",
|
||||
name: "Owner User",
|
||||
email: "owner@example.com",
|
||||
role: "tenant_admin",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "user-admin",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
role: "tenant_admin",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "user-member",
|
||||
name: "Member User",
|
||||
email: "member@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "gpdtdc-rnd",
|
||||
tenant: tenants[2],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
profile: {
|
||||
sub: "admin-1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchTenantOwners: vi.fn(async () => [users[0]]),
|
||||
fetchTenantAdmins: vi.fn(async () => [users[1]]),
|
||||
addTenantOwner: vi.fn(async () => undefined),
|
||||
addTenantAdmin: vi.fn(async () => undefined),
|
||||
removeTenantOwner: vi.fn(async () => undefined),
|
||||
removeTenantAdmin: vi.fn(async () => undefined),
|
||||
fetchUsers: vi.fn(async () => ({
|
||||
items: users,
|
||||
total: users.length,
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: tenants,
|
||||
total: tenants.length,
|
||||
})),
|
||||
updateTenant: vi.fn(async () => tenants[2]),
|
||||
updateUser: vi.fn(async () => users[2]),
|
||||
exportTenantsCSV: vi.fn(async () => ({
|
||||
blob: new Blob(["name,slug"]),
|
||||
filename: "tenants.csv",
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin tenant tab coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("renders tenant owners and admins lists", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/permissions"
|
||||
element={<TenantAdminsAndOwnersTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/permissions",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Owner User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Admin User")).toBeInTheDocument();
|
||||
expect(screen.getByText("owner@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant hierarchy and selected organization members", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/organization"
|
||||
element={<TenantUserGroupsTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/organization",
|
||||
);
|
||||
|
||||
expect((await screen.findAllByText("GPDTDC")).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText("Member User")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
fetchDataIntegrityReport,
|
||||
fetchMe,
|
||||
fetchOrphanUserLoginIDs,
|
||||
fetchUserProjectionStatus,
|
||||
reconcileUserProjection,
|
||||
resetUserProjection,
|
||||
} from "../../lib/adminApi";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import DataIntegrityPage from "./DataIntegrityPage";
|
||||
@@ -60,6 +63,24 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
fetchUserProjectionStatus: vi.fn(async () => ({
|
||||
name: "kratos_users",
|
||||
status: "ready",
|
||||
ready: true,
|
||||
lastSyncedAt: "2026-05-11T03:00:00Z",
|
||||
updatedAt: "2026-05-11T03:00:10Z",
|
||||
projectedUsers: 152,
|
||||
})),
|
||||
reconcileUserProjection: vi.fn(async () => ({
|
||||
status: "success",
|
||||
syncedUsers: 152,
|
||||
updatedAt: "2026-05-11T03:01:00Z",
|
||||
})),
|
||||
resetUserProjection: vi.fn(async () => ({
|
||||
status: "success",
|
||||
syncedUsers: 152,
|
||||
updatedAt: "2026-05-11T03:02:00Z",
|
||||
})),
|
||||
deleteOrphanUserLoginIDs: vi.fn(async () => ({
|
||||
deletedCount: 1,
|
||||
deleted: [
|
||||
@@ -95,6 +116,7 @@ describe("DataIntegrityPage", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
});
|
||||
|
||||
@@ -102,6 +124,12 @@ describe("DataIntegrityPage", () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "정합성 검사" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "사용자 동기화" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
|
||||
@@ -113,6 +141,28 @@ describe("DataIntegrityPage", () => {
|
||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders user projection sync inside data integrity", async () => {
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "사용자 동기화" }));
|
||||
|
||||
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
|
||||
expect(screen.getByText("준비됨")).toBeInTheDocument();
|
||||
expect(screen.getByText("152")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
|
||||
await waitFor(() => {
|
||||
expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
|
||||
await waitFor(() => {
|
||||
expect(resetUserProjection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(fetchUserProjectionStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows orphan login ID targets and deletes selected rows", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderPage();
|
||||
@@ -175,16 +225,16 @@ describe("DataIntegrityPage", () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Data Integrity Check"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("Data Integrity Check")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Review integrity status and inspect checks across the admin data model.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("Tenant integrity")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Duplicate tenant slug")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Duplicate tenant slug"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",
|
||||
|
||||
@@ -12,13 +12,14 @@ 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";
|
||||
import { UserProjectionContent } from "../projections/UserProjectionPage";
|
||||
|
||||
function statusLabel(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
@@ -187,6 +188,14 @@ function recheckStatusText(status: "idle" | "running" | "success" | "error") {
|
||||
}
|
||||
}
|
||||
|
||||
function pageTabClassName(active: boolean) {
|
||||
return `relative px-6 py-3 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`;
|
||||
}
|
||||
|
||||
function OrphanLoginIDTable({
|
||||
items,
|
||||
selectedIds,
|
||||
@@ -284,6 +293,9 @@ function OrphanLoginIDTable({
|
||||
|
||||
function DataIntegrityContent() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
|
||||
"integrity",
|
||||
);
|
||||
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
|
||||
const [recheckStatus, setRecheckStatus] = useState<
|
||||
"idle" | "running" | "success" | "error"
|
||||
@@ -360,210 +372,243 @@ function DataIntegrityContent() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRecheck}
|
||||
disabled={isLoading || isFetching || isManualRechecking}
|
||||
>
|
||||
<Database size={16} />
|
||||
{isManualRechecking
|
||||
? t("ui.admin.integrity.recheck.running", "검사 중")
|
||||
: t("ui.admin.integrity.recheck.run", "다시 검사")}
|
||||
</Button>
|
||||
{recheckMessage ? (
|
||||
<output
|
||||
aria-live="polite"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{recheckMessage}
|
||||
</output>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4 pb-6">
|
||||
{isError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message ||
|
||||
t(
|
||||
"msg.admin.integrity.report.load_error",
|
||||
"정합성 리포트를 불러오지 못했습니다.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.read_model.title",
|
||||
"Read model integrity",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.read_model.description",
|
||||
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{data ? (
|
||||
<Badge variant={statusBadgeVariant(data.status)}>
|
||||
{statusLabel(data.status)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.loading", "불러오는 중")}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.totalChecks ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.passed", "정상")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.passed ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.failures", "실패 건수")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.failures ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.checkedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(data?.sections ?? []).map((section) => (
|
||||
<section
|
||||
key={section.key}
|
||||
className="rounded-lg border border-border bg-card p-5"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{integritySectionLabel(section.key, section.label)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{integritySectionDescription(section.key)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={statusBadgeVariant(section.status)}>
|
||||
{statusLabel(section.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{section.checks.map((check) => (
|
||||
<div
|
||||
key={check.key}
|
||||
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon check={check} />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{integrityCheckLabel(check.key, check.label)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{integrityCheckDescription(
|
||||
check.key,
|
||||
check.description,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 md:justify-end">
|
||||
<Badge variant={statusBadgeVariant(check.status)}>
|
||||
{statusLabel(check.status)}
|
||||
</Badge>
|
||||
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
|
||||
{check.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.orphan_login_ids.title",
|
||||
"유령 로그인 ID 정리",
|
||||
)}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.description",
|
||||
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{activeTab === "integrity" ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={
|
||||
selectedOrphanIds.length === 0 || deleteMutation.isPending
|
||||
}
|
||||
variant="outline"
|
||||
onClick={handleRecheck}
|
||||
disabled={isLoading || isFetching || isManualRechecking}
|
||||
>
|
||||
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
|
||||
<Database size={16} />
|
||||
{isManualRechecking
|
||||
? t("ui.admin.integrity.recheck.running", "검사 중")
|
||||
: t("ui.admin.integrity.recheck.run", "다시 검사")}
|
||||
</Button>
|
||||
{recheckMessage ? (
|
||||
<output
|
||||
aria-live="polite"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{recheckMessage}
|
||||
</output>
|
||||
) : null}
|
||||
</div>
|
||||
{orphanLoginIDsQuery.isError ? (
|
||||
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.load_error",
|
||||
"유령 로그인 ID 대상을 불러오지 못했습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{deleteMutation.data ? (
|
||||
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.delete_success",
|
||||
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
|
||||
{ count: deleteMutation.data.deletedCount },
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<OrphanLoginIDTable
|
||||
items={orphanItems}
|
||||
selectedIds={selectedOrphanIds}
|
||||
onToggle={toggleOrphanID}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="flex border-b border-border"
|
||||
role="tablist"
|
||||
aria-label="데이터 정합성 탭"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "integrity"}
|
||||
className={pageTabClassName(activeTab === "integrity")}
|
||||
onClick={() => setActiveTab("integrity")}
|
||||
>
|
||||
{t("ui.admin.integrity.tab_checks", "정합성 검사")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "projection"}
|
||||
className={pageTabClassName(activeTab === "projection")}
|
||||
onClick={() => setActiveTab("projection")}
|
||||
>
|
||||
{t("ui.admin.integrity.tab_user_projection", "사용자 동기화")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "integrity" ? (
|
||||
<div className="space-y-4 pb-6 animate-in fade-in duration-500">
|
||||
{isError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message ||
|
||||
t(
|
||||
"msg.admin.integrity.report.load_error",
|
||||
"정합성 리포트를 불러오지 못했습니다.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.read_model.title",
|
||||
"Read model integrity",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.read_model.description",
|
||||
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{data ? (
|
||||
<Badge variant={statusBadgeVariant(data.status)}>
|
||||
{statusLabel(data.status)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.loading", "불러오는 중")}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.totalChecks ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.passed", "정상")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.passed ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.failures", "실패 건수")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.failures ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.checkedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(data?.sections ?? []).map((section) => (
|
||||
<section
|
||||
key={section.key}
|
||||
className="rounded-lg border border-border bg-card p-5"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{integritySectionLabel(section.key, section.label)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{integritySectionDescription(section.key)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={statusBadgeVariant(section.status)}>
|
||||
{statusLabel(section.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{section.checks.map((check) => (
|
||||
<div
|
||||
key={check.key}
|
||||
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon check={check} />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{integrityCheckLabel(check.key, check.label)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{integrityCheckDescription(
|
||||
check.key,
|
||||
check.description,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 md:justify-end">
|
||||
<Badge variant={statusBadgeVariant(check.status)}>
|
||||
{statusLabel(check.status)}
|
||||
</Badge>
|
||||
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
|
||||
{check.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.orphan_login_ids.title",
|
||||
"유령 로그인 ID 정리",
|
||||
)}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.description",
|
||||
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={
|
||||
selectedOrphanIds.length === 0 || deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
{orphanLoginIDsQuery.isError ? (
|
||||
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.load_error",
|
||||
"유령 로그인 ID 대상을 불러오지 못했습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{deleteMutation.data ? (
|
||||
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.delete_success",
|
||||
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
|
||||
{ count: deleteMutation.data.deletedCount },
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<OrphanLoginIDTable
|
||||
items={orphanItems}
|
||||
selectedIds={selectedOrphanIds}
|
||||
onToggle={toggleOrphanID}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-in fade-in duration-500">
|
||||
<UserProjectionContent embedded />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,13 +17,12 @@ import {
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
type DataIntegrityStatus,
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
type TenantSummary,
|
||||
fetchAdminOverviewStats,
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchAllTenants,
|
||||
fetchDataIntegrityReport,
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
@@ -203,10 +202,7 @@ function IntegrityOverviewSummary() {
|
||||
<AlertTriangle size={18} className="text-amber-600" />
|
||||
)}
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.summary.title",
|
||||
"정합성 최종 검증",
|
||||
)}
|
||||
{t("ui.admin.integrity.summary.title", "정합성 최종 검증")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
@@ -466,7 +462,7 @@ function GlobalOverviewPage() {
|
||||
const metric = (value: number | undefined) =>
|
||||
value === undefined ? "-" : value.toLocaleString();
|
||||
const periodControls = (
|
||||
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||
<fieldset className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||
{[
|
||||
["day", t("ui.common.chart.period.day", "일")],
|
||||
["week", t("ui.common.chart.period.week", "주")],
|
||||
@@ -486,7 +482,7 @@ function GlobalOverviewPage() {
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
const chartFilters = (
|
||||
<div>
|
||||
|
||||
@@ -61,17 +61,13 @@ describe("UserProjectionPage", () => {
|
||||
it("renders projection status for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("사용자 동기화 관리"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Kratos 사용자 동기화"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
|
||||
expect(screen.getByText("준비됨")).toBeInTheDocument();
|
||||
expect(screen.getByText("152")).toBeInTheDocument();
|
||||
expect(fetchUserProjectionStatus).toHaveBeenCalled();
|
||||
@@ -100,9 +96,7 @@ describe("UserProjectionPage", () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("사용자 동기화 관리"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("사용자 동기화 관리")).not.toBeInTheDocument();
|
||||
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -55,7 +55,11 @@ function ProjectionStatusBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function UserProjectionContent() {
|
||||
export function UserProjectionContent({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["user-projection-status"],
|
||||
@@ -94,53 +98,55 @@ function UserProjectionContent() {
|
||||
const actionResult = reconcileMutation.data ?? resetMutation.data;
|
||||
const actionError = reconcileMutation.error ?? resetMutation.error;
|
||||
|
||||
return (
|
||||
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t(
|
||||
"ui.admin.user_projection.title",
|
||||
"User Projection Management",
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.user_projection.subtitle",
|
||||
"Review and sync the Kratos user read model.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
const header = (
|
||||
<header
|
||||
className={
|
||||
embedded
|
||||
? "flex flex-shrink-0 flex-wrap items-start justify-between gap-4"
|
||||
: "flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur"
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => reconcileMutation.mutate()}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleReset}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.user_projection.title", "User Projection Management")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.user_projection.actions.reset",
|
||||
"Reset and rebuild",
|
||||
"msg.admin.user_projection.subtitle",
|
||||
"Review and sync the Kratos user read model.",
|
||||
)}
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => reconcileMutation.mutate()}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleReset}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{isError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message ||
|
||||
@@ -230,10 +236,7 @@ function UserProjectionContent() {
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.user_projection.summary.updated_at",
|
||||
"Updated at",
|
||||
)}
|
||||
{t("ui.admin.user_projection.summary.updated_at", "Updated at")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.updatedAt)}
|
||||
@@ -249,6 +252,22 @@ function UserProjectionContent() {
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="space-y-4 pb-6">
|
||||
{header}
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
{header}
|
||||
{body}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -258,22 +277,19 @@ export default function UserProjectionPage() {
|
||||
<RoleGuard
|
||||
roles={["super_admin"]}
|
||||
fallback={
|
||||
<main className="p-6 md:p-8">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t(
|
||||
"ui.admin.user_projection.forbidden.title",
|
||||
"Access denied",
|
||||
)}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.user_projection.forbidden.description",
|
||||
"This screen is only available to super_admin users.",
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<main className="p-6 md:p-8">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("ui.admin.user_projection.forbidden.title", "Access denied")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.user_projection.forbidden.description",
|
||||
"This screen is only available to super_admin users.",
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<UserProjectionContent />
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
@@ -33,6 +33,8 @@ type ParentTenantSelectorProps = {
|
||||
orgChartPickerLabel?: string;
|
||||
localPickerLabel?: string;
|
||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||
compact?: boolean;
|
||||
controlTestId?: string;
|
||||
};
|
||||
|
||||
export function ParentTenantSelector({
|
||||
@@ -49,6 +51,8 @@ export function ParentTenantSelector({
|
||||
orgChartPickerLabel,
|
||||
localPickerLabel,
|
||||
localTenantFilter,
|
||||
compact = false,
|
||||
controlTestId,
|
||||
}: ParentTenantSelectorProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
||||
@@ -81,19 +85,37 @@ export function ParentTenantSelector({
|
||||
}, [excludeTenantId, onChange, pickerOpen]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
|
||||
<div className={compact ? "space-y-1" : "space-y-2"}>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? "flex min-h-5 flex-wrap items-center justify-between gap-2"
|
||||
: "flex min-h-8 flex-wrap items-center justify-between gap-2"
|
||||
}
|
||||
>
|
||||
<Label className="text-sm font-semibold">{label}</Label>
|
||||
{labelAction}
|
||||
</div>
|
||||
<input id={id} name={id} type="hidden" value={value} readOnly />
|
||||
<div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
|
||||
<div
|
||||
data-testid={controlTestId}
|
||||
className={
|
||||
compact
|
||||
? "flex h-10 min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2"
|
||||
: "flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2"
|
||||
}
|
||||
>
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={compact ? "h-8 shrink-0 px-2" : undefined}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{orgChartPickerLabel ??
|
||||
selectedTenant?.name ??
|
||||
(compact ? undefined : selectedTenant?.name) ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -185,14 +207,23 @@ export function ParentTenantSelector({
|
||||
)}
|
||||
{selectedTenant ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedTenant.slug} · {selectedTenant.type}
|
||||
<span
|
||||
className={
|
||||
compact
|
||||
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
|
||||
: "text-xs text-muted-foreground"
|
||||
}
|
||||
title={`${selectedTenant.name} · ${selectedTenant.slug} · ${selectedTenant.type}`}
|
||||
>
|
||||
{compact
|
||||
? `${selectedTenant.name} · ${selectedTenant.slug}`
|
||||
: `${selectedTenant.slug} · ${selectedTenant.type}`}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
|
||||
onClick={() => onChange("")}
|
||||
aria-label={noneLabel}
|
||||
>
|
||||
@@ -200,7 +231,15 @@ export function ParentTenantSelector({
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{noneLabel}</span>
|
||||
<span
|
||||
className={
|
||||
compact
|
||||
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
|
||||
: "text-xs text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{noneLabel}
|
||||
</span>
|
||||
)}
|
||||
{contextLabel && (
|
||||
<span className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
@@ -41,7 +39,6 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type TenantAdmin,
|
||||
addTenantAdmin,
|
||||
addTenantOwner,
|
||||
fetchTenantAdmins,
|
||||
@@ -49,6 +46,7 @@ import {
|
||||
fetchUsers,
|
||||
removeTenantAdmin,
|
||||
removeTenantOwner,
|
||||
type TenantAdmin,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
@@ -68,16 +66,15 @@ function mergePendingMembers(
|
||||
export function TenantAdminsAndOwnersTab() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const _currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||
const tenantId = tenantIdParam ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
|
||||
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
const ownersQuery = useQuery({
|
||||
queryKey: ["tenant-owners", tenantId],
|
||||
queryFn: () => fetchTenantOwners(tenantId),
|
||||
@@ -188,7 +185,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
|
||||
if (context?.previousOwners) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-owners", tenantId],
|
||||
@@ -289,7 +286,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
|
||||
);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
|
||||
if (context?.previousAdmins) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-admins", tenantId],
|
||||
@@ -311,7 +308,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveOwner = (userId: string, userName: string) => {
|
||||
const _handleRemoveOwner = (userId: string, userName: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
@@ -325,7 +322,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
const _handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
@@ -339,6 +336,8 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
const serverOwners = ownersQuery.data || [];
|
||||
const serverAdmins = adminsQuery.data || [];
|
||||
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
@@ -19,15 +20,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 = {
|
||||
@@ -46,6 +47,7 @@ function TenantCreatePage() {
|
||||
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
|
||||
const [orgUnitType, setOrgUnitType] = useState("");
|
||||
const [visibility, setVisibility] = useState<TenantVisibility>("public");
|
||||
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
@@ -109,7 +111,11 @@ function TenantCreatePage() {
|
||||
status,
|
||||
domains,
|
||||
config: canConfigureHanmacOrg
|
||||
? mergeTenantOrgConfig(undefined, { orgUnitType, visibility })
|
||||
? mergeTenantOrgConfig(undefined, {
|
||||
orgUnitType,
|
||||
visibility,
|
||||
worksmobileExcluded,
|
||||
})
|
||||
: undefined,
|
||||
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||
}),
|
||||
@@ -284,6 +290,27 @@ function TenantCreatePage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
data-testid="tenant-worksmobile-excluded-slot"
|
||||
className="flex min-h-9 items-center gap-2 rounded-md border border-input px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
id="worksmobileExcluded"
|
||||
checked={worksmobileExcluded}
|
||||
onCheckedChange={(checked) =>
|
||||
setWorksmobileExcluded(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="worksmobileExcluded"
|
||||
className="cursor-pointer text-sm font-semibold"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.tenants.profile.worksmobile_excluded",
|
||||
"WORKS 연동 제외",
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function canShowWorksmobileEntry(tenant?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
parentId?: string | null;
|
||||
}) {
|
||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||
|
||||
describe("TenantDetailPage Worksmobile entry visibility", () => {
|
||||
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
|
||||
expect(
|
||||
canShowWorksmobileEntry({
|
||||
id: "hanmac-family-id",
|
||||
slug: "hanmac-family",
|
||||
parentId: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canShowWorksmobileEntry({
|
||||
id: "hanmac-child-id",
|
||||
slug: "hanmac-family",
|
||||
parentId: "root-id",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
canShowWorksmobileEntry({
|
||||
id: "other-id",
|
||||
slug: "other",
|
||||
parentId: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||
|
||||
function TenantDetailPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
@@ -25,9 +23,7 @@ function TenantDetailPage() {
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccessSchema =
|
||||
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
|
||||
const canAccessSchema = profileRole === "super_admin";
|
||||
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
const isOrganizationTab = location.pathname.includes("/organization");
|
||||
@@ -126,18 +122,6 @@ function TenantDetailPage() {
|
||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||
</Link>
|
||||
)}
|
||||
{showWorksmobileEntry && (
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/worksmobile`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
isWorksmobileTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outlet for nested routes */}
|
||||
|
||||
@@ -29,7 +29,6 @@ function renderTenantDetailPage() {
|
||||
<Routes>
|
||||
<Route path="/tenants/:tenantId/*" element={<TenantDetailPage />}>
|
||||
<Route index element={<div>profile</div>} />
|
||||
<Route path="worksmobile" element={<div>worksmobile</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
@@ -42,16 +41,11 @@ describe("TenantDetailPage Worksmobile navigation", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens Worksmobile management in the current admin route", async () => {
|
||||
it("does not render Worksmobile as a tenant detail tab", async () => {
|
||||
renderTenantDetailPage();
|
||||
|
||||
const link = await screen.findByRole("link", { name: /Worksmobile/i });
|
||||
await screen.findByText("프로필");
|
||||
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"/tenants/hanmac-family-id/worksmobile",
|
||||
);
|
||||
expect(link).not.toHaveAttribute("target");
|
||||
expect(link).not.toHaveAttribute("rel");
|
||||
expect(screen.queryByRole("link", { name: /Worksmobile/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
@@ -53,13 +52,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";
|
||||
@@ -239,7 +238,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
function TenantGroupsPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
const _queryClient = useQueryClient();
|
||||
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
getOrgUnitTypeOptionsForTenantType,
|
||||
mergeTenantOrgConfig,
|
||||
readTenantOrgConfig,
|
||||
removeTenantOrgConfig,
|
||||
shouldAllowHanmacOrgConfig,
|
||||
TENANT_VISIBILITY_OPTIONS,
|
||||
type TenantVisibility,
|
||||
} from "../utils/orgConfig";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
|
||||
export function TenantProfilePage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||
const tenantId = tenantIdParam ?? "";
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (!tenantId) {
|
||||
return (
|
||||
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const parentQuery = useQuery({
|
||||
@@ -74,6 +70,7 @@ export function TenantProfilePage() {
|
||||
const [orgUnitType, setOrgUnitType] = useState("");
|
||||
const [tenantVisibility, setTenantVisibility] =
|
||||
useState<TenantVisibility>("public");
|
||||
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantQuery.data) {
|
||||
@@ -88,6 +85,7 @@ export function TenantProfilePage() {
|
||||
setParentId(tenantQuery.data.parentId ?? "");
|
||||
setOrgUnitType(orgConfig.orgUnitType);
|
||||
setTenantVisibility(orgConfig.visibility);
|
||||
setWorksmobileExcluded(orgConfig.worksmobileExcluded);
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
@@ -105,6 +103,7 @@ export function TenantProfilePage() {
|
||||
orgConfigCandidate,
|
||||
])
|
||||
: false;
|
||||
const orgUnitTypeOptions = getOrgUnitTypeOptionsForTenantType(type);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (overrideForceDomains?: string[]) => {
|
||||
@@ -113,6 +112,7 @@ export function TenantProfilePage() {
|
||||
? mergeTenantOrgConfig(baseConfig, {
|
||||
orgUnitType,
|
||||
visibility: tenantVisibility,
|
||||
worksmobileExcluded,
|
||||
})
|
||||
: removeTenantOrgConfig(baseConfig);
|
||||
|
||||
@@ -197,6 +197,12 @@ export function TenantProfilePage() {
|
||||
? isSeedTenant(tenantQuery.data)
|
||||
: false;
|
||||
|
||||
if (!tenantId) {
|
||||
return (
|
||||
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (isProtectedSeedTenant) {
|
||||
return;
|
||||
@@ -224,78 +230,46 @@ export function TenantProfilePage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-[var(--color-panel)] mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.admin.tenants.profile.subtitle",
|
||||
"슬러그 및 상태 변경은 즉시 적용됩니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
<Card className="mt-4 bg-[var(--color-panel)]">
|
||||
<CardHeader className="px-5 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.admin.tenants.profile.subtitle",
|
||||
"슬러그 및 상태 변경은 즉시 적용됩니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-3 px-5 pb-4">
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
|
||||
</Label>
|
||||
<select
|
||||
id="type"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
<option value="COMPANY">
|
||||
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
||||
</option>
|
||||
<option value="COMPANY_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.company_group",
|
||||
"COMPANY_GROUP (그룹사/지주사)",
|
||||
)}
|
||||
</option>
|
||||
<option value="ORGANIZATION">
|
||||
{t(
|
||||
"domain.tenant_type.organization",
|
||||
"ORGANIZATION (정규 조직)",
|
||||
)}
|
||||
</option>
|
||||
<option value="USER_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.user_group",
|
||||
"USER_GROUP (내부 부서/팀)",
|
||||
)}
|
||||
</option>
|
||||
<option value="PERSONAL">
|
||||
{t(
|
||||
"domain.tenant_type.personal",
|
||||
"PERSONAL (개인 워크스페이스)",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
data-testid="tenant-parent-org-config-layout"
|
||||
className="grid gap-4 md:grid-cols-4"
|
||||
data-testid="tenant-profile-primary-row"
|
||||
className="grid gap-3 lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.8fr)_minmax(320px,1.4fr)]"
|
||||
>
|
||||
<div
|
||||
data-testid="tenant-parent-picker-slot"
|
||||
className={canEditOrgConfig ? "md:col-span-2" : "md:col-span-4"}
|
||||
>
|
||||
<div data-testid="tenant-name-slot" className="space-y-1">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div data-testid="tenant-slug-slot" className="space-y-1">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||
</Label>
|
||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
</div>
|
||||
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label={t(
|
||||
@@ -306,18 +280,61 @@ export function TenantProfilePage() {
|
||||
onChange={setParentId}
|
||||
tenants={parentQuery.data?.items ?? []}
|
||||
noneLabel={t("ui.common.none", "없음 (최상위)")}
|
||||
helpText={t(
|
||||
"ui.admin.tenants.profile.form.parent_help",
|
||||
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
|
||||
)}
|
||||
excludeTenantId={tenantId}
|
||||
compact
|
||||
controlTestId="tenant-parent-picker-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="tenant-profile-config-row"
|
||||
className="grid gap-3 lg:grid-cols-[minmax(190px,1fr)_minmax(150px,0.8fr)_minmax(150px,0.8fr)_minmax(190px,0.9fr)]"
|
||||
>
|
||||
<div data-testid="tenant-type-slot" className="space-y-1">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
|
||||
</Label>
|
||||
<select
|
||||
id="type"
|
||||
data-testid="tenant-type-select"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
<option value="COMPANY">
|
||||
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
||||
</option>
|
||||
<option value="COMPANY_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.company_group",
|
||||
"COMPANY_GROUP (그룹사/지주사)",
|
||||
)}
|
||||
</option>
|
||||
<option value="ORGANIZATION">
|
||||
{t(
|
||||
"domain.tenant_type.organization",
|
||||
"ORGANIZATION (정규 조직)",
|
||||
)}
|
||||
</option>
|
||||
<option value="USER_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.user_group",
|
||||
"USER_GROUP (내부 부서/팀)",
|
||||
)}
|
||||
</option>
|
||||
<option value="PERSONAL">
|
||||
{t(
|
||||
"domain.tenant_type.personal",
|
||||
"PERSONAL (개인 워크스페이스)",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{canEditOrgConfig && (
|
||||
<>
|
||||
<div
|
||||
data-testid="tenant-org-unit-type-slot"
|
||||
className="space-y-2"
|
||||
className="space-y-1"
|
||||
>
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
@@ -326,19 +343,20 @@ export function TenantProfilePage() {
|
||||
)}
|
||||
</Label>
|
||||
<select
|
||||
data-testid="tenant-org-unit-type-select"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={orgUnitType}
|
||||
onChange={(event) => setOrgUnitType(event.target.value)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
|
||||
{orgUnitTypeOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div data-testid="tenant-visibility-slot" className="space-y-2">
|
||||
<div data-testid="tenant-visibility-slot" className="space-y-1">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
|
||||
</Label>
|
||||
@@ -358,68 +376,92 @@ export function TenantProfilePage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
data-testid="tenant-worksmobile-excluded-slot"
|
||||
className="space-y-1"
|
||||
>
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.tenants.profile.worksmobile_sync",
|
||||
"WORKS 연동",
|
||||
)}
|
||||
</Label>
|
||||
<select
|
||||
id="worksmobileExcluded"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={worksmobileExcluded ? "excluded" : "enabled"}
|
||||
onChange={(event) =>
|
||||
setWorksmobileExcluded(event.target.value === "excluded")
|
||||
}
|
||||
>
|
||||
<option value="enabled">
|
||||
{t(
|
||||
"ui.admin.tenants.profile.worksmobile_enabled",
|
||||
"연동",
|
||||
)}
|
||||
</option>
|
||||
<option value="excluded">
|
||||
{t(
|
||||
"ui.admin.tenants.profile.worksmobile_excluded",
|
||||
"제외",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||
</Label>
|
||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.description", "설명")}
|
||||
</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.tenants.profile.allowed_domains",
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<DomainTagInput
|
||||
id="tenant-domains"
|
||||
value={domains}
|
||||
onChange={setDomains}
|
||||
tenants={parentQuery.data?.items ?? []}
|
||||
currentTenantId={tenantId}
|
||||
confirmedConflicts={forceDomainConflicts}
|
||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||
placeholder="example.com, example.kr"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.profile.allowed_domains_help",
|
||||
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.status", "상태")}
|
||||
</Label>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => setStatus("active")}
|
||||
>
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => setStatus("inactive")}
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(260px,0.9fr)_minmax(360px,1.4fr)_auto]">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.description", "설명")}
|
||||
</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.tenants.profile.allowed_domains",
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<DomainTagInput
|
||||
id="tenant-domains"
|
||||
value={domains}
|
||||
onChange={setDomains}
|
||||
tenants={parentQuery.data?.items ?? []}
|
||||
currentTenantId={tenantId}
|
||||
confirmedConflicts={forceDomainConflicts}
|
||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||
placeholder="example.com, example.kr"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.status", "상태")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => setStatus("active")}
|
||||
>
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => setStatus("inactive")}
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{errorMsg && (
|
||||
@@ -430,7 +472,7 @@ export function TenantProfilePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
|
||||
@@ -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() {
|
||||
@@ -34,8 +34,7 @@ export function TenantSchemaPage() {
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccess =
|
||||
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||
const canAccess = profileRole === "super_admin";
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, Building2, Plus } from "lucide-react";
|
||||
import { Building2, Plus } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
Loader2,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
User,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { Loader2, Mail, Plus, User, UserPlus } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
@@ -19,12 +11,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -80,7 +66,7 @@ function TenantUsersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemoveMember = (userId: string, userName: string) => {
|
||||
const _handleRemoveMember = (userId: string, userName: string) => {
|
||||
if (!tenantSlug) return;
|
||||
if (
|
||||
window.confirm(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatWorksmobilePersonName,
|
||||
formatWorksmobileUpdateDetails,
|
||||
getDefaultGroupComparisonFilters,
|
||||
getDefaultUserComparisonFilters,
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
@@ -460,6 +461,17 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
).toEqual([rows[0]]);
|
||||
});
|
||||
|
||||
it("shows update-needed user rows by default", () => {
|
||||
const rows = [
|
||||
{ resourceType: "USER", status: "needs_update", baronId: "user-1" },
|
||||
{ resourceType: "USER", status: "matched", baronId: "user-2" },
|
||||
];
|
||||
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, getDefaultUserComparisonFilters()),
|
||||
).toEqual([rows[0]]);
|
||||
});
|
||||
|
||||
it("formats update details for changed organization rows", () => {
|
||||
expect(
|
||||
formatWorksmobileUpdateDetails({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
@@ -68,8 +68,13 @@ export function getTenantViewRows(
|
||||
tenants: TenantSummary[],
|
||||
viewMode: TenantViewMode,
|
||||
scopeTenantId = "",
|
||||
isSearchActive = false,
|
||||
): TenantViewRow[] {
|
||||
const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined);
|
||||
const { subTree } = buildTenantFullTree(
|
||||
tenants,
|
||||
scopeTenantId || undefined,
|
||||
isSearchActive,
|
||||
);
|
||||
const treeRows: TenantViewRow[] = [];
|
||||
collectTenantTreeRows(subTree, 0, treeRows);
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canAccessWorksmobile,
|
||||
HANMAC_FAMILY_TENANT_ID,
|
||||
} from "./worksmobileAccess";
|
||||
|
||||
describe("worksmobile access", () => {
|
||||
it("allows super admins", () => {
|
||||
expect(canAccessWorksmobile({ role: "super_admin" })).toBe(true);
|
||||
});
|
||||
|
||||
it("allows hanmac-family tenant managers", () => {
|
||||
expect(
|
||||
canAccessWorksmobile({
|
||||
role: "tenant_admin",
|
||||
manageableTenants: [{ id: HANMAC_FAMILY_TENANT_ID }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canAccessWorksmobile({
|
||||
role: "tenant_admin",
|
||||
manageableTenants: [{ slug: "hanmac-family" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects admins that do not manage hanmac-family", () => {
|
||||
expect(
|
||||
canAccessWorksmobile({
|
||||
role: "tenant_admin",
|
||||
manageableTenants: [{ slug: "other-company" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canAccessWorksmobile({
|
||||
role: "user",
|
||||
tenantId: HANMAC_FAMILY_TENANT_ID,
|
||||
tenantSlug: "hanmac-family",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(canAccessWorksmobile({ role: "user" })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects admins that only manage Worksmobile-excluded hanmac-family tenants", () => {
|
||||
expect(
|
||||
canAccessWorksmobile({
|
||||
role: "tenant_admin",
|
||||
manageableTenants: [
|
||||
{
|
||||
slug: "hanmac-family",
|
||||
config: { worksmobileExcluded: true },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
61
adminfront/src/features/tenants/routes/worksmobileAccess.ts
Normal file
61
adminfront/src/features/tenants/routes/worksmobileAccess.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { isSuperAdminRole } from "../../../lib/roles";
|
||||
|
||||
export const HANMAC_FAMILY_TENANT_ID = "038326b6-954a-48a7-a85f-efd83f62b82a";
|
||||
export const HANMAC_FAMILY_TENANT_SLUG = "hanmac-family";
|
||||
|
||||
export type WorksmobileAccessProfile = {
|
||||
role?: string;
|
||||
tenantId?: string;
|
||||
tenantSlug?: string;
|
||||
tenant?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
manageableTenants?: Array<{
|
||||
id?: string;
|
||||
slug?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function isWorksmobileExcludedConfig(config?: Record<string, unknown>) {
|
||||
const rawValue = config?.worksmobileExcluded;
|
||||
return (
|
||||
rawValue === true ||
|
||||
String(rawValue ?? "")
|
||||
.trim()
|
||||
.toLowerCase() === "true"
|
||||
);
|
||||
}
|
||||
|
||||
function isProfileTenantWorksmobileExcluded(
|
||||
profile?: WorksmobileAccessProfile | null,
|
||||
) {
|
||||
if (isWorksmobileExcludedConfig(profile?.tenant?.config)) {
|
||||
return true;
|
||||
}
|
||||
return (profile?.manageableTenants ?? []).some((tenant) => {
|
||||
const isCurrentTenant =
|
||||
(profile?.tenantId && tenant.id === profile.tenantId) ||
|
||||
(profile?.tenantSlug && tenant.slug === profile.tenantSlug);
|
||||
return isCurrentTenant && isWorksmobileExcludedConfig(tenant.config);
|
||||
});
|
||||
}
|
||||
|
||||
export function canAccessWorksmobile(
|
||||
profile?: WorksmobileAccessProfile | null,
|
||||
) {
|
||||
if (isSuperAdminRole(profile?.role)) {
|
||||
return true;
|
||||
}
|
||||
if (isProfileTenantWorksmobileExcluded(profile)) {
|
||||
return false;
|
||||
}
|
||||
return (profile?.manageableTenants ?? []).some(
|
||||
(tenant) =>
|
||||
!isWorksmobileExcludedConfig(tenant.config) &&
|
||||
(tenant.id === HANMAC_FAMILY_TENANT_ID ||
|
||||
tenant.slug === HANMAC_FAMILY_TENANT_SLUG),
|
||||
);
|
||||
}
|
||||
@@ -300,6 +300,9 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
|
||||
}
|
||||
|
||||
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||
if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) {
|
||||
return [`최근 실패: ${row.worksmobileLastError}`];
|
||||
}
|
||||
if (row.status !== "needs_update") {
|
||||
return [];
|
||||
}
|
||||
@@ -310,6 +313,21 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||
if (baronName && worksmobileName && baronName !== worksmobileName) {
|
||||
details.push(`이름: ${worksmobileName} -> ${baronName}`);
|
||||
}
|
||||
if (row.resourceType === "USER") {
|
||||
const expectedExternalKey = row.baronId?.trim() ?? "";
|
||||
const actualExternalKey = row.externalKey?.trim() ?? "";
|
||||
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
|
||||
details.push(
|
||||
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
|
||||
);
|
||||
}
|
||||
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
|
||||
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
|
||||
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
|
||||
details.push(`이메일: ${actualEmail} -> ${expectedEmail}`);
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
const expectedParent =
|
||||
row.baronParentWorksmobileName ??
|
||||
@@ -395,6 +413,10 @@ export const comparisonFilterOptions: Array<{
|
||||
|
||||
export const userFilterOptions = comparisonFilterOptions;
|
||||
|
||||
export function getDefaultUserComparisonFilters(): WorksmobileComparisonFilter[] {
|
||||
return ["baron_only", "needs_update", "works_only"];
|
||||
}
|
||||
|
||||
export function getDefaultGroupComparisonFilters(): WorksmobileComparisonFilter[] {
|
||||
return ["baron_only", "needs_update", "works_only"];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
ORG_UNIT_TYPE_OPTIONS,
|
||||
mergeTenantOrgConfig,
|
||||
ORG_UNIT_TYPE_OPTIONS,
|
||||
readTenantOrgConfig,
|
||||
removeTenantOrgConfig,
|
||||
shouldAllowHanmacOrgConfig,
|
||||
} from "./orgConfig";
|
||||
|
||||
@@ -49,17 +50,65 @@ describe("tenant org config", () => {
|
||||
it("reads and writes tenant visibility and org unit type", () => {
|
||||
expect(
|
||||
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
|
||||
).toEqual({ orgUnitType: "팀", visibility: "private" });
|
||||
).toEqual({
|
||||
orgUnitType: "팀",
|
||||
visibility: "private",
|
||||
worksmobileExcluded: false,
|
||||
});
|
||||
expect(
|
||||
readTenantOrgConfig({ visibility: "internal", orgUnitType: "센터" }),
|
||||
).toEqual({ orgUnitType: "센터", visibility: "internal" });
|
||||
).toEqual({
|
||||
orgUnitType: "센터",
|
||||
visibility: "internal",
|
||||
worksmobileExcluded: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeTenantOrgConfig(
|
||||
{ userSchema: [], visibility: "private", orgUnitType: "팀" },
|
||||
{ orgUnitType: "", visibility: "internal" },
|
||||
{
|
||||
orgUnitType: "",
|
||||
visibility: "internal",
|
||||
worksmobileExcluded: false,
|
||||
},
|
||||
),
|
||||
).toEqual({ userSchema: [], visibility: "internal" });
|
||||
).toEqual({
|
||||
userSchema: [],
|
||||
visibility: "internal",
|
||||
worksmobileExcluded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads, writes, and removes the Worksmobile exclusion flag", () => {
|
||||
expect(readTenantOrgConfig({ worksmobileExcluded: true })).toMatchObject({
|
||||
worksmobileExcluded: true,
|
||||
});
|
||||
expect(readTenantOrgConfig({ worksmobileExcluded: "true" })).toMatchObject({
|
||||
worksmobileExcluded: true,
|
||||
});
|
||||
expect(
|
||||
mergeTenantOrgConfig(
|
||||
{ userSchema: [], worksmobileExcluded: false },
|
||||
{
|
||||
orgUnitType: "팀",
|
||||
visibility: "private",
|
||||
worksmobileExcluded: true,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
userSchema: [],
|
||||
orgUnitType: "팀",
|
||||
visibility: "private",
|
||||
worksmobileExcluded: true,
|
||||
});
|
||||
expect(
|
||||
removeTenantOrgConfig({
|
||||
userSchema: [],
|
||||
orgUnitType: "팀",
|
||||
visibility: "private",
|
||||
worksmobileExcluded: true,
|
||||
}),
|
||||
).toEqual({ userSchema: [] });
|
||||
});
|
||||
|
||||
it("includes task-force and executive-direct org unit types", () => {
|
||||
|
||||
@@ -14,6 +14,13 @@ export const ORG_UNIT_TYPE_OPTIONS = [
|
||||
"임원직속",
|
||||
] as const;
|
||||
|
||||
export const USER_GROUP_ORG_UNIT_TYPE_OPTIONS = [
|
||||
"팀",
|
||||
"TF",
|
||||
"TF팀",
|
||||
"셀",
|
||||
] as const;
|
||||
|
||||
export const TENANT_VISIBILITY_OPTIONS = [
|
||||
{ label: "공개", value: "public" },
|
||||
{ label: "내부", value: "internal" },
|
||||
@@ -26,6 +33,7 @@ export type TenantVisibility =
|
||||
export type TenantOrgConfig = {
|
||||
orgUnitType: string;
|
||||
visibility: TenantVisibility;
|
||||
worksmobileExcluded: boolean;
|
||||
};
|
||||
|
||||
const ORG_UNIT_TYPE_SET = new Set<string>(ORG_UNIT_TYPE_OPTIONS);
|
||||
@@ -55,17 +63,29 @@ export function shouldAllowHanmacOrgConfig(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getOrgUnitTypeOptionsForTenantType(type: string) {
|
||||
return type === "USER_GROUP"
|
||||
? USER_GROUP_ORG_UNIT_TYPE_OPTIONS
|
||||
: ORG_UNIT_TYPE_OPTIONS;
|
||||
}
|
||||
|
||||
export function readTenantOrgConfig(
|
||||
config: Record<string, unknown> | undefined,
|
||||
): TenantOrgConfig {
|
||||
const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
|
||||
const rawOrgUnitType = String(config?.orgUnitType ?? "");
|
||||
const rawWorksmobileExcluded = config?.worksmobileExcluded;
|
||||
|
||||
return {
|
||||
orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
|
||||
visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
|
||||
? (rawVisibility as TenantVisibility)
|
||||
: "public",
|
||||
worksmobileExcluded:
|
||||
rawWorksmobileExcluded === true ||
|
||||
String(rawWorksmobileExcluded ?? "")
|
||||
.trim()
|
||||
.toLowerCase() === "true",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +96,7 @@ export function mergeTenantOrgConfig(
|
||||
const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
|
||||
const merged = { ...rest };
|
||||
merged.visibility = next.visibility;
|
||||
merged.worksmobileExcluded = next.worksmobileExcluded;
|
||||
|
||||
if (next.orgUnitType) {
|
||||
merged.orgUnitType = next.orgUnitType;
|
||||
@@ -90,6 +111,7 @@ export function removeTenantOrgConfig(
|
||||
const {
|
||||
orgUnitType: _orgUnitType,
|
||||
visibility: _visibility,
|
||||
worksmobileExcluded: _worksmobileExcluded,
|
||||
...rest
|
||||
} = config ?? {};
|
||||
return rest;
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("tenantCsvImport", () => {
|
||||
|
||||
it("parses tenant CSV rows with the supported import columns", () => {
|
||||
const rows = parseTenantCSV(
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터\n",
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터,no\n",
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
@@ -82,6 +82,7 @@ describe("tenantCsvImport", () => {
|
||||
emailDomain: "hanmac-tech.example.com",
|
||||
visibility: "internal",
|
||||
orgUnitType: "센터",
|
||||
worksmobileSync: "no",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -111,7 +112,7 @@ describe("tenantCsvImport", () => {
|
||||
|
||||
it("serializes selected matches by filling tenant_id before upload", () => {
|
||||
const rows = parseTenantCSV(
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀\n",
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀,no\n",
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
@@ -119,10 +120,10 @@ describe("tenantCsvImport", () => {
|
||||
});
|
||||
|
||||
expect(csv.split("\n")[0]).toBe(
|
||||
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
|
||||
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync",
|
||||
);
|
||||
expect(csv).toContain(
|
||||
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀",
|
||||
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀,no",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -150,6 +151,26 @@ 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(
|
||||
`tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain
|
||||
${exportedTenantId},Tenant With UUID,COMPANY,,,tenant-with-uuid,Memo,tenant-with-uuid.example.com
|
||||
`,
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: {
|
||||
mode: "create",
|
||||
slug: "tenant-with-uuid",
|
||||
},
|
||||
});
|
||||
|
||||
expect(csv).toContain(
|
||||
`${exportedTenantId},Tenant With UUID,COMPANY,,,tenant-with-uuid,Memo,tenant-with-uuid.example.com`,
|
||||
);
|
||||
});
|
||||
|
||||
it("remaps child parent_tenant_id from source ids to selected staging ids", () => {
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
@@ -233,10 +254,10 @@ describe("tenantCsvImport", () => {
|
||||
});
|
||||
|
||||
expect(csv.split("\n")[0]).toBe(
|
||||
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
|
||||
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync",
|
||||
);
|
||||
expect(csv).toContain(
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,,,,yes",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export type TenantCSVRow = {
|
||||
emailDomain: string;
|
||||
visibility: string;
|
||||
orgUnitType: string;
|
||||
worksmobileSync: string;
|
||||
};
|
||||
|
||||
export type TenantCSVParseOptions = {
|
||||
@@ -80,6 +81,7 @@ const importHeaders = [
|
||||
"email_domain",
|
||||
"visibility",
|
||||
"org_unit_type",
|
||||
"worksmobile_sync",
|
||||
];
|
||||
|
||||
const headerAliases: Record<string, TenantCSVSourceKey> = {
|
||||
@@ -116,6 +118,11 @@ const headerAliases: Record<string, TenantCSVSourceKey> = {
|
||||
organization_type: "orgUnitType",
|
||||
orgtype: "orgUnitType",
|
||||
org_type: "orgUnitType",
|
||||
worksmobile: "worksmobileSync",
|
||||
worksmobilesync: "worksmobileSync",
|
||||
worksmobile_sync: "worksmobileSync",
|
||||
works_sync: "worksmobileSync",
|
||||
works: "worksmobileSync",
|
||||
};
|
||||
|
||||
export function parseTenantCSV(
|
||||
@@ -175,6 +182,7 @@ export function parseTenantCSV(
|
||||
emailDomain: value("emailDomain"),
|
||||
visibility: value("visibility"),
|
||||
orgUnitType: value("orgUnitType"),
|
||||
worksmobileSync: normalizeWorksmobileSync(value("worksmobileSync")),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -305,6 +313,7 @@ export function serializeTenantImportCSV(
|
||||
preview.row.emailDomain,
|
||||
preview.row.visibility,
|
||||
preview.row.orgUnitType,
|
||||
preview.row.worksmobileSync || "yes",
|
||||
]);
|
||||
}
|
||||
return `${lines.map(formatCSVRecord).join("\n")}\n`;
|
||||
@@ -325,12 +334,15 @@ function buildTargetTenantIds(
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceTenantId = isUUIDLikeTenantId(preview.row.tenantId)
|
||||
? preview.row.tenantId
|
||||
: "";
|
||||
const targetTenantId =
|
||||
typeof resolution === "string"
|
||||
? resolution || preview.row.tenantId
|
||||
? resolution || sourceTenantId
|
||||
: resolution.mode === "existing"
|
||||
? resolution.tenantId
|
||||
: resolution.tenantId || createTenantImportId();
|
||||
: resolution.tenantId || sourceTenantId || createTenantImportId();
|
||||
const targetSlug =
|
||||
typeof resolution === "object" && resolution.mode === "create"
|
||||
? resolution.slug || preview.defaultCreateSlug
|
||||
@@ -400,6 +412,12 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
function findTenantImportConflicts(
|
||||
row: TenantCSVRow,
|
||||
tenants: TenantSummary[],
|
||||
@@ -519,6 +537,30 @@ function normalizeHeader(value: string) {
|
||||
return value.trim().toLowerCase().replaceAll(" ", "_");
|
||||
}
|
||||
|
||||
function normalizeWorksmobileSync(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
[
|
||||
"no",
|
||||
"n",
|
||||
"false",
|
||||
"0",
|
||||
"off",
|
||||
"none",
|
||||
"excluded",
|
||||
"exclude",
|
||||
"not_sync",
|
||||
"not-synced",
|
||||
"미연동",
|
||||
"연동안함",
|
||||
"제외",
|
||||
].includes(normalized)
|
||||
) {
|
||||
return "no";
|
||||
}
|
||||
return "yes";
|
||||
}
|
||||
|
||||
function slugFromMailingList(value: string) {
|
||||
if (!value) return "";
|
||||
return normalizeTenantSlug(value.split("@")[0] ?? value);
|
||||
@@ -569,10 +611,37 @@ function suggestUniqueTenantSlug(value: string, tenants: TenantSummary[]) {
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
// 한글 조직명을 영어로 유추하거나 정규화하는 맵 (자주 쓰이는 단어)
|
||||
const commonMappings: Record<string, string> = {
|
||||
총괄기획실: "gpd",
|
||||
기술개발센터: "tdc",
|
||||
경영기획: "planning",
|
||||
영업: "sales",
|
||||
인프라: "infra",
|
||||
건설: "construction",
|
||||
운영: "ops",
|
||||
환경: "env",
|
||||
사업: "biz",
|
||||
본부: "hq",
|
||||
부: "dept",
|
||||
팀: "team",
|
||||
지원: "support",
|
||||
};
|
||||
|
||||
const result = value.trim();
|
||||
|
||||
// 1. 전체 매칭 확인
|
||||
if (commonMappings[result]) {
|
||||
return commonMappings[result];
|
||||
}
|
||||
|
||||
// 2. 부분 단어 치환 및 정규화
|
||||
return result
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]+/g, "-")
|
||||
.replace(/[^a-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]+/g, "-") // 특수문자 제거
|
||||
.split("-")
|
||||
.map((part) => commonMappings[part] || part) // 부분 단어 변환
|
||||
.join("-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Building2, Plus, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
@@ -21,9 +20,9 @@ import {
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
type TenantSummary,
|
||||
fetchAllTenants,
|
||||
fetchGroups,
|
||||
type TenantSummary,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export default function GlobalUserGroupListPage() {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CornerDownRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -52,7 +50,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
@@ -62,25 +59,18 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} 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) => {
|
||||
@@ -413,7 +403,7 @@ const MemberTable: React.FC<{
|
||||
|
||||
function TenantUserGroupsTab() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const _navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string>(tenantId || "");
|
||||
@@ -452,7 +442,7 @@ function TenantUserGroupsTab() {
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
const { currentBase, subTree } = useMemo(() => {
|
||||
const { currentBase } = useMemo(() => {
|
||||
const allItems = allTenantsData?.items ?? [];
|
||||
return buildTenantFullTree(allItems, tenantId);
|
||||
}, [allTenantsData, tenantId]);
|
||||
@@ -482,8 +472,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(
|
||||
@@ -853,7 +845,7 @@ const UserAddDialog: React.FC<{
|
||||
try {
|
||||
const res = await fetchUsers(20, 0, userSearch);
|
||||
setSearchResults(res.items);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패"));
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
|
||||
@@ -574,9 +574,9 @@ export function UserGroupDetailPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupRoles.map((role, idx) => (
|
||||
groupRoles.map((role) => (
|
||||
<TableRow
|
||||
key={`${role.tenantId}-${role.relation}-${idx}`}
|
||||
key={`${role.tenantId}-${role.relation}`}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -20,7 +22,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Checkbox } from "../../components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -38,27 +39,32 @@ 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 { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
type OrgChartTenantSelection,
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
getTenantGradeOptions,
|
||||
type OrgChartTenantSelection,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
|
||||
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
||||
type UserFormValues = UserCreateRequest & {
|
||||
metadata: Record<string, unknown> & {
|
||||
sub_email?: string[];
|
||||
};
|
||||
};
|
||||
type UserCategory = "hanmac" | "external" | "personal";
|
||||
|
||||
type PickerTarget = { kind: "appointment"; index: number };
|
||||
@@ -105,7 +111,10 @@ function createEmptyAppointment(): AppointmentDraft {
|
||||
tenantId: "",
|
||||
tenantName: "",
|
||||
tenantSlug: "",
|
||||
isPrimary: false,
|
||||
isOwner: false,
|
||||
isAdmin: false,
|
||||
isManager: false,
|
||||
grade: "",
|
||||
jobTitle: "",
|
||||
position: "",
|
||||
@@ -132,6 +141,8 @@ function UserCreatePage() {
|
||||
);
|
||||
const [isResolvingTenant, setIsResolvingTenant] = React.useState(false);
|
||||
|
||||
const [newSubEmail, setNewSubEmail] = React.useState("");
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
@@ -142,6 +153,7 @@ function UserCreatePage() {
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -161,16 +173,41 @@ function UserCreatePage() {
|
||||
position: "",
|
||||
jobTitle: "",
|
||||
role: "user",
|
||||
metadata: {},
|
||||
metadata: {
|
||||
sub_email: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Lock company for tenant_admin
|
||||
const currentSubEmails = (watch("metadata.sub_email") as string[]) || [];
|
||||
|
||||
const handleAddSubEmail = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const value = newSubEmail.trim().replace(/,/g, "");
|
||||
if (value?.includes("@") && !currentSubEmails.includes(value)) {
|
||||
setValue("metadata.sub_email", [...currentSubEmails, value], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setNewSubEmail("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSubEmail = (emailToRemove: string) => {
|
||||
setValue(
|
||||
"metadata.sub_email",
|
||||
currentSubEmails.filter((e) => e !== emailToRemove),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
};
|
||||
|
||||
// Lock company for non-super_admin
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
|
||||
if (profileRole !== "super_admin" && profile?.tenantSlug) {
|
||||
setValue("tenantSlug", profile.tenantSlug);
|
||||
}
|
||||
}, [profile, setValue]);
|
||||
}, [profile, profileRole, setValue]);
|
||||
|
||||
const hanmacFamilyTenantId = React.useMemo(() => {
|
||||
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
||||
@@ -314,8 +351,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;
|
||||
}),
|
||||
@@ -367,10 +404,15 @@ function UserCreatePage() {
|
||||
const {
|
||||
hanmacFamily: _hanmacFamily,
|
||||
userType: _userType,
|
||||
sub_email: rawSubEmail,
|
||||
...formMetadata
|
||||
} = data.metadata ?? {};
|
||||
|
||||
const sub_email = Array.isArray(rawSubEmail) ? rawSubEmail : [];
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
...formMetadata,
|
||||
...(sub_email.length > 0 ? { sub_email } : { sub_email: [] }),
|
||||
};
|
||||
|
||||
const payload: UserCreateRequest = {
|
||||
@@ -425,8 +467,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,
|
||||
@@ -442,12 +486,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;
|
||||
@@ -481,6 +524,21 @@ function UserCreatePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Access Control: Only super_admin can create users
|
||||
if (profile && profileRole !== "super_admin") {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<ShieldAlert size={48} className="text-destructive" />
|
||||
<h3 className="text-lg font-bold">
|
||||
{t("msg.admin.common.forbidden", "이 작업을 수행할 권한이 없습니다.")}
|
||||
</h3>
|
||||
<Button onClick={() => navigate("/")}>
|
||||
{t("ui.common.go_home", "홈으로 이동")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
@@ -580,6 +638,73 @@ function UserCreatePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="sub_email_input"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{t("ui.admin.users.create.form.sub_email", "보조 이메일")}
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2 mb-1">
|
||||
{currentSubEmails.map((email) => (
|
||||
<div
|
||||
key={email}
|
||||
className="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 bg-secondary text-secondary-foreground"
|
||||
>
|
||||
{email}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveSubEmail(email)}
|
||||
className="text-muted-foreground hover:text-foreground ml-1 rounded-full p-0.5 hover:bg-muted transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="sub_email_input"
|
||||
value={newSubEmail}
|
||||
onChange={(e) => setNewSubEmail(e.target.value)}
|
||||
onKeyDown={handleAddSubEmail}
|
||||
className="pr-20"
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.sub_email_placeholder",
|
||||
"추가할 이메일 입력 후 Enter",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1 h-8 text-xs font-bold"
|
||||
data-testid="add-sub-email-btn"
|
||||
onClick={() => {
|
||||
const value = newSubEmail.trim().replace(/,/g, "");
|
||||
if (
|
||||
value?.includes("@") &&
|
||||
!currentSubEmails.includes(value)
|
||||
) {
|
||||
setValue(
|
||||
"metadata.sub_email",
|
||||
[...currentSubEmails, value],
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
setNewSubEmail("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>{" "}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
* 여러 개 입력 가능. 입력 후 엔터를 눌러 추가하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">
|
||||
@@ -712,7 +837,7 @@ function UserCreatePage() {
|
||||
id="tenantSlug"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("tenantSlug")}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
disabled={profileRole !== "super_admin"}
|
||||
>
|
||||
{nonHanmacFamilyTenants.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.slug}>
|
||||
@@ -771,6 +896,7 @@ function UserCreatePage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addAppointment}
|
||||
data-testid="add-appointment-btn"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("ui.common.add", "추가")}
|
||||
@@ -788,45 +914,73 @@ function UserCreatePage() {
|
||||
data-testid={`appointment-tenant-owner-line-${index}`}
|
||||
>
|
||||
<Label>소속 테넌트</Label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setPickerTarget({
|
||||
kind: "appointment",
|
||||
index,
|
||||
})
|
||||
}
|
||||
disabled={isResolvingTenant}
|
||||
data-testid={`appointment-tenant-picker-${index}`}
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
{appointment.tenantName || "테넌트 선택"}
|
||||
</Button>
|
||||
{appointment.tenantSlug && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{appointment.tenantSlug}
|
||||
</span>
|
||||
)}
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Switch
|
||||
checked={appointment.isOwner}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isOwner: checked === true,
|
||||
<div
|
||||
className="flex items-center justify-between gap-3"
|
||||
data-testid={`appointment-tenant-owner-controls-${index}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-w-0 max-w-full"
|
||||
onClick={() =>
|
||||
setPickerTarget({
|
||||
kind: "appointment",
|
||||
index,
|
||||
})
|
||||
}
|
||||
aria-label={t(
|
||||
disabled={isResolvingTenant}
|
||||
data-testid={`appointment-tenant-picker-${index}`}
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{appointment.tenantName || "테넌트 선택"}
|
||||
</span>
|
||||
</Button>
|
||||
{appointment.tenantSlug && (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{appointment.tenantSlug}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={appointment.isPrimary === true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isPrimary: checked === true,
|
||||
})
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.users.detail.form.appointment_owner",
|
||||
"대표 조직",
|
||||
)}
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.appointment_owner",
|
||||
"대표 조직",
|
||||
)}
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.appointment_owner",
|
||||
"대표 조직",
|
||||
)}
|
||||
</label>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={appointment.isManager === true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isManager: checked === true,
|
||||
})
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.users.detail.form.appointment_manager",
|
||||
"조직장",
|
||||
)}
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.appointment_manager",
|
||||
"조직장",
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -838,15 +992,25 @@ function UserCreatePage() {
|
||||
<Label htmlFor={`appointment-grade-${index}`}>
|
||||
직급
|
||||
</Label>
|
||||
<Input
|
||||
<select
|
||||
id={`appointment-grade-${index}`}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={appointment.grade ?? ""}
|
||||
onChange={(event) =>
|
||||
updateAppointment(index, {
|
||||
grade: event.target.value,
|
||||
grade: event.target.value || "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
>
|
||||
<option value="">없음</option>
|
||||
{getTenantGradeOptions(appointment, tenants).map(
|
||||
(grade) => (
|
||||
<option key={grade} value={grade}>
|
||||
{grade}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`appointment-job-title-${index}`}>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import UserDetailPage from "./UserDetailPage";
|
||||
|
||||
const updateUserMock = vi.hoisted(() => vi.fn());
|
||||
const profileRoleMock = vi.hoisted(() => ({ role: "super_admin" }));
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
deleteUser: vi.fn(),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-user",
|
||||
role: profileRoleMock.role,
|
||||
name: "Admin",
|
||||
email: "admin@example.com",
|
||||
})),
|
||||
fetchPasswordPolicy: vi.fn(async () => ({ minLength: 12 })),
|
||||
fetchTenant: vi.fn(),
|
||||
fetchUser: vi.fn(async () => ({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
name: "사용자",
|
||||
phone: "01012345678",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "hanmac",
|
||||
tenant: {
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
joinedTenants: [],
|
||||
metadata: {
|
||||
employee_id: {
|
||||
"0": "h",
|
||||
"1": "j",
|
||||
"2": "k",
|
||||
"3": "w",
|
||||
"4": "o",
|
||||
"5": "n",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
})),
|
||||
fetchUserRpHistory: vi.fn(async () => []),
|
||||
updateUser: updateUserMock,
|
||||
}));
|
||||
|
||||
function renderUserDetailPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["/users/user-1"]}>
|
||||
<Routes>
|
||||
<Route path="/users/:id" element={<UserDetailPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("UserDetailPage Worksmobile employee number", () => {
|
||||
beforeEach(() => {
|
||||
updateUserMock.mockReset();
|
||||
updateUserMock.mockResolvedValue({});
|
||||
profileRoleMock.role = "super_admin";
|
||||
});
|
||||
|
||||
it("shows and saves metadata employee_id from the user edit form", async () => {
|
||||
renderUserDetailPage();
|
||||
|
||||
const employeeInput = await screen.findByLabelText("사번");
|
||||
|
||||
expect(employeeInput).toHaveValue("hjkwon");
|
||||
|
||||
fireEvent.change(employeeInput, { target: { value: "EMP001" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
expect(updateUserMock).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ employee_id: "EMP001" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows super admin to save a changed email", async () => {
|
||||
renderUserDetailPage();
|
||||
|
||||
const emailInput = await screen.findByLabelText("이메일");
|
||||
fireEvent.change(emailInput, { target: { value: "changed@example.com" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
expect(updateUserMock).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({
|
||||
email: "changed@example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows forbidden message for non-super admin", async () => {
|
||||
profileRoleMock.role = "tenant_admin";
|
||||
renderUserDetailPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("이 작업을 수행할 권한이 없습니다."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes metadata employee_id when the field is cleared", async () => {
|
||||
renderUserDetailPage();
|
||||
|
||||
const employeeInput = await screen.findByLabelText("사번");
|
||||
|
||||
fireEvent.change(employeeInput, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
const payload = updateUserMock.mock.calls[0][1];
|
||||
expect(payload.metadata).not.toHaveProperty("employee_id");
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
RefreshCw,
|
||||
Save,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
@@ -34,7 +36,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Checkbox } from "../../components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -59,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,
|
||||
@@ -70,18 +69,21 @@ 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,
|
||||
getTenantGradeOptions,
|
||||
isHanmacFamilyTenant,
|
||||
isHanmacFamilyUser,
|
||||
type OrgChartTenantSelection,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
@@ -93,7 +95,11 @@ import {
|
||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
|
||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||
email: string;
|
||||
metadata: Record<string, unknown> & {
|
||||
employee_id?: string;
|
||||
sub_email?: string | string[];
|
||||
};
|
||||
};
|
||||
type UserCategory = "hanmac" | "external" | "personal";
|
||||
|
||||
@@ -105,6 +111,67 @@ type AppointmentDraft = UserAppointment & {
|
||||
|
||||
const PASSWORD_RESET_MIN_LENGTH = 12;
|
||||
|
||||
function isMetadataRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cleanMetadataValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (isMetadataRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(
|
||||
([_, fieldValue]) =>
|
||||
fieldValue !== undefined && fieldValue !== null && fieldValue !== "",
|
||||
),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeEmployeeIDMetadataValue(value: unknown) {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return String(value).trim();
|
||||
}
|
||||
if (!isMetadataRecord(value)) {
|
||||
return "";
|
||||
}
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, fieldValue]) => ({
|
||||
index: Number(key),
|
||||
value: typeof fieldValue === "string" ? fieldValue : "",
|
||||
}))
|
||||
.filter((entry) => Number.isInteger(entry.index) && entry.value.length > 0)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
if (entries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return entries
|
||||
.map((entry) => entry.value)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeSubEmails(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.includes("@"));
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
return value
|
||||
.split(/[;,\n\r\t]/)
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email.includes("@"));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function createDraftId() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
|
||||
}
|
||||
@@ -138,6 +205,8 @@ function createEmptyAppointment(): AppointmentDraft {
|
||||
tenantSlug: "",
|
||||
isPrimary: false,
|
||||
isOwner: false,
|
||||
isAdmin: false,
|
||||
isManager: false,
|
||||
grade: "",
|
||||
jobTitle: "",
|
||||
position: "",
|
||||
@@ -316,8 +385,8 @@ function UserDetailPage() {
|
||||
const userId = params.id ?? "";
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
|
||||
const [_error, _setError] = React.useState<string | null>(null);
|
||||
const [_successMsg, _setSuccessMsg] = React.useState<string | null>(null);
|
||||
const [isPasswordResetOpen, setIsPasswordResetOpen] = React.useState(false);
|
||||
const [generatedPassword, setGeneratedPassword] = React.useState<
|
||||
string | null
|
||||
@@ -401,11 +470,34 @@ function UserDetailPage() {
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const isAdmin =
|
||||
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||
const isAdmin = profileRole === "super_admin";
|
||||
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
||||
const watchedStatus = watch("status");
|
||||
|
||||
const [newSubEmail, setNewSubEmail] = React.useState("");
|
||||
const currentSubEmails = (watch("metadata.sub_email") as string[]) || [];
|
||||
|
||||
const handleAddSubEmail = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const value = newSubEmail.trim().replace(/,/g, "");
|
||||
if (value?.includes("@") && !currentSubEmails.includes(value)) {
|
||||
setValue("metadata.sub_email", [...currentSubEmails, value], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setNewSubEmail("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSubEmail = (emailToRemove: string) => {
|
||||
setValue(
|
||||
"metadata.sub_email",
|
||||
currentSubEmails.filter((e) => e !== emailToRemove),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
};
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
|
||||
onSuccess: (_, newPass) => {
|
||||
@@ -551,8 +643,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;
|
||||
}),
|
||||
@@ -565,7 +657,7 @@ function UserDetailPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const setPrimaryAppointment = (targetIndex: number) => {
|
||||
const _setPrimaryAppointment = (targetIndex: number) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
current.map((appointment, index) => ({
|
||||
...appointment,
|
||||
@@ -612,6 +704,7 @@ function UserDetailPage() {
|
||||
: null);
|
||||
|
||||
reset({
|
||||
email: user.email || "",
|
||||
name: user.name,
|
||||
phone: user.phone || "",
|
||||
role: user.role,
|
||||
@@ -626,11 +719,23 @@ function UserDetailPage() {
|
||||
grade: user.grade || "",
|
||||
position: user.position || "",
|
||||
jobTitle: user.jobTitle || "",
|
||||
metadata:
|
||||
(user.metadata as unknown as Record<
|
||||
metadata: {
|
||||
...((user.metadata as unknown as Record<
|
||||
string,
|
||||
Record<string, string | number | boolean>
|
||||
>) || {},
|
||||
>) || {}),
|
||||
employee_id: normalizeEmployeeIDMetadataValue(
|
||||
user.metadata?.employee_id,
|
||||
),
|
||||
sub_email: Array.isArray(user.metadata?.sub_email)
|
||||
? user.metadata.sub_email
|
||||
: typeof user.metadata?.sub_email === "string"
|
||||
? user.metadata.sub_email
|
||||
.split(/[;,\n\r\t]/)
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e.includes("@"))
|
||||
: [],
|
||||
} as UserFormValues["metadata"],
|
||||
});
|
||||
const isUserHanmacFamily = isHanmacFamilyUser(
|
||||
user,
|
||||
@@ -663,6 +768,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
|
||||
@@ -676,6 +784,8 @@ function UserDetailPage() {
|
||||
isOwner:
|
||||
metadata.primaryTenantIsOwner === true &&
|
||||
tenant.id === fallbackAppointment?.id,
|
||||
isAdmin: false,
|
||||
isManager: false,
|
||||
grade: user.grade,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
@@ -689,6 +799,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,
|
||||
@@ -727,31 +839,48 @@ function UserDetailPage() {
|
||||
});
|
||||
|
||||
const onSubmit = async (data: UserFormValues) => {
|
||||
// Filter out undefined/null/empty strings from metadata
|
||||
const cleanMetadata = Object.fromEntries(
|
||||
Object.entries(data.metadata).map(([tenantId, fields]) => {
|
||||
const cleanFields = Object.fromEntries(
|
||||
Object.entries(fields).filter(
|
||||
([_, v]) => v !== undefined && v !== null && v !== "",
|
||||
),
|
||||
);
|
||||
return [tenantId, cleanFields];
|
||||
Object.entries(data.metadata ?? {}).flatMap(([key, value]) => {
|
||||
const cleanedValue = cleanMetadataValue(value);
|
||||
if (
|
||||
cleanedValue === undefined ||
|
||||
cleanedValue === null ||
|
||||
cleanedValue === ""
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [[key, cleanedValue]];
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
hanmacFamily: _hanmacFamily,
|
||||
userType: _userType,
|
||||
sub_email: rawSubEmail,
|
||||
...safeMetadata
|
||||
} = cleanMetadata;
|
||||
const subEmail = normalizeSubEmails(rawSubEmail);
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
...safeMetadata,
|
||||
...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }),
|
||||
};
|
||||
const employeeID = String(data.metadata?.employee_id ?? "").trim();
|
||||
if (employeeID) {
|
||||
metadata.employee_id = employeeID;
|
||||
} else {
|
||||
delete metadata.employee_id;
|
||||
}
|
||||
|
||||
const payload: UserUpdateRequest = {
|
||||
...data,
|
||||
metadata,
|
||||
};
|
||||
if (profileRole !== "super_admin") {
|
||||
delete payload.email;
|
||||
} else {
|
||||
payload.email = data.email.trim();
|
||||
}
|
||||
payload.role = undefined;
|
||||
|
||||
if (userCategory === "personal") {
|
||||
@@ -779,23 +908,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;
|
||||
}
|
||||
@@ -811,12 +940,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);
|
||||
@@ -872,6 +999,21 @@ function UserDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Access Control: Only super_admin or self can view details
|
||||
if (!isAdmin && !isSelf) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<ShieldAlert size={48} className="text-destructive" />
|
||||
<h3 className="text-lg font-bold">
|
||||
{t("msg.admin.common.forbidden", "이 작업을 수행할 권한이 없습니다.")}
|
||||
</h3>
|
||||
<Button onClick={() => navigate("/")}>
|
||||
{t("ui.common.go_home", "홈으로 이동")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back button and actions */}
|
||||
@@ -928,6 +1070,14 @@ function UserDetailPage() {
|
||||
<Mail size={14} className="text-primary/70" />
|
||||
{user.email}
|
||||
</div>
|
||||
{normalizeSubEmails(user.metadata?.sub_email).length > 0 && (
|
||||
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
||||
<Mail size={14} className="text-primary/40" />
|
||||
<span className="text-[10px] font-bold">
|
||||
+{normalizeSubEmails(user.metadata?.sub_email).length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{user.phone && (
|
||||
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
||||
<Shield size={14} className="text-primary/70" />
|
||||
@@ -1007,9 +1157,19 @@ function UserDetailPage() {
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
value={user.email}
|
||||
disabled
|
||||
className="bg-muted/50 border-none font-medium h-11"
|
||||
type="email"
|
||||
disabled={profileRole !== "super_admin"}
|
||||
{...register("email", {
|
||||
required: t(
|
||||
"msg.admin.users.detail.email_required",
|
||||
"이메일을 입력하세요.",
|
||||
),
|
||||
})}
|
||||
className={
|
||||
profileRole === "super_admin"
|
||||
? "h-11 shadow-sm"
|
||||
: "bg-muted/50 border-none font-medium h-11"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -1046,6 +1206,111 @@ function UserDetailPage() {
|
||||
className="h-11 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="metadata_employee_id"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
사번
|
||||
</Label>
|
||||
<Input
|
||||
id="metadata_employee_id"
|
||||
maxLength={20}
|
||||
{...register("metadata.employee_id", {
|
||||
setValueAs: (value) =>
|
||||
typeof value === "string" ? value.trim() : value,
|
||||
maxLength: {
|
||||
value: 20,
|
||||
message:
|
||||
"Worksmobile 사번은 20자 이하로 입력해야 합니다.",
|
||||
},
|
||||
})}
|
||||
className="h-11 shadow-sm"
|
||||
/>
|
||||
{errors.metadata?.employee_id && (
|
||||
<p className="text-xs text-destructive">
|
||||
{String(errors.metadata.employee_id.message)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Worksmobile employeeNumber로 전송됩니다. 1~20자만
|
||||
허용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
||||
<div className="space-y-2 col-span-full">
|
||||
<Label
|
||||
htmlFor="sub_email_input"
|
||||
className="text-xs font-bold uppercase text-muted-foreground flex items-center gap-2"
|
||||
>
|
||||
<Mail size={14} />
|
||||
{t("ui.admin.users.detail.form.sub_email", "보조 이메일")}
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2 mb-1">
|
||||
{currentSubEmails.map((email) => (
|
||||
<Badge
|
||||
key={email}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{email}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveSubEmail(email)}
|
||||
className="text-muted-foreground hover:text-foreground ml-1 rounded-full p-0.5 hover:bg-muted transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="sub_email_input"
|
||||
value={newSubEmail}
|
||||
onChange={(e) => setNewSubEmail(e.target.value)}
|
||||
onKeyDown={handleAddSubEmail}
|
||||
className="h-11 shadow-sm pr-20"
|
||||
placeholder={t(
|
||||
"ui.admin.users.detail.form.sub_email_placeholder",
|
||||
"추가할 이메일 입력 후 Enter",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1 h-9 text-xs font-bold"
|
||||
data-testid="add-sub-email-btn"
|
||||
onClick={() => {
|
||||
const value = newSubEmail.trim().replace(/,/g, "");
|
||||
if (
|
||||
value?.includes("@") &&
|
||||
!currentSubEmails.includes(value)
|
||||
) {
|
||||
setValue(
|
||||
"metadata.sub_email",
|
||||
[...currentSubEmails, value],
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
setNewSubEmail("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
{t(
|
||||
"msg.admin.users.detail.sub_email_help",
|
||||
"* 보조 이메일로도 로그인이 가능하며 계정 찾기 등에 활용될 수 있습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
||||
@@ -1123,7 +1388,7 @@ function UserDetailPage() {
|
||||
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
|
||||
{...register("tenantSlug")}
|
||||
disabled={
|
||||
profile?.role === "tenant_admin" &&
|
||||
profileRole !== "super_admin" &&
|
||||
selectableRepresentativeTenants.length <= 1
|
||||
}
|
||||
>
|
||||
@@ -1173,6 +1438,7 @@ function UserDetailPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addAppointment}
|
||||
data-testid="add-appointment-btn"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("ui.common.add", "추가")}
|
||||
@@ -1195,49 +1461,78 @@ function UserDetailPage() {
|
||||
"소속 테넌트",
|
||||
)}
|
||||
</Label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setPickerTarget({
|
||||
kind: "appointment",
|
||||
index,
|
||||
})
|
||||
}
|
||||
disabled={isResolvingTenant}
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
{appointment.tenantName ||
|
||||
t(
|
||||
"ui.admin.users.detail.form.pick_tenant",
|
||||
"테넌트 선택",
|
||||
)}
|
||||
</Button>
|
||||
{appointment.tenantSlug && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{appointment.tenantSlug}
|
||||
</span>
|
||||
)}
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Switch
|
||||
checked={appointment.isOwner}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isOwner: checked === true,
|
||||
<div
|
||||
className="flex items-center justify-between gap-3"
|
||||
data-testid={`detail-appointment-tenant-owner-controls-${index}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-w-0 max-w-full"
|
||||
onClick={() =>
|
||||
setPickerTarget({
|
||||
kind: "appointment",
|
||||
index,
|
||||
})
|
||||
}
|
||||
disabled={appointment.isPrimary}
|
||||
aria-label={t(
|
||||
disabled={isResolvingTenant}
|
||||
data-testid={`detail-appointment-tenant-picker-${index}`}
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{appointment.tenantName ||
|
||||
t(
|
||||
"ui.admin.users.detail.form.pick_tenant",
|
||||
"테넌트 선택",
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
{appointment.tenantSlug && (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{appointment.tenantSlug}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={appointment.isPrimary === true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isPrimary: checked === true,
|
||||
})
|
||||
}
|
||||
disabled={appointment.isPrimary === true}
|
||||
aria-label={t(
|
||||
"ui.admin.users.detail.form.appointment_owner",
|
||||
"대표 조직",
|
||||
)}
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.appointment_owner",
|
||||
"대표 조직",
|
||||
)}
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.appointment_owner",
|
||||
"대표 조직",
|
||||
)}
|
||||
</label>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={appointment.isManager === true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isManager: checked === true,
|
||||
})
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.users.detail.form.appointment_manager",
|
||||
"조직장",
|
||||
)}
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.appointment_manager",
|
||||
"조직장",
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1254,15 +1549,26 @@ function UserDetailPage() {
|
||||
"직급",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
<select
|
||||
id={`detail-appointment-grade-${index}`}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={appointment.grade ?? ""}
|
||||
onChange={(event) =>
|
||||
updateAppointment(index, {
|
||||
grade: event.target.value,
|
||||
grade: event.target.value || "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
>
|
||||
<option value="">없음</option>
|
||||
{getTenantGradeOptions(
|
||||
appointment,
|
||||
tenants,
|
||||
).map((grade) => (
|
||||
<option key={grade} value={grade}>
|
||||
{grade}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
|
||||
192
adminfront/src/features/users/UserListPage.render.test.tsx
Normal file
192
adminfront/src/features/users/UserListPage.render.test.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import UserListPage from "./UserListPage";
|
||||
|
||||
const selectRenderCounter = vi.hoisted(() => ({ count: 0 }));
|
||||
|
||||
const users = Array.from({ length: 200 }, (_, index) => ({
|
||||
id: `user-${index}`,
|
||||
name: `User ${index}`,
|
||||
email: `user${index}@example.com`,
|
||||
phone: `010-${String(index).padStart(4, "0")}-0000`,
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "hanmac",
|
||||
tenant: { id: "tenant-1", name: "한맥", slug: "hanmac" },
|
||||
metadata: {},
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
}));
|
||||
|
||||
const fetchUsersMock = vi.hoisted(() => vi.fn());
|
||||
const searchRenderBudgetMs =
|
||||
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 200;
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-user",
|
||||
role: "super_admin",
|
||||
name: "Admin",
|
||||
email: "admin@example.com",
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
|
||||
total: 1,
|
||||
})),
|
||||
fetchTenant: vi.fn(async () => ({
|
||||
id: "tenant-1",
|
||||
name: "한맥",
|
||||
slug: "hanmac",
|
||||
config: { userSchema: [] },
|
||||
})),
|
||||
fetchUsers: fetchUsersMock,
|
||||
bulkCreateUsers: vi.fn(),
|
||||
bulkDeleteUsers: vi.fn(),
|
||||
bulkUpdateUsers: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
exportUsersCSV: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ui/select", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectTrigger: ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
selectRenderCounter.count += 1;
|
||||
return (
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
SelectValue: () => <span />,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value: _value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
function renderUserListPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<UserListPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve: (value: T) => void = () => {};
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("UserListPage search rendering", () => {
|
||||
beforeEach(() => {
|
||||
selectRenderCounter.count = 0;
|
||||
fetchUsersMock.mockReset();
|
||||
fetchUsersMock.mockImplementation(
|
||||
async (_limit: number, _offset: number, search?: string) => {
|
||||
const normalizedSearch = search?.trim().toLowerCase();
|
||||
const items = normalizedSearch
|
||||
? users.filter((user) =>
|
||||
`${user.name} ${user.email}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedSearch),
|
||||
)
|
||||
: users;
|
||||
return { items, total: items.length };
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rerender user table controls while typing a draft search", async () => {
|
||||
renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
|
||||
const renderCountBeforeTyping = selectRenderCounter.count;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "u" } });
|
||||
|
||||
expect(searchInput).toHaveValue("u");
|
||||
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
|
||||
});
|
||||
|
||||
it("keeps rendered row controls below the full 200-user result set", async () => {
|
||||
renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
|
||||
expect(screen.getAllByTestId(/^user-status-select-/).length).toBeLessThan(
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders compact vertically centered user table headers", async () => {
|
||||
renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
const nameHeader = screen.getByRole("columnheader", { name: /이름/ });
|
||||
const content = nameHeader.firstElementChild;
|
||||
|
||||
expect(nameHeader).toHaveClass("h-9", "py-1", "align-middle", "text-xs");
|
||||
expect(content).toHaveClass("flex", "h-full", "items-center");
|
||||
});
|
||||
|
||||
it("centers the initial loading message across the user table", async () => {
|
||||
const deferred = createDeferred<{ items: typeof users; total: number }>();
|
||||
fetchUsersMock.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
renderUserListPage();
|
||||
|
||||
const loadingCell = await screen.findByTestId("user-table-loading-cell");
|
||||
expect(loadingCell).toHaveClass(
|
||||
"flex",
|
||||
"items-center",
|
||||
"justify-center",
|
||||
"text-center",
|
||||
);
|
||||
expect(loadingCell).toHaveStyle({ gridColumn: "1 / -1" });
|
||||
|
||||
deferred.resolve({ items: users, total: users.length });
|
||||
});
|
||||
|
||||
it("renders a 200-user search result update within 200ms after search submit", async () => {
|
||||
renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
|
||||
const startedAt = performance.now();
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "user 19" } });
|
||||
fireEvent.keyDown(searchInput, { key: "Enter" });
|
||||
|
||||
expect(await screen.findByText("User 19")).toBeInTheDocument();
|
||||
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
|
||||
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
observeElementRect,
|
||||
type Rect,
|
||||
useVirtualizer,
|
||||
type Virtualizer,
|
||||
} from "@tanstack/react-virtual";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Users,
|
||||
Download,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
LayoutDashboard,
|
||||
@@ -19,13 +21,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 +83,6 @@ import {
|
||||
} from "../../components/ui/table";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
type UserSummary,
|
||||
bulkDeleteUsers,
|
||||
bulkUpdateUsers,
|
||||
deleteUser,
|
||||
@@ -90,13 +91,15 @@ import {
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchUsers,
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
UserBulkUploadModal,
|
||||
downloadUserTemplate,
|
||||
UserBulkUploadModal,
|
||||
} from "./components/UserBulkUploadModal";
|
||||
import {
|
||||
normalizeUserStatusValue,
|
||||
@@ -113,6 +116,23 @@ type UserSchemaField = {
|
||||
|
||||
type UserSortKey = string;
|
||||
|
||||
const USER_ROW_ESTIMATED_HEIGHT = 64;
|
||||
const USER_ROW_OVERSCAN = 20;
|
||||
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
|
||||
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
|
||||
const userMetadataColumnWidth = 160;
|
||||
const userCreatedColumnWidth = 150;
|
||||
type UserRowVirtualizer = Virtualizer<HTMLDivElement, HTMLTableRowElement>;
|
||||
const userTableHeadClassName =
|
||||
"h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap";
|
||||
const userTableHeadInteractiveClassName = `${userTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`;
|
||||
const userTableHeadContentClassName = "flex h-full items-center gap-1";
|
||||
const userSortableTableHeadClassName =
|
||||
"!h-9 !px-3 !py-1 leading-tight whitespace-nowrap";
|
||||
const userSortableTableHeadContentClassName = "h-full items-center";
|
||||
const userTableStateCellClassName =
|
||||
"flex h-24 items-center justify-center p-0 text-center text-sm text-muted-foreground";
|
||||
|
||||
const bulkPermissionOptions = [
|
||||
{
|
||||
value: "super_admin",
|
||||
@@ -130,11 +150,105 @@ function assignableSystemRoleValue(role?: string | null) {
|
||||
return isSuperAdminRole(role) ? "super_admin" : "user";
|
||||
}
|
||||
|
||||
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 = {
|
||||
initialSearch: string;
|
||||
selectedCompany: string;
|
||||
tenants: TenantSummary[];
|
||||
profileRole?: string | null;
|
||||
onSearch: (value: string) => void;
|
||||
onCompanyChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
initialSearch,
|
||||
selectedCompany,
|
||||
tenants,
|
||||
profileRole,
|
||||
onSearch,
|
||||
onCompanyChange,
|
||||
}: UserListSearchControlsProps) {
|
||||
const [localSearch, setLocalSearch] = React.useState(initialSearch);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalSearch(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (localSearch !== initialSearch) {
|
||||
onSearch(localSearch);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [localSearch, onSearch, initialSearch]);
|
||||
|
||||
const tenantOptions = React.useMemo(
|
||||
() =>
|
||||
tenants.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.slug}>
|
||||
{tenant.name}
|
||||
</option>
|
||||
)),
|
||||
[tenants],
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={localSearch}
|
||||
onChange={(event) => setLocalSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
onSearch(localSearch);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(event) => onCompanyChange(event.target.value)}
|
||||
disabled={profileRole !== "super_admin"}
|
||||
>
|
||||
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
|
||||
{tenantOptions}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onSearch(localSearch)}
|
||||
className="h-9"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function UserListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = React.useState(1);
|
||||
const _navigate = useNavigate();
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [searchDraft, setSearchDraft] = React.useState("");
|
||||
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
||||
const [visibleColumns, setVisibleColumns] = React.useState<
|
||||
Record<string, boolean>
|
||||
@@ -148,14 +262,13 @@ function UserListPage() {
|
||||
const [sortConfig, setSortConfig] =
|
||||
React.useState<SortConfig<UserSortKey> | null>(null);
|
||||
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
|
||||
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
const userTableViewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", "all"],
|
||||
@@ -163,12 +276,12 @@ function UserListPage() {
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
// Lock company for tenant_admin
|
||||
// Lock company for non-super_admin
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
|
||||
if (profileRole !== "super_admin" && profile?.tenantSlug) {
|
||||
setSelectedCompany(profile.tenantSlug);
|
||||
}
|
||||
}, [profile]);
|
||||
}, [profile, profileRole]);
|
||||
|
||||
const selectedTenantId = React.useMemo(() => {
|
||||
return tenants.find((t) => t.slug === selectedCompany)?.id ?? "";
|
||||
@@ -208,10 +321,12 @@ function UserListPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
|
||||
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
||||
placeholderData: (previousData) => previousData,
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["users", { search, tenantSlug: selectedCompany }],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchUsers(50, 0, search, selectedCompany, pageParam as string),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -254,16 +369,13 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearch(searchDraft);
|
||||
setPage(1);
|
||||
};
|
||||
const handleSearch = React.useCallback((nextSearch: string) => {
|
||||
setSearch(nextSearch);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
const handleCompanyChange = React.useCallback((nextCompany: string) => {
|
||||
setSelectedCompany(nextCompany);
|
||||
}, []);
|
||||
|
||||
const handleExport = (includeIds = false) => {
|
||||
exportMutation.mutate(includeIds);
|
||||
@@ -279,7 +391,11 @@ function UserListPage() {
|
||||
)
|
||||
: null;
|
||||
|
||||
const rawItems = query.data?.items ?? [];
|
||||
const serverItems = React.useMemo(
|
||||
() => query.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[query.data],
|
||||
);
|
||||
const rawItems = serverItems;
|
||||
const userSortResolvers = React.useMemo<
|
||||
SortResolverMap<UserSummary, UserSortKey>
|
||||
>(
|
||||
@@ -306,8 +422,74 @@ 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 lastItem = virtualRows[virtualRows.length - 1];
|
||||
React.useEffect(() => {
|
||||
if (!lastItem) return;
|
||||
if (
|
||||
lastItem.index >= serverItems.length - 1 &&
|
||||
query.hasNextPage &&
|
||||
!query.isFetchingNextPage
|
||||
) {
|
||||
query.fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
lastItem,
|
||||
serverItems.length,
|
||||
query.hasNextPage,
|
||||
query.isFetchingNextPage,
|
||||
query.fetchNextPage,
|
||||
]);
|
||||
|
||||
const shouldVirtualizeRows = !query.isLoading && items.length > 0;
|
||||
const tableColumnCount = 9 + visibleUserSchemaFields.length;
|
||||
|
||||
const requestSort = (key: UserSortKey) => {
|
||||
setSortConfig((current) => toggleSort(current, key));
|
||||
@@ -324,8 +506,7 @@ function UserListPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const total = query.data?.pages[0]?.total ?? 0;
|
||||
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
@@ -373,7 +554,7 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleApplyBulkStatus = () => {
|
||||
const _handleApplyBulkStatus = () => {
|
||||
if (selectedUserIds.length === 0 || !selectedBulkStatus) return;
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: selectedUserIds,
|
||||
@@ -381,7 +562,7 @@ function UserListPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplyBulkPermission = () => {
|
||||
const _handleApplyBulkPermission = () => {
|
||||
if (selectedUserIds.length === 0 || !selectedBulkPermission) return;
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: selectedUserIds,
|
||||
@@ -404,7 +585,7 @@ function UserListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (userId: string, userName: string) => {
|
||||
const _handleDelete = (userId: string, userName: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
@@ -436,52 +617,13 @@ function UserListPage() {
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(e) => {
|
||||
setSelectedCompany(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">
|
||||
{t("ui.common.all", "전체 테넌트")}
|
||||
</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
className="h-9"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
<UserListSearchControls
|
||||
initialSearch={search}
|
||||
selectedCompany={selectedCompany}
|
||||
tenants={tenants}
|
||||
profileRole={profileRole}
|
||||
onSearch={handleSearch}
|
||||
onCompanyChange={handleCompanyChange}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -643,82 +785,92 @@ function UserListPage() {
|
||||
)}
|
||||
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table>
|
||||
<div
|
||||
ref={userTableViewportRef}
|
||||
data-testid="user-table-viewport"
|
||||
className={commonTableViewportClass}
|
||||
>
|
||||
<Table style={{ display: "grid", minWidth: userTableMinWidth }}>
|
||||
<TableHeader className={sortableTableHeaderClassName}>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className={`${sortableTableHeadBaseClassName} w-12`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={
|
||||
items.length > 0 &&
|
||||
selectedUserIds.length === items.length
|
||||
}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
<TableRow
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: userTableGridTemplateColumns,
|
||||
minWidth: userTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableHead className={`${userTableHeadClassName} w-12`}>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={
|
||||
items.length > 0 &&
|
||||
selectedUserIds.length === items.length
|
||||
}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[120px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={`${userTableHeadInteractiveClassName} min-w-[120px]`}
|
||||
onClick={() => requestSort("name")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.name", "이름")}
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[180px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={`${userTableHeadInteractiveClassName} min-w-[180px]`}
|
||||
onClick={() => requestSort("email")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.email", "이메일")}
|
||||
{getSortIcon("email")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[140px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={`${userTableHeadInteractiveClassName} min-w-[140px]`}
|
||||
onClick={() => requestSort("phone")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.phone", "전화번호")}
|
||||
{getSortIcon("phone")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[220px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={`${userTableHeadInteractiveClassName} min-w-[220px]`}
|
||||
onClick={() => requestSort("id")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.id", "ID")}
|
||||
{getSortIcon("id")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={userTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={userTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("role")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||
{getSortIcon("role")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className={userTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("tenant_dept")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t(
|
||||
"ui.admin.users.list.table.tenant_dept",
|
||||
"TENANT / DEPT",
|
||||
@@ -727,21 +879,20 @@ function UserListPage() {
|
||||
</div>
|
||||
</TableHead>
|
||||
{/* Dynamic Columns from Schema */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<SortableTableHead
|
||||
key={field.key}
|
||||
className="whitespace-nowrap"
|
||||
label={field.label}
|
||||
onSort={requestSort}
|
||||
sortConfig={sortConfig}
|
||||
sortKey={field.key}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{visibleUserSchemaFields.map((field) => (
|
||||
<SortableTableHead
|
||||
key={field.key}
|
||||
className={userSortableTableHeadClassName}
|
||||
contentClassName={userSortableTableHeadContentClassName}
|
||||
label={field.label}
|
||||
onSort={requestSort}
|
||||
sortConfig={sortConfig}
|
||||
sortKey={field.key}
|
||||
/>
|
||||
))}
|
||||
<SortableTableHead
|
||||
className="whitespace-nowrap"
|
||||
className={userSortableTableHeadClassName}
|
||||
contentClassName={userSortableTableHeadContentClassName}
|
||||
label={t("ui.admin.users.list.table.created", "CREATED")}
|
||||
onSort={requestSort}
|
||||
sortConfig={sortConfig}
|
||||
@@ -749,22 +900,51 @@ function UserListPage() {
|
||||
/>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableBody
|
||||
style={
|
||||
shouldVirtualizeRows
|
||||
? {
|
||||
display: "grid",
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
minWidth: userTableMinWidth,
|
||||
position: "relative",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableRow
|
||||
data-testid="user-table-loading-row"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: userTableGridTemplateColumns,
|
||||
minWidth: userTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
colSpan={tableColumnCount}
|
||||
data-testid="user-table-loading-cell"
|
||||
className={userTableStateCellClassName}
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableRow
|
||||
data-testid="user-table-empty-row"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: userTableGridTemplateColumns,
|
||||
minWidth: userTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
colSpan={tableColumnCount}
|
||||
data-testid="user-table-empty-cell"
|
||||
className={userTableStateCellClassName}
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
>
|
||||
{t(
|
||||
"msg.admin.users.list.empty",
|
||||
@@ -773,145 +953,162 @@ function UserListPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={
|
||||
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => toggleSelectUser(user.id)}
|
||||
disabled={user.id === profile?.id}
|
||||
title={
|
||||
user.id === profile?.id
|
||||
? t(
|
||||
"msg.admin.users.self_delete_blocked",
|
||||
"본인 계정은 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
{shouldVirtualizeRows &&
|
||||
virtualRows.map((virtualRow) => {
|
||||
const user = items[virtualRow.index];
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
className={
|
||||
selectedUserIds.includes(user.id)
|
||||
? "bg-primary/5"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
|
||||
title={user.name}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: userTableGridTemplateColumns,
|
||||
height: `${virtualRow.size}px`,
|
||||
minWidth: userTableMinWidth,
|
||||
position: "absolute",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-sm text-muted-foreground truncate max-w-[200px]"
|
||||
title={user.email}
|
||||
>
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{user.phone || "-"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[220px] break-all font-mono text-xs text-muted-foreground"
|
||||
data-testid={`user-internal-id-${user.id}`}
|
||||
>
|
||||
{user.id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={normalizeUserStatusValue(user.status)}
|
||||
onValueChange={(status) =>
|
||||
statusMutation.mutate({
|
||||
userId: user.id,
|
||||
status,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending || user.id === profile?.id
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-[150px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium"
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.change_status",
|
||||
"{{name}} 상태 변경",
|
||||
{ name: user.name },
|
||||
)}
|
||||
data-testid={`user-status-select-${user.id}`}
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => toggleSelectUser(user.id)}
|
||||
disabled={user.id === profile?.id}
|
||||
title={
|
||||
user.id === profile?.id
|
||||
? t(
|
||||
"msg.admin.users.self_delete_blocked",
|
||||
"본인 계정은 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
|
||||
title={user.name}
|
||||
>
|
||||
{user.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-sm text-muted-foreground truncate max-w-[200px]"
|
||||
title={user.email}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={assignableSystemRoleValue(user.role)}
|
||||
onValueChange={(value) =>
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: [user.id],
|
||||
role: value,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
bulkUpdateMutation.isPending ||
|
||||
!isSuperAdminRole(profile?.role) ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bulkPermissionOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{user.phone || "-"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[220px] break-all font-mono text-xs text-muted-foreground"
|
||||
data-testid={`user-internal-id-${user.id}`}
|
||||
>
|
||||
{user.id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={normalizeUserStatusValue(user.status)}
|
||||
onValueChange={(status) =>
|
||||
statusMutation.mutate({
|
||||
userId: user.id,
|
||||
status,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-[150px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium"
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.change_status",
|
||||
"{{name}} 상태 변경",
|
||||
{ name: user.name },
|
||||
)}
|
||||
data-testid={`user-status-select-${user.id}`}
|
||||
>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{user.tenant?.name ||
|
||||
user.tenantSlug ||
|
||||
t("ui.common.unassigned", "미배정")}
|
||||
</span>
|
||||
{user.department && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={assignableSystemRoleValue(user.role)}
|
||||
onValueChange={(value) =>
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: [user.id],
|
||||
role: value,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
bulkUpdateMutation.isPending ||
|
||||
!isSuperAdminRole(profile?.role) ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bulkPermissionOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{user.tenant?.name ||
|
||||
user.tenantSlug ||
|
||||
t("ui.common.unassigned", "미배정")}
|
||||
</span>
|
||||
{user.department && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
{visibleUserSchemaFields.map((field) => (
|
||||
<TableCell key={field.key} className="text-sm">
|
||||
{String(user.metadata?.[field.key] ?? "-")}
|
||||
</TableCell>
|
||||
),
|
||||
)}
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
))}
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -1035,36 +1232,6 @@ function UserListPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1 || query.isFetching}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
{t("ui.common.previous", "Previous")}
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
|
||||
page,
|
||||
total: totalPages,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages || query.isFetching}
|
||||
>
|
||||
{t("ui.common.next", "Next")}
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -16,12 +16,10 @@ import { Input } from "../../../components/ui/input";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type GroupSummary,
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
bulkUpdateUsers,
|
||||
fetchAllTenants,
|
||||
fetchGroups,
|
||||
type UserSummary,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
@@ -49,7 +47,7 @@ export function UserBulkMoveGroupModal({
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const _queryClient = useQueryClient();
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", "all"],
|
||||
|
||||
@@ -30,15 +30,16 @@ import {
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
buildTenantImportPreview,
|
||||
type TenantCSVRow,
|
||||
type TenantImportPreviewRow,
|
||||
buildTenantImportPreview,
|
||||
} from "../../tenants/utils/tenantCsvImport";
|
||||
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
|
||||
import { parseUserCSV } from "../utils/csvParser";
|
||||
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
|
||||
import {
|
||||
type HanmacImportEmailPreview,
|
||||
buildHanmacImportEmailPreview,
|
||||
type HanmacImportEmailPreview,
|
||||
} from "../utils/hanmacImportEmail";
|
||||
|
||||
interface UserBulkUploadModalProps {
|
||||
@@ -72,6 +73,7 @@ function buildUserTenantPreviewRows(
|
||||
emailDomain: user.tenantImport?.emailDomain ?? "",
|
||||
visibility: "public",
|
||||
orgUnitType: "node",
|
||||
worksmobileSync: "yes",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,6 +116,13 @@ function hanmacEmailStatusLabel(preview?: HanmacImportEmailPreview) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function userImportErrorLabel(user: BulkUserItem) {
|
||||
if (!user.importErrors?.includes("duplicateEmail")) {
|
||||
return "";
|
||||
}
|
||||
return "중복 이메일";
|
||||
}
|
||||
|
||||
function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
||||
if (!preview) return "text-muted-foreground";
|
||||
if (preview.status === "blockingError") return "text-destructive";
|
||||
@@ -126,9 +135,9 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
||||
|
||||
export const downloadUserTemplate = () => {
|
||||
const headers =
|
||||
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
||||
"email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
||||
const example =
|
||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
||||
"user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
||||
const blob = new Blob([`${headers}\n${example}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
@@ -278,10 +287,12 @@ export function UserBulkUploadModal({
|
||||
}
|
||||
|
||||
return previewData.map((user, index) => {
|
||||
const key = tenantImportKeyFromUser(user);
|
||||
const finalUser = applyGeneralPlanningOfficePriority(user, tenants);
|
||||
|
||||
const key = tenantImportKeyFromUser(finalUser);
|
||||
const resolvedTenant = key ? tenantByKey.get(key) : undefined;
|
||||
const emailPreview = hanmacEmailPreviews[index];
|
||||
const { tenantImport: _tenantImport, ...payload } = user;
|
||||
const { tenantImport: _tenantImport, ...payload } = finalUser;
|
||||
return {
|
||||
...payload,
|
||||
email: emailPreview?.finalEmail ?? payload.email,
|
||||
@@ -292,22 +303,6 @@ export function UserBulkUploadModal({
|
||||
});
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers =
|
||||
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
||||
const example =
|
||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
||||
const blob = new Blob([`${headers}\n${example}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "user_bulk_template.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setPreviewData([]);
|
||||
@@ -352,6 +347,9 @@ export function UserBulkUploadModal({
|
||||
const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some(
|
||||
(preview) => preview?.status === "blockingError",
|
||||
);
|
||||
const hasBlockingImportRows = previewData.some(
|
||||
(user) => (user.importErrors?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
const triggerProps = {
|
||||
disabled: mutation.isPending,
|
||||
@@ -407,7 +405,7 @@ export function UserBulkUploadModal({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
onClick={downloadUserTemplate}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
@@ -548,7 +546,10 @@ export function UserBulkUploadModal({
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewData.slice(0, 10).map((u, index) => (
|
||||
<tr key={`${u.email}-${index}`} className="border-t">
|
||||
<tr
|
||||
key={`${u.email}-${u.tenantSlug ?? ""}-${u.name}`}
|
||||
className="border-t"
|
||||
>
|
||||
<td className="p-2">
|
||||
<input
|
||||
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
|
||||
@@ -570,11 +571,22 @@ export function UserBulkUploadModal({
|
||||
<td className="p-2">{u.name}</td>
|
||||
<td className="p-2">{u.tenantSlug || "-"}</td>
|
||||
<td
|
||||
className={`p-2 text-xs ${hanmacEmailStatusClass(
|
||||
hanmacEmailPreviews[index],
|
||||
)}`}
|
||||
className={`p-2 text-xs ${
|
||||
u.importErrors?.length
|
||||
? "text-destructive"
|
||||
: hanmacEmailStatusClass(
|
||||
hanmacEmailPreviews[index],
|
||||
)
|
||||
}`}
|
||||
>
|
||||
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
|
||||
{u.importErrors?.length
|
||||
? "오류"
|
||||
: hanmacEmailStatusLabel(
|
||||
hanmacEmailPreviews[index],
|
||||
)}
|
||||
{u.importErrors?.length ? (
|
||||
<div>{userImportErrorLabel(u)}</div>
|
||||
) : null}
|
||||
{hanmacEmailPreviews[index]?.reason && (
|
||||
<div>{hanmacEmailPreviews[index]?.reason}</div>
|
||||
)}
|
||||
@@ -599,15 +611,71 @@ export function UserBulkUploadModal({
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{successCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.success", "성공")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
{results.some((r) => r.success && r.status === "created") && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{
|
||||
results.filter(
|
||||
(r) => r.success && r.status === "created",
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.status.new", "신규")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{results.some((r) => r.success && r.status === "updated") && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{
|
||||
results.filter(
|
||||
(r) => r.success && r.status === "updated",
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.status.updated", "수정")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{results.some((r) => r.success && r.status === "unchanged") && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-slate-500">
|
||||
{
|
||||
results.filter(
|
||||
(r) => r.success && r.status === "unchanged",
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.status.unchanged", "동일")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{!results.some((r) => r.success && r.status) &&
|
||||
successCount > 0 && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{successCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.success", "성공")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-destructive">
|
||||
{failCount}
|
||||
@@ -637,7 +705,60 @@ export function UserBulkUploadModal({
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{r.email}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium truncate">{r.email}</div>
|
||||
{r.success && r.status === "created" && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
|
||||
{t("ui.common.status.new", "신규")}
|
||||
</span>
|
||||
)}
|
||||
{r.success && r.status === "updated" && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 text-[10px] font-bold">
|
||||
{t("ui.common.status.updated", "수정")}
|
||||
</span>
|
||||
)}
|
||||
{r.success && r.status === "unchanged" && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-slate-100 text-slate-600 text-[10px] font-bold">
|
||||
{t("ui.common.status.unchanged", "동일")}
|
||||
</span>
|
||||
)}
|
||||
{r.success && !r.status && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
|
||||
{t("ui.common.success", "성공")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.success && r.status === "updated" && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground flex flex-wrap gap-1 items-center">
|
||||
<span className="font-medium">
|
||||
{t(
|
||||
"ui.admin.users.bulk.modified_fields",
|
||||
"수정 항목:",
|
||||
)}
|
||||
</span>
|
||||
{r.modifiedFields &&
|
||||
r.modifiedFields.length > 0 &&
|
||||
r.modifiedFields.map((field) => (
|
||||
<span
|
||||
key={field}
|
||||
className="px-1 py-0.5 rounded bg-blue-50 text-blue-600 border border-blue-100"
|
||||
>
|
||||
{t(
|
||||
`ui.admin.users.field.${field.toLowerCase()}`,
|
||||
field,
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{r.success && r.status === "unchanged" && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground italic">
|
||||
{t(
|
||||
"ui.admin.users.bulk.no_changes",
|
||||
"기존 데이터와 동일 (변경 사항 없음)",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!r.success && (
|
||||
<div className="text-xs text-destructive">
|
||||
{r.message}
|
||||
@@ -659,7 +780,8 @@ export function UserBulkUploadModal({
|
||||
previewData.length === 0 ||
|
||||
mutation.isPending ||
|
||||
preparing ||
|
||||
hasBlockingHanmacEmailRows
|
||||
hasBlockingHanmacEmailRows ||
|
||||
hasBlockingImportRows
|
||||
}
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="bulk-start-btn"
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildAuthenticatedOrgChartUrl,
|
||||
buildOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
getTenantGradeOptions,
|
||||
isHanmacFamilyUser,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
@@ -59,9 +60,7 @@ describe("orgChartPicker", () => {
|
||||
buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", {
|
||||
includeInternal: false,
|
||||
}),
|
||||
).toBe(
|
||||
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
|
||||
);
|
||||
).toBe("https://orgchart.example.com/login?auto=1&returnTo=%2Fchart");
|
||||
});
|
||||
|
||||
it("parses the first tenant id and name from orgfront confirm messages", () => {
|
||||
@@ -116,6 +115,22 @@ describe("orgChartPicker", () => {
|
||||
type: "COMPANY",
|
||||
parentId: undefined,
|
||||
},
|
||||
{
|
||||
id: "internal-id",
|
||||
slug: "internal",
|
||||
name: "Internal",
|
||||
type: "COMPANY",
|
||||
parentId: undefined,
|
||||
config: { visibility: "internal" },
|
||||
},
|
||||
{
|
||||
id: "private-id",
|
||||
slug: "private",
|
||||
name: "Private",
|
||||
type: "COMPANY",
|
||||
parentId: undefined,
|
||||
visibility: "private",
|
||||
},
|
||||
{
|
||||
id: "hanmac-family-id",
|
||||
slug: "hanmac-family",
|
||||
@@ -251,4 +266,54 @@ describe("orgChartPicker", () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns GPDTDC rank options for GPDTDC subtree and general Hanmac ranks otherwise", () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: "hanmac-family-id",
|
||||
slug: "hanmac-family",
|
||||
name: "한맥가족",
|
||||
type: "COMPANY_GROUP",
|
||||
parentId: undefined,
|
||||
},
|
||||
{
|
||||
id: "gpdtdc-id",
|
||||
slug: "gpdtdc",
|
||||
name: "총괄기획&기술개발센터",
|
||||
type: "COMPANY",
|
||||
parentId: "hanmac-family-id",
|
||||
},
|
||||
{
|
||||
id: "gpdtdc-team-id",
|
||||
slug: "gpdtdc-team",
|
||||
name: "연구팀",
|
||||
type: "USER_GROUP",
|
||||
parentId: "gpdtdc-id",
|
||||
},
|
||||
{
|
||||
id: "hanmac-id",
|
||||
slug: "hanmac",
|
||||
name: "한맥기술",
|
||||
type: "COMPANY",
|
||||
parentId: "hanmac-family-id",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getTenantGradeOptions({ tenantId: "gpdtdc-team-id" }, tenants),
|
||||
).toEqual(["연구원", "선임", "책임", "수석", "부사장", "사장"]);
|
||||
expect(getTenantGradeOptions({ tenantSlug: "hanmac" }, tenants)).toEqual([
|
||||
"사원",
|
||||
"대리",
|
||||
"과장",
|
||||
"차장",
|
||||
"부장",
|
||||
"이사",
|
||||
"상무",
|
||||
"전무",
|
||||
"부사장",
|
||||
"사장",
|
||||
"회장",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ export type TenantFilterTarget = {
|
||||
parentId?: string | null;
|
||||
name?: string;
|
||||
tenantName?: string;
|
||||
visibility?: string;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type HanmacFamilyUserTarget = {
|
||||
@@ -43,6 +45,29 @@ type OrgChartLoginOptions = {
|
||||
returnTo?: string;
|
||||
};
|
||||
|
||||
export const GPDTDC_GRADE_OPTIONS = [
|
||||
"연구원",
|
||||
"선임",
|
||||
"책임",
|
||||
"수석",
|
||||
"부사장",
|
||||
"사장",
|
||||
] as const;
|
||||
|
||||
export const HANMAC_FAMILY_GRADE_OPTIONS = [
|
||||
"사원",
|
||||
"대리",
|
||||
"과장",
|
||||
"차장",
|
||||
"부장",
|
||||
"이사",
|
||||
"상무",
|
||||
"전무",
|
||||
"부사장",
|
||||
"사장",
|
||||
"회장",
|
||||
] as const;
|
||||
|
||||
function isSystemTenant(tenant: TenantFilterTarget) {
|
||||
const slug = tenant.slug?.trim().toLowerCase();
|
||||
const type = tenant.type?.trim().toUpperCase();
|
||||
@@ -56,6 +81,73 @@ function isSystemTenant(tenant: TenantFilterTarget) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTenantTarget<T extends TenantFilterTarget>(
|
||||
target: TenantFilterTarget | undefined,
|
||||
tenants: T[],
|
||||
) {
|
||||
if (!target) return undefined;
|
||||
const tenantID = target.id ?? target.tenantId ?? "";
|
||||
const tenantSlug = target.slug ?? target.tenantSlug ?? "";
|
||||
return (
|
||||
tenants.find((tenant) => tenantID && tenant.id === tenantID) ??
|
||||
tenants.find(
|
||||
(tenant) =>
|
||||
tenantSlug &&
|
||||
tenant.slug?.trim().toLowerCase() === tenantSlug.trim().toLowerCase(),
|
||||
) ??
|
||||
target
|
||||
);
|
||||
}
|
||||
|
||||
function isGPDTDCTenant<T extends TenantFilterTarget>(
|
||||
target: TenantFilterTarget | undefined,
|
||||
tenants: T[],
|
||||
) {
|
||||
const tenant = resolveTenantTarget(target, tenants);
|
||||
if (!tenant) return false;
|
||||
|
||||
const tenantById = new Map(
|
||||
tenants
|
||||
.filter((item) => item.id?.trim())
|
||||
.map((item) => [item.id as string, item]),
|
||||
);
|
||||
let current: TenantFilterTarget | undefined = tenant;
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (current) {
|
||||
const slug = current.slug?.trim().toLowerCase();
|
||||
if (slug === "gpdtdc") {
|
||||
return true;
|
||||
}
|
||||
const parentId = current.parentId ?? "";
|
||||
if (!parentId || visited.has(parentId)) {
|
||||
return false;
|
||||
}
|
||||
visited.add(parentId);
|
||||
current = tenantById.get(parentId);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getTenantGradeOptions<T extends TenantFilterTarget>(
|
||||
target: TenantFilterTarget | undefined,
|
||||
tenants: T[],
|
||||
) {
|
||||
return isGPDTDCTenant(target, tenants)
|
||||
? [...GPDTDC_GRADE_OPTIONS]
|
||||
: [...HANMAC_FAMILY_GRADE_OPTIONS];
|
||||
}
|
||||
|
||||
function isPublicRepresentativeTenant(tenant: TenantFilterTarget) {
|
||||
const visibility = String(
|
||||
tenant.visibility ?? tenant.config?.visibility ?? "public",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return visibility !== "internal" && visibility !== "private";
|
||||
}
|
||||
|
||||
function isInTenantSubtree<T extends TenantFilterTarget>(
|
||||
tenant: T,
|
||||
rootTenantId: string,
|
||||
@@ -102,7 +194,7 @@ export function isHanmacFamilyTenant<T extends TenantFilterTarget>(
|
||||
tenants: T[],
|
||||
hanmacFamilyTenantId?: string,
|
||||
) {
|
||||
if (!tenant || !tenant.id) return false;
|
||||
if (!tenant?.id) return false;
|
||||
|
||||
const rootTenantId = resolveHanmacFamilyTenantId(
|
||||
tenants,
|
||||
@@ -187,6 +279,7 @@ export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
|
||||
return tenants.filter(
|
||||
(tenant) =>
|
||||
!isSystemTenant(tenant) &&
|
||||
isPublicRepresentativeTenant(tenant) &&
|
||||
!isInTenantSubtree(tenant, rootTenantId, tenantById),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ export const userStatusValues = [
|
||||
|
||||
export type UserStatusValue = (typeof userStatusValues)[number];
|
||||
|
||||
export function normalizeUserStatusValue(status?: string | null): UserStatusValue {
|
||||
export function normalizeUserStatusValue(
|
||||
status?: string | null,
|
||||
): UserStatusValue {
|
||||
switch ((status ?? "").trim().toLowerCase()) {
|
||||
case "active":
|
||||
return "active";
|
||||
|
||||
@@ -97,6 +97,23 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore exported user_id during user CSV import", () => {
|
||||
const csv = `user_id,email,name,tenant_id,tenant_slug
|
||||
9f8cc1b1-af8d-45d4-946c-924a529c2556,restore@test.com,Restore User,tenant-id,restore-tenant`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
email: "restore@test.com",
|
||||
name: "Restore User",
|
||||
tenantId: "tenant-id",
|
||||
tenantSlug: "restore-tenant",
|
||||
});
|
||||
expect(result[0]).not.toHaveProperty("id");
|
||||
expect(result[0]).not.toHaveProperty("uuid");
|
||||
});
|
||||
|
||||
it("should parse one nullable additional appointment from numbered columns", () => {
|
||||
const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
|
||||
dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002
|
||||
@@ -128,4 +145,46 @@ nullable@test.com,Nullable User,010-1111-1111,user,primary-tenant,개발팀,책
|
||||
});
|
||||
expect(result[1].additionalAppointments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should preserve sub_email as secondary email metadata without replacing primary email", () => {
|
||||
const csv = `email,name,tenant_slug,employee_id,sub_email
|
||||
primary@samaneng.com,Primary User,rnd-saman,EMP001,secondary@hanmaceng.co.kr`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
email: "primary@samaneng.com",
|
||||
tenantSlug: "rnd-saman",
|
||||
metadata: {
|
||||
employee_id: "EMP001",
|
||||
sub_email: ["secondary@hanmaceng.co.kr"],
|
||||
aliasEmails: ["secondary@hanmaceng.co.kr"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should mark duplicate bulk alias emails as blocking import errors", () => {
|
||||
const csv = `email,name,tenant_slug,sub_email
|
||||
user1@samaneng.com,User One,rnd-saman,shared@hanmaceng.co.kr
|
||||
user2@samaneng.com,User Two,rnd-saman,shared@hanmaceng.co.kr`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].importErrors).toContain("duplicateEmail");
|
||||
expect(result[1].importErrors).toContain("duplicateEmail");
|
||||
});
|
||||
|
||||
it("should mark a primary email reused as a sub email as a blocking import error", () => {
|
||||
const csv = `email,name,tenant_slug,sub_email
|
||||
user1@samaneng.com,User One,rnd-saman,user2@samaneng.com
|
||||
user2@samaneng.com,User Two,rnd-saman,alias@hanmaceng.co.kr`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].importErrors).toContain("duplicateEmail");
|
||||
expect(result[1].importErrors).toContain("duplicateEmail");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,13 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
for (let i = 1; i < records.length; i++) {
|
||||
const values = records[i].map((v) => v.trim());
|
||||
if (values.every((value) => value === "")) continue;
|
||||
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
|
||||
const item: Partial<BulkUserItem> & {
|
||||
metadata: Record<string, unknown>;
|
||||
} = {
|
||||
metadata: {},
|
||||
};
|
||||
const additionalAppointment: BulkUserAppointment & {
|
||||
metadata: Record<string, string>;
|
||||
metadata: Record<string, unknown>;
|
||||
} = {
|
||||
metadata: {},
|
||||
};
|
||||
@@ -94,6 +96,8 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
item.jobTitle = value;
|
||||
} else if (header === "employee_id") {
|
||||
item.metadata.employee_id = value;
|
||||
} else if (header === "secondary_emails") {
|
||||
applySecondaryEmailMetadata(item, value);
|
||||
} else if (header === "tenant_slug1") {
|
||||
additionalAppointment.tenantSlug = value;
|
||||
} else if (header === "department1") {
|
||||
@@ -117,6 +121,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
item.metadata.personal_email = value;
|
||||
} else if (header === "subemail") {
|
||||
item.metadata.naverworks_sub_email = value;
|
||||
addWorksmobileAliasEmails(item, splitEmailTokens(value).slice(1));
|
||||
item.email = firstEmailToken(value) || item.email;
|
||||
} else if (header === "nickname") {
|
||||
item.metadata.naverworks_nickname = value;
|
||||
@@ -181,11 +186,11 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
return markBulkEmailDuplicateErrors(data);
|
||||
}
|
||||
|
||||
function cleanAdditionalAppointment(
|
||||
appointment: BulkUserAppointment & { metadata: Record<string, string> },
|
||||
appointment: BulkUserAppointment & { metadata: Record<string, unknown> },
|
||||
) {
|
||||
const metadata =
|
||||
Object.keys(appointment.metadata).length > 0
|
||||
@@ -201,6 +206,12 @@ function cleanAdditionalAppointment(
|
||||
...(appointment.isOwner !== undefined
|
||||
? { isOwner: appointment.isOwner }
|
||||
: {}),
|
||||
...(appointment.isAdmin !== undefined
|
||||
? { isAdmin: appointment.isAdmin }
|
||||
: {}),
|
||||
...(appointment.isManager !== undefined
|
||||
? { isManager: appointment.isManager }
|
||||
: {}),
|
||||
...(appointment.department ? { department: appointment.department } : {}),
|
||||
...(appointment.grade ? { grade: appointment.grade } : {}),
|
||||
...(appointment.position ? { position: appointment.position } : {}),
|
||||
@@ -210,7 +221,29 @@ function cleanAdditionalAppointment(
|
||||
}
|
||||
|
||||
function normalizeHeader(header: string) {
|
||||
return header
|
||||
const raw = header.trim().replace(/^\uFEFF/, "");
|
||||
const lower = raw.toLowerCase();
|
||||
const separatorNormalized = lower.replace(/-+/g, "_").replace(/_+/g, "_");
|
||||
const compactKorean = raw.replace(/\s+/g, "");
|
||||
|
||||
if (
|
||||
[
|
||||
"sub_email",
|
||||
"secondary_email",
|
||||
"secondary_emails",
|
||||
"additional_email",
|
||||
"additional_emails",
|
||||
"alias_email",
|
||||
"alias_emails",
|
||||
"worksmobile_alias_email",
|
||||
"worksmobile_alias_emails",
|
||||
].includes(separatorNormalized) ||
|
||||
["보조이메일", "보조메일", "추가이메일", "추가메일"].includes(compactKorean)
|
||||
) {
|
||||
return "secondary_emails";
|
||||
}
|
||||
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\uFEFF/, "")
|
||||
@@ -264,14 +297,132 @@ function parseCSVRecords(text: string) {
|
||||
}
|
||||
|
||||
function firstEmailToken(value: string) {
|
||||
return (
|
||||
value
|
||||
.split(/[;,]/)
|
||||
.map((token) => token.trim())
|
||||
.find((token) => token.includes("@")) ?? ""
|
||||
return splitEmailTokens(value)[0] ?? "";
|
||||
}
|
||||
|
||||
function splitEmailTokens(value: string) {
|
||||
return value
|
||||
.split(/[;,\n\r\t]/)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.includes("@"));
|
||||
}
|
||||
|
||||
function metadataString(value: unknown) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function metadataEmailList(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return splitEmailTokens(value);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function uniqueEmails(values: string[]) {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function bulkUserImportErrorList(user: BulkUserItem) {
|
||||
return Array.isArray(user.importErrors) ? user.importErrors : [];
|
||||
}
|
||||
|
||||
function withBulkUserImportError(user: BulkUserItem, error: string) {
|
||||
const errors = Array.from(new Set([...bulkUserImportErrorList(user), error]));
|
||||
return { ...user, importErrors: errors };
|
||||
}
|
||||
|
||||
function bulkUserAliasEmails(user: BulkUserItem) {
|
||||
return uniqueEmails([
|
||||
...metadataEmailList(user.metadata.sub_email),
|
||||
...metadataEmailList(user.metadata.aliasEmails),
|
||||
...metadataEmailList(user.metadata.secondary_emails),
|
||||
...metadataEmailList(user.metadata.worksmobileAliasEmails),
|
||||
]);
|
||||
}
|
||||
|
||||
function markBulkEmailDuplicateErrors(users: BulkUserItem[]) {
|
||||
const duplicateIndexes = new Set<number>();
|
||||
const owners = new Map<string, Set<number>>();
|
||||
|
||||
users.forEach((user, index) => {
|
||||
const primaryEmail = user.email.trim().toLowerCase();
|
||||
const aliases = bulkUserAliasEmails(user);
|
||||
const rowEmails = new Set<string>();
|
||||
|
||||
if (primaryEmail) {
|
||||
rowEmails.add(primaryEmail);
|
||||
}
|
||||
for (const alias of aliases) {
|
||||
if (primaryEmail && alias === primaryEmail) {
|
||||
duplicateIndexes.add(index);
|
||||
}
|
||||
rowEmails.add(alias);
|
||||
}
|
||||
|
||||
for (const email of rowEmails) {
|
||||
const existing = owners.get(email) ?? new Set<number>();
|
||||
existing.add(index);
|
||||
owners.set(email, existing);
|
||||
}
|
||||
});
|
||||
|
||||
for (const indexes of owners.values()) {
|
||||
if (indexes.size < 2) {
|
||||
continue;
|
||||
}
|
||||
for (const index of indexes) {
|
||||
duplicateIndexes.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
return users.map((user, index) =>
|
||||
duplicateIndexes.has(index)
|
||||
? withBulkUserImportError(user, "duplicateEmail")
|
||||
: user,
|
||||
);
|
||||
}
|
||||
|
||||
function addWorksmobileAliasEmails(
|
||||
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
|
||||
emails: string[],
|
||||
) {
|
||||
const aliases = uniqueEmails([
|
||||
...metadataEmailList(item.metadata.aliasEmails),
|
||||
...emails,
|
||||
]);
|
||||
if (aliases.length > 0) {
|
||||
item.metadata.aliasEmails = aliases;
|
||||
item.metadata.worksmobileAliasEmails = aliases;
|
||||
}
|
||||
}
|
||||
|
||||
function applySecondaryEmailMetadata(
|
||||
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
|
||||
value: string,
|
||||
) {
|
||||
const emails = splitEmailTokens(value);
|
||||
const uniqueSecondaryEmails = uniqueEmails([
|
||||
...metadataEmailList(item.metadata.secondary_emails),
|
||||
...emails,
|
||||
]);
|
||||
item.metadata.sub_email = emails;
|
||||
item.metadata.secondary_emails = uniqueSecondaryEmails;
|
||||
addWorksmobileAliasEmails(item, emails);
|
||||
}
|
||||
|
||||
function splitOrganizationPath(value: string) {
|
||||
return value
|
||||
.split("|")
|
||||
@@ -280,28 +431,32 @@ function splitOrganizationPath(value: string) {
|
||||
}
|
||||
|
||||
function applyNaverWorksFallbacks(
|
||||
item: Partial<BulkUserItem> & { metadata: Record<string, string> },
|
||||
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
|
||||
) {
|
||||
if (!item.name) {
|
||||
const firstName = item.metadata.naverworks_first_name ?? "";
|
||||
const lastName = item.metadata.naverworks_last_name ?? "";
|
||||
const firstName = metadataString(item.metadata.naverworks_first_name);
|
||||
const lastName = metadataString(item.metadata.naverworks_last_name);
|
||||
item.name = [firstName, lastName].filter(Boolean).join(" ").trim();
|
||||
if (!item.name && item.metadata.naverworks_nickname) {
|
||||
item.name = item.metadata.naverworks_nickname;
|
||||
const nickname = metadataString(item.metadata.naverworks_nickname);
|
||||
if (!item.name && nickname) {
|
||||
item.name = nickname;
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.email) {
|
||||
item.email = item.metadata.personal_email;
|
||||
item.email = metadataString(item.metadata.personal_email);
|
||||
}
|
||||
|
||||
if (!item.phone) {
|
||||
const countryCode = item.metadata.naverworks_mobile_country_code ?? "";
|
||||
const number = item.metadata.naverworks_mobile_numbers ?? "";
|
||||
const countryCode = metadataString(
|
||||
item.metadata.naverworks_mobile_country_code,
|
||||
);
|
||||
const number = metadataString(item.metadata.naverworks_mobile_numbers);
|
||||
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
|
||||
}
|
||||
|
||||
if (!item.grade && item.metadata.naverworks_level) {
|
||||
item.grade = item.metadata.naverworks_level;
|
||||
const level = metadataString(item.metadata.naverworks_level);
|
||||
if (!item.grade && level) {
|
||||
item.grade = level;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { BulkUserItem, TenantSummary } from "../../../lib/adminApi";
|
||||
import { applyGeneralPlanningOfficePriority } from "./generalPlanningOfficePriority";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
name: string,
|
||||
slug: string,
|
||||
parentId?: string,
|
||||
): TenantSummary {
|
||||
return {
|
||||
id,
|
||||
type: "COMPANY",
|
||||
name,
|
||||
slug,
|
||||
description: "",
|
||||
status: "active",
|
||||
parentId,
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyGeneralPlanningOfficePriority", () => {
|
||||
it("promotes the general planning office appointment and preserves string employee IDs", () => {
|
||||
const user: BulkUserItem = {
|
||||
email: "dual@test.com",
|
||||
name: "Dual User",
|
||||
tenantSlug: "hanmac-tech",
|
||||
department: "개발팀",
|
||||
grade: "책임",
|
||||
position: "팀장",
|
||||
jobTitle: "Backend",
|
||||
metadata: {
|
||||
employee_id: "EMP001",
|
||||
},
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "planning-team",
|
||||
tenantName: "경영기획팀",
|
||||
department: "센터",
|
||||
grade: "수석",
|
||||
jobTitle: "Architecture",
|
||||
metadata: {
|
||||
employee_id: "EMP002",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = applyGeneralPlanningOfficePriority(user, [
|
||||
tenant("gpo", "총괄기획실", "gpo"),
|
||||
tenant("planning", "경영기획팀", "planning-team", "gpo"),
|
||||
tenant("tech", "한맥기술", "hanmac-tech"),
|
||||
]);
|
||||
|
||||
expect(result.tenantSlug).toBe("planning-team");
|
||||
expect(result.department).toBe("센터");
|
||||
expect(result.grade).toBe("수석");
|
||||
expect(result.jobTitle).toBe("Architecture");
|
||||
expect(result.metadata.employee_id).toBe("EMP002");
|
||||
expect(result.additionalAppointments?.[0]).toMatchObject({
|
||||
tenantSlug: "hanmac-tech",
|
||||
department: "개발팀",
|
||||
grade: "책임",
|
||||
position: "팀장",
|
||||
jobTitle: "Backend",
|
||||
metadata: {
|
||||
employee_id: "EMP001",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not write non-string employee IDs into string metadata", () => {
|
||||
const user: BulkUserItem = {
|
||||
email: "dual@test.com",
|
||||
name: "Dual User",
|
||||
tenantSlug: "hanmac-tech",
|
||||
metadata: {
|
||||
employee_id: "EMP001",
|
||||
},
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "gpo",
|
||||
tenantName: "총괄기획실",
|
||||
metadata: {
|
||||
employee_id: 1002,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = applyGeneralPlanningOfficePriority(user, [
|
||||
tenant("gpo", "총괄기획실", "gpo"),
|
||||
tenant("tech", "한맥기술", "hanmac-tech"),
|
||||
]);
|
||||
|
||||
expect(result.tenantSlug).toBe("gpo");
|
||||
expect(result.metadata.employee_id).toBeUndefined();
|
||||
expect(result.additionalAppointments?.[0].metadata).toMatchObject({
|
||||
employee_id: "EMP001",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses GPDTDC as the Baron representative while keeping the first affiliation primary for WorksMobile", () => {
|
||||
const user: BulkUserItem = {
|
||||
email: "gpdtdc-dual@test.com",
|
||||
name: "GPDTDC Dual User",
|
||||
tenantSlug: "rnd-saman",
|
||||
department: "삼안기술연구소",
|
||||
grade: "책임",
|
||||
metadata: {
|
||||
employee_id: "SAMAN001",
|
||||
},
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "tdc",
|
||||
tenantName: "기술개발센터",
|
||||
grade: "책임연구원",
|
||||
metadata: {
|
||||
employee_id: "B24051",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = applyGeneralPlanningOfficePriority(user, [
|
||||
tenant("family", "한맥가족사", "hanmac-family"),
|
||||
tenant("gpdtdc", "총괄기획&기술개발센터", "gpdtdc", "family"),
|
||||
tenant("tdc", "기술개발센터", "tdc", "gpdtdc"),
|
||||
tenant("saman", "삼안", "rnd-saman"),
|
||||
]);
|
||||
|
||||
expect(result.tenantSlug).toBe("gpdtdc");
|
||||
expect(result.tenantImport).toMatchObject({
|
||||
slug: "gpdtdc",
|
||||
name: "총괄기획&기술개발센터",
|
||||
});
|
||||
expect(result.metadata.employee_id).toBe("SAMAN001");
|
||||
expect(result.additionalAppointments).toEqual([
|
||||
expect.objectContaining({
|
||||
tenantSlug: "rnd-saman",
|
||||
isPrimary: true,
|
||||
department: "삼안기술연구소",
|
||||
grade: "책임",
|
||||
metadata: {
|
||||
employee_id: "SAMAN001",
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tenantSlug: "tdc",
|
||||
isPrimary: false,
|
||||
grade: "책임연구원",
|
||||
metadata: {
|
||||
employee_id: "B24051",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import type {
|
||||
BulkUserAppointment,
|
||||
BulkUserItem,
|
||||
TenantSummary,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export function applyGeneralPlanningOfficePriority(
|
||||
user: BulkUserItem,
|
||||
tenants: TenantSummary[],
|
||||
): BulkUserItem {
|
||||
const gpdtdcRepresentative = applyGPDTDCRepresentativeTenant(user, tenants);
|
||||
if (gpdtdcRepresentative) {
|
||||
return gpdtdcRepresentative;
|
||||
}
|
||||
|
||||
const firstAdditional = user.additionalAppointments?.[0];
|
||||
const secondarySlug = firstAdditional?.tenantSlug;
|
||||
|
||||
if (
|
||||
!firstAdditional ||
|
||||
!secondarySlug ||
|
||||
!isUnderGeneralPlanningOffice(secondarySlug, tenants) ||
|
||||
isUnderGeneralPlanningOffice(user.tenantSlug || "", tenants)
|
||||
) {
|
||||
return user;
|
||||
}
|
||||
|
||||
const primaryEmployeeId = stringValue(user.metadata.employee_id);
|
||||
const secondaryEmployeeId = stringValue(
|
||||
firstAdditional.metadata?.employee_id,
|
||||
);
|
||||
|
||||
const metadata = { ...user.metadata };
|
||||
if (secondaryEmployeeId) {
|
||||
metadata.employee_id = secondaryEmployeeId;
|
||||
} else {
|
||||
delete metadata.employee_id;
|
||||
}
|
||||
|
||||
const primaryAppointmentMetadata: Record<string, unknown> = {
|
||||
...firstAdditional.metadata,
|
||||
};
|
||||
if (primaryEmployeeId) {
|
||||
primaryAppointmentMetadata.employee_id = primaryEmployeeId;
|
||||
} else {
|
||||
delete primaryAppointmentMetadata.employee_id;
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
tenantSlug: firstAdditional.tenantSlug,
|
||||
tenantImport: user.tenantImport
|
||||
? {
|
||||
...user.tenantImport,
|
||||
slug: firstAdditional.tenantSlug || "",
|
||||
name: firstAdditional.tenantName || firstAdditional.tenantSlug || "",
|
||||
}
|
||||
: user.tenantImport,
|
||||
department: firstAdditional.department,
|
||||
grade: firstAdditional.grade,
|
||||
position: firstAdditional.position,
|
||||
jobTitle: firstAdditional.jobTitle,
|
||||
metadata,
|
||||
additionalAppointments: [
|
||||
{
|
||||
...firstAdditional,
|
||||
tenantSlug: user.tenantSlug,
|
||||
department: user.department,
|
||||
grade: user.grade,
|
||||
position: user.position,
|
||||
jobTitle: user.jobTitle,
|
||||
metadata: primaryAppointmentMetadata,
|
||||
},
|
||||
...(user.additionalAppointments?.slice(1) ?? []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function applyGPDTDCRepresentativeTenant(
|
||||
user: BulkUserItem,
|
||||
tenants: TenantSummary[],
|
||||
): BulkUserItem | undefined {
|
||||
const root = findGPDTDCRootTenant(tenants);
|
||||
if (!root) return undefined;
|
||||
|
||||
const primarySlug = user.tenantSlug || "";
|
||||
const hasPrimaryUnderRoot = isUnderTenant(primarySlug, root, tenants);
|
||||
const hasAppointmentUnderRoot = (user.additionalAppointments ?? []).some(
|
||||
(appointment) => isUnderTenant(appointment.tenantSlug || "", root, tenants),
|
||||
);
|
||||
if (!hasPrimaryUnderRoot && !hasAppointmentUnderRoot) return undefined;
|
||||
if (primarySlug === root.slug) return undefined;
|
||||
|
||||
const worksmobileAppointments: BulkUserAppointment[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addAppointment = (
|
||||
appointment: BulkUserAppointment,
|
||||
fallbackKey: string,
|
||||
) => {
|
||||
const key = appointment.tenantSlug || appointment.tenantId || fallbackKey;
|
||||
if (!key || key === root.slug || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
worksmobileAppointments.push(appointment);
|
||||
};
|
||||
|
||||
addAppointment(buildPrimaryAppointment(user), "primary");
|
||||
for (const appointment of user.additionalAppointments ?? []) {
|
||||
addAppointment(
|
||||
{ ...appointment, isPrimary: false },
|
||||
appointment.tenantSlug || "",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
tenantSlug: root.slug,
|
||||
tenantImport: {
|
||||
...(user.tenantImport ?? {}),
|
||||
sourceTenantId: undefined,
|
||||
slug: root.slug,
|
||||
name: root.name,
|
||||
},
|
||||
additionalAppointments:
|
||||
worksmobileAppointments.length > 0 ? worksmobileAppointments : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrimaryAppointment(user: BulkUserItem): BulkUserAppointment {
|
||||
return {
|
||||
...(user.tenantId ? { tenantId: user.tenantId } : {}),
|
||||
...(user.tenantSlug ? { tenantSlug: user.tenantSlug } : {}),
|
||||
isPrimary: true,
|
||||
isOwner: false,
|
||||
...(user.department ? { department: user.department } : {}),
|
||||
...(user.grade ? { grade: user.grade } : {}),
|
||||
...(user.position ? { position: user.position } : {}),
|
||||
...(user.jobTitle ? { jobTitle: user.jobTitle } : {}),
|
||||
metadata: { ...user.metadata },
|
||||
};
|
||||
}
|
||||
|
||||
function findGPDTDCRootTenant(tenants: TenantSummary[]) {
|
||||
return tenants.find((tenant) => {
|
||||
const slug = tenant.slug.trim().toLowerCase();
|
||||
const name = tenant.name.replace(/\s+/g, "").toLowerCase();
|
||||
return (
|
||||
slug === "gpdtdc" ||
|
||||
name === "gpdtdc" ||
|
||||
name.includes("총괄기획&기술개발센터") ||
|
||||
name.includes("총괄기획기술개발센터")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isUnderGeneralPlanningOffice(
|
||||
tenantSlug: string,
|
||||
tenants: TenantSummary[],
|
||||
): boolean {
|
||||
let current = tenants.find((tenant) => tenant.slug === tenantSlug);
|
||||
while (current) {
|
||||
if (current.name === "총괄기획실") return true;
|
||||
if (!current.parentId) break;
|
||||
current = tenants.find((tenant) => tenant.id === current?.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isUnderTenant(
|
||||
tenantSlug: string,
|
||||
root: TenantSummary,
|
||||
tenants: TenantSummary[],
|
||||
): boolean {
|
||||
let current = tenants.find((tenant) => tenant.slug === tenantSlug);
|
||||
while (current) {
|
||||
if (current.id === root.id || current.slug === root.slug) return true;
|
||||
if (!current.parentId) break;
|
||||
current = tenants.find((tenant) => tenant.id === current?.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
@@ -26,7 +26,8 @@
|
||||
--input: 215 25% 24%;
|
||||
--ring: 209 79% 52%;
|
||||
--radius: 0.75rem;
|
||||
--app-background-image: radial-gradient(
|
||||
--app-background-image:
|
||||
radial-gradient(
|
||||
circle at 10% 18%,
|
||||
rgba(54, 211, 153, 0.16),
|
||||
transparent 28%
|
||||
|
||||
225
adminfront/src/lib/adminApi.contract.test.ts
Normal file
225
adminfront/src/lib/adminApi.contract.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiClient = {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
const fetchAllCursorPages = vi.fn(async () => ({
|
||||
items: [{ id: "tenant-1", name: "Tenant", slug: "tenant" }],
|
||||
total: 1,
|
||||
}));
|
||||
|
||||
vi.mock("./apiClient", () => ({
|
||||
default: apiClient,
|
||||
}));
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
userManager: {
|
||||
getUser: vi.fn(async () => ({ access_token: "access-token" })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../common/core/pagination", () => ({
|
||||
fetchAllCursorPages,
|
||||
}));
|
||||
|
||||
describe("adminApi endpoint contracts", () => {
|
||||
beforeEach(() => {
|
||||
apiClient.get.mockReset();
|
||||
apiClient.post.mockReset();
|
||||
apiClient.put.mockReset();
|
||||
apiClient.patch.mockReset();
|
||||
apiClient.delete.mockReset();
|
||||
|
||||
apiClient.get.mockResolvedValue({
|
||||
data: { ok: true },
|
||||
headers: { "content-disposition": 'attachment; filename="export.csv"' },
|
||||
});
|
||||
apiClient.post.mockResolvedValue({ data: { ok: true } });
|
||||
apiClient.put.mockResolvedValue({ data: { ok: true } });
|
||||
apiClient.patch.mockResolvedValue({ data: { ok: true } });
|
||||
apiClient.delete.mockResolvedValue({ data: { ok: true } });
|
||||
fetchAllCursorPages.mockClear();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("routes read APIs to their documented admin endpoints", async () => {
|
||||
const adminApi = await import("./adminApi");
|
||||
|
||||
await adminApi.fetchAuditLogs(10, "cursor-a");
|
||||
await adminApi.fetchAdminOverviewStats();
|
||||
await adminApi.fetchDataIntegrityReport();
|
||||
await adminApi.fetchOrphanUserLoginIDs();
|
||||
await adminApi.fetchUserProjectionStatus();
|
||||
await adminApi.fetchAdminRPUsageDaily({
|
||||
days: 30,
|
||||
period: "week",
|
||||
tenantId: "tenant-1",
|
||||
});
|
||||
await adminApi.fetchTenants(25, 50, "parent-1", "cursor-b");
|
||||
await adminApi.fetchAllTenants({ pageSize: 200, parentId: "parent-1" });
|
||||
await adminApi.fetchTenant("tenant-1");
|
||||
await adminApi.fetchTenantAdmins("tenant-1");
|
||||
await adminApi.fetchTenantOwners("tenant-1");
|
||||
await adminApi.fetchGroups("tenant-1");
|
||||
await adminApi.fetchGroup("tenant-1", "group-1");
|
||||
await adminApi.fetchGroupRoles("tenant-1", "group-1");
|
||||
await adminApi.fetchApiKeys(20, 40);
|
||||
await adminApi.fetchUsers(30, 60, "admin", "tenant");
|
||||
await adminApi.fetchUser("user-1");
|
||||
await adminApi.fetchWorksmobileOverview("tenant-1");
|
||||
await adminApi.fetchWorksmobileComparison("tenant-1", true);
|
||||
await adminApi.fetchWorksmobileCredentialBatches("tenant-1");
|
||||
await adminApi.downloadWorksmobileInitialPasswordsCSV("tenant-1");
|
||||
await adminApi.downloadWorksmobileInitialPasswordsCSV(
|
||||
"tenant-1",
|
||||
"credential-batch-1",
|
||||
);
|
||||
await adminApi.fetchPasswordPolicy();
|
||||
await adminApi.fetchUserRpHistory("user-1");
|
||||
await adminApi.fetchMe();
|
||||
await adminApi.fetchRelyingParties("tenant-1");
|
||||
await adminApi.fetchAllRelyingParties();
|
||||
await adminApi.fetchRelyingParty("client-1");
|
||||
await adminApi.fetchRPOwners("client-1");
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", {
|
||||
params: { limit: 10, cursor: "cursor-a" },
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", {
|
||||
params: {
|
||||
limit: 25,
|
||||
offset: 50,
|
||||
parentId: "parent-1",
|
||||
cursor: "cursor-b",
|
||||
},
|
||||
});
|
||||
expect(fetchAllCursorPages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: "/v1/admin/tenants",
|
||||
pageSize: 200,
|
||||
params: { parentId: "parent-1" },
|
||||
}),
|
||||
);
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/comparison",
|
||||
{ params: { includeMatched: true } },
|
||||
);
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/credential-batches",
|
||||
);
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/initial-passwords.csv",
|
||||
{
|
||||
params: { batchId: "credential-batch-1" },
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
expect(await adminApi.exportTenantsCSV(true, "parent-1")).toMatchObject({
|
||||
filename: "export.csv",
|
||||
});
|
||||
expect(
|
||||
await adminApi.exportUsersCSV("admin", "tenant", true),
|
||||
).toMatchObject({
|
||||
filename: "export.csv",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes mutation APIs to their documented admin endpoints", async () => {
|
||||
const adminApi = await import("./adminApi");
|
||||
|
||||
await adminApi.deleteOrphanUserLoginIDs(["orphan-1"]);
|
||||
await adminApi.reconcileUserProjection();
|
||||
await adminApi.resetUserProjection();
|
||||
await adminApi.createTenant({ name: "Tenant", slug: "tenant" });
|
||||
await adminApi.updateTenant("tenant-1", { status: "inactive" });
|
||||
await adminApi.deleteTenant("tenant-1");
|
||||
await adminApi.deleteTenantsBulk(["tenant-1"]);
|
||||
await adminApi.importTenantsCSV(new File(["name"], "tenants.csv"));
|
||||
await adminApi.approveTenant("tenant-1");
|
||||
await adminApi.addTenantAdmin("tenant-1", "user-1");
|
||||
await adminApi.removeTenantAdmin("tenant-1", "user-1");
|
||||
await adminApi.addTenantOwner("tenant-1", "user-1");
|
||||
await adminApi.removeTenantOwner("tenant-1", "user-1");
|
||||
await adminApi.createGroup("tenant-1", { name: "Group" });
|
||||
await adminApi.deleteGroup("tenant-1", "group-1");
|
||||
await adminApi.addGroupMember("tenant-1", "group-1", "user-1");
|
||||
await adminApi.removeGroupMember("tenant-1", "group-1", "user-1");
|
||||
await adminApi.assignGroupRole("tenant-1", "group-1", "tenant-2", "owner");
|
||||
await adminApi.removeGroupRole("tenant-1", "group-1", "tenant-2", "owner");
|
||||
await adminApi.createApiKey({ name: "key", scopes: ["read"] });
|
||||
await adminApi.updateApiKeyScopes("key-1", { scopes: ["write"] });
|
||||
await adminApi.rotateApiKeySecret("key-1");
|
||||
await adminApi.deleteApiKey("key-1");
|
||||
await adminApi.createUser({ email: "user@example.com", name: "User" });
|
||||
await adminApi.bulkCreateUsers([
|
||||
{ email: "user@example.com", name: "User", metadata: {} },
|
||||
]);
|
||||
await adminApi.enqueueWorksmobileBackfillDryRun("tenant-1");
|
||||
await adminApi.enqueueWorksmobileOrgUnitSync("tenant-1", "org/unit");
|
||||
await adminApi.enqueueWorksmobileOrgUnitDelete("tenant-1", "org/unit");
|
||||
await adminApi.enqueueWorksmobileUserSync("tenant-1", "user-1");
|
||||
await adminApi.enqueueWorksmobileUserSync(
|
||||
"tenant-1",
|
||||
"user-2",
|
||||
"credential-batch-1",
|
||||
);
|
||||
await adminApi.resetWorksmobileUserPassword(
|
||||
"tenant-1",
|
||||
"user-2",
|
||||
"credential-batch-2",
|
||||
);
|
||||
await adminApi.deleteWorksmobileCredentialBatchPasswords(
|
||||
"tenant-1",
|
||||
"credential-batch-1",
|
||||
);
|
||||
await adminApi.retryWorksmobileJob("tenant-1", "job-1");
|
||||
await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" });
|
||||
await adminApi.bulkDeleteUsers(["user-1"]);
|
||||
await adminApi.updateUser("user-1", { status: "active" });
|
||||
await adminApi.deleteUser("user-1");
|
||||
await adminApi.createRelyingParty("tenant-1", {
|
||||
client_name: "RP",
|
||||
redirect_uris: ["https://rp.example/callback"],
|
||||
});
|
||||
await adminApi.updateRelyingParty("client-1", {
|
||||
client_name: "RP",
|
||||
redirect_uris: ["https://rp.example/callback"],
|
||||
});
|
||||
await adminApi.deleteRelyingParty("client-1");
|
||||
await adminApi.addRPOwner("client-1", "User:user-1");
|
||||
await adminApi.removeRPOwner("client-1", "User:user-1");
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||
"/v1/admin/integrity/orphan-user-login-ids",
|
||||
{ data: { ids: ["orphan-1"] } },
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/projections/users/reconcile",
|
||||
);
|
||||
expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", {
|
||||
status: "active",
|
||||
});
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/orgunits/org%2Funit/sync",
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/sync",
|
||||
{ credentialBatchId: "credential-batch-1" },
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/password/reset",
|
||||
{ credentialBatchId: "credential-batch-2" },
|
||||
);
|
||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/credential-batches/credential-batch-1/passwords",
|
||||
);
|
||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||
"/v1/admin/relying-parties/client-1/owners/User:user-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -70,11 +70,22 @@ export type TenantUpdateRequest = {
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TenantImportDetail = {
|
||||
row: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
action: "created" | "updated" | "failed" | "skipped";
|
||||
message: string;
|
||||
modifiedFields?: string[];
|
||||
};
|
||||
|
||||
export type TenantImportResult = {
|
||||
created: number;
|
||||
updated: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
details: TenantImportDetail[];
|
||||
};
|
||||
|
||||
export type ApiKeySummary = {
|
||||
@@ -204,9 +215,14 @@ export type DeleteOrphanUserLoginIDsResult = {
|
||||
skippedIds: string[];
|
||||
};
|
||||
|
||||
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||
export async function fetchAuditLogs(
|
||||
limit = 50,
|
||||
cursor?: string,
|
||||
search?: string,
|
||||
status?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||
params: { limit, cursor },
|
||||
params: { limit, cursor, search, status },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -282,11 +298,12 @@ export async function fetchTenants(
|
||||
offset = 0,
|
||||
parentId?: string,
|
||||
cursor?: string,
|
||||
search?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<TenantListResponse>(
|
||||
"/v1/admin/tenants",
|
||||
{
|
||||
params: { limit, offset, parentId, cursor },
|
||||
params: { limit, offset, parentId, cursor, search },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
@@ -650,6 +667,8 @@ export type UserListResponse = {
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
next_cursor?: string;
|
||||
nextCursor?: string;
|
||||
};
|
||||
|
||||
export type UserCreateRequest = {
|
||||
@@ -676,6 +695,7 @@ export type UserCreateResponse = UserSummary & {
|
||||
};
|
||||
|
||||
export type UserUpdateRequest = {
|
||||
email?: string;
|
||||
loginId?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
@@ -701,7 +721,9 @@ export type UserAppointment = {
|
||||
tenantSlug?: string;
|
||||
tenantName: string;
|
||||
isPrimary?: boolean;
|
||||
isOwner: boolean;
|
||||
isOwner?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isManager?: boolean;
|
||||
jobTitle?: string;
|
||||
grade?: string;
|
||||
position?: string;
|
||||
@@ -713,6 +735,8 @@ export type BulkUserAppointment = {
|
||||
tenantName?: string;
|
||||
isPrimary?: boolean;
|
||||
isOwner?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isManager?: boolean;
|
||||
department?: string;
|
||||
grade?: string;
|
||||
position?: string;
|
||||
@@ -745,7 +769,8 @@ export type BulkUserItem = {
|
||||
memo?: string;
|
||||
emailDomain?: string;
|
||||
};
|
||||
metadata: Record<string, string>;
|
||||
metadata: Record<string, unknown>;
|
||||
importErrors?: string[];
|
||||
};
|
||||
|
||||
export type BulkUserResult = {
|
||||
@@ -757,6 +782,7 @@ export type BulkUserResult = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
userId?: string;
|
||||
modifiedFields?: string[];
|
||||
};
|
||||
|
||||
export type BulkUserResponse = {
|
||||
@@ -768,6 +794,7 @@ export type WorksmobileOutboxItem = {
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
action: string;
|
||||
payload?: Record<string, unknown>;
|
||||
status: string;
|
||||
retryCount: number;
|
||||
lastError?: string;
|
||||
@@ -786,6 +813,34 @@ export type WorksmobileOverview = {
|
||||
recentJobs: WorksmobileOutboxItem[];
|
||||
};
|
||||
|
||||
export type WorksmobileCredentialBatch = {
|
||||
batchId: string;
|
||||
operation?: string;
|
||||
userCount: number;
|
||||
pendingCount?: number;
|
||||
processingCount?: number;
|
||||
processedCount?: number;
|
||||
failedCount?: number;
|
||||
hasPasswords: boolean;
|
||||
deletedAt?: string;
|
||||
failures?: WorksmobileCredentialBatchFailure[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type WorksmobileCredentialBatchFailure = {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
status: string;
|
||||
retryCount: number;
|
||||
lastError?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type WorksmobilePendingJobDeleteResult = {
|
||||
deletedCount: number;
|
||||
};
|
||||
|
||||
export type WorksmobileComparisonItem = {
|
||||
resourceType: string;
|
||||
baronId?: string;
|
||||
@@ -819,6 +874,10 @@ export type WorksmobileComparisonItem = {
|
||||
worksmobileParentName?: string;
|
||||
worksmobileParentEmail?: string;
|
||||
worksmobileParentExternalKey?: string;
|
||||
worksmobileJobStatus?: string;
|
||||
worksmobileJobRetryCount?: number;
|
||||
worksmobileLastError?: string;
|
||||
worksmobileLastAttemptAt?: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
@@ -832,9 +891,10 @@ export async function fetchUsers(
|
||||
offset = 0,
|
||||
search?: string,
|
||||
tenantSlug?: string,
|
||||
cursor?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
|
||||
params: { limit, offset, search, tenantSlug },
|
||||
params: { limit, offset, search, tenantSlug, cursor },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -902,10 +962,22 @@ export async function fetchWorksmobileComparison(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
|
||||
export async function fetchWorksmobileCredentialBatches(tenantId: string) {
|
||||
const { data } = await apiClient.get<WorksmobileCredentialBatch[]>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/credential-batches`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function downloadWorksmobileInitialPasswordsCSV(
|
||||
tenantId: string,
|
||||
batchId?: string,
|
||||
) {
|
||||
const trimmedBatchId = batchId?.trim();
|
||||
const response = await apiClient.get<Blob>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/initial-passwords.csv`,
|
||||
{
|
||||
...(trimmedBatchId ? { params: { batchId: trimmedBatchId } } : {}),
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
@@ -920,6 +992,23 @@ export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteWorksmobileCredentialBatchPasswords(
|
||||
tenantId: string,
|
||||
batchId: string,
|
||||
) {
|
||||
const { data } = await apiClient.delete<WorksmobileCredentialBatch>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/credential-batches/${encodeURIComponent(batchId)}/passwords`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteWorksmobilePendingJobs(tenantId: string) {
|
||||
const { data } = await apiClient.delete<WorksmobilePendingJobDeleteResult>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/jobs/pending`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
|
||||
const { data } = await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,
|
||||
@@ -950,10 +1039,30 @@ export async function enqueueWorksmobileOrgUnitDelete(
|
||||
export async function enqueueWorksmobileUserSync(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
credentialBatchId?: string,
|
||||
) {
|
||||
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`,
|
||||
);
|
||||
const trimmedBatchId = credentialBatchId?.trim();
|
||||
const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`;
|
||||
const { data } = trimmedBatchId
|
||||
? await apiClient.post<WorksmobileOutboxItem>(path, {
|
||||
credentialBatchId: trimmedBatchId,
|
||||
})
|
||||
: await apiClient.post<WorksmobileOutboxItem>(path);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function resetWorksmobileUserPassword(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
credentialBatchId?: string,
|
||||
) {
|
||||
const trimmedBatchId = credentialBatchId?.trim();
|
||||
const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/password/reset`;
|
||||
const { data } = trimmedBatchId
|
||||
? await apiClient.post<WorksmobileOutboxItem>(path, {
|
||||
credentialBatchId: trimmedBatchId,
|
||||
})
|
||||
: await apiClient.post<WorksmobileOutboxItem>(path);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import axios from "axios";
|
||||
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
||||
import {
|
||||
shouldSuppressDevelopmentSessionRedirect,
|
||||
} from "../../../common/core/session";
|
||||
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
|
||||
import { userManager } from "./auth";
|
||||
|
||||
let isRedirectingToLogin = false;
|
||||
@@ -30,14 +28,6 @@ apiClient.interceptors.request.use(async (config) => {
|
||||
config.headers["X-Tenant-ID"] = tenantId;
|
||||
}
|
||||
|
||||
// [Development Only] Inject Mock Role from RoleSwitcher
|
||||
const isMockRoleEnabled =
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||
if (isMockRoleEnabled && mockRole) {
|
||||
config.headers["X-Test-Role"] = mockRole;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAdminAuthRedirectUris,
|
||||
canStartBrowserPkceLogin,
|
||||
resolveAdminPublicOrigin,
|
||||
} from "./authConfig";
|
||||
|
||||
@@ -24,4 +25,39 @@ describe("admin auth config", () => {
|
||||
"http://localhost:5173",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks browser PKCE login when WebCrypto is unavailable", () => {
|
||||
expect(
|
||||
canStartBrowserPkceLogin({
|
||||
isSecureContext: false,
|
||||
origin: "http://localhost:5173",
|
||||
cryptoSubtleAvailable: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canStartBrowserPkceLogin({
|
||||
isSecureContext: true,
|
||||
origin: "https://admin.example.test",
|
||||
cryptoSubtleAvailable: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows trusted local and private-network origins only when WebCrypto is available", () => {
|
||||
for (const origin of [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://host.docker.internal:5173",
|
||||
"http://172.16.9.189:5173",
|
||||
"http://192.168.0.20:5173",
|
||||
]) {
|
||||
expect(
|
||||
canStartBrowserPkceLogin({
|
||||
isSecureContext: false,
|
||||
origin,
|
||||
cryptoSubtleAvailable: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,3 +31,58 @@ export function buildAdminAuthRedirectUris(
|
||||
popupRedirectUri: `${publicOrigin}${ADMIN_AUTH_CALLBACK_PATH}`,
|
||||
};
|
||||
}
|
||||
|
||||
export type BrowserPkceLoginCheck = {
|
||||
isSecureContext?: boolean;
|
||||
origin?: string;
|
||||
cryptoSubtleAvailable?: boolean;
|
||||
};
|
||||
|
||||
const devTrustedPkceHosts = new Set([
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"host.docker.internal",
|
||||
]);
|
||||
|
||||
function isPrivateIPv4(hostname: string) {
|
||||
const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [first, second] = parts;
|
||||
return (
|
||||
first === 10 ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
);
|
||||
}
|
||||
|
||||
function isDevTrustedPkceOrigin(origin: string) {
|
||||
try {
|
||||
const hostname = new URL(origin).hostname;
|
||||
return devTrustedPkceHosts.has(hostname) || isPrivateIPv4(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canStartBrowserPkceLogin({
|
||||
isSecureContext = window.isSecureContext,
|
||||
origin = window.location.origin,
|
||||
cryptoSubtleAvailable = Boolean(window.crypto?.subtle),
|
||||
}: BrowserPkceLoginCheck = {}) {
|
||||
if (!cryptoSubtleAvailable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isSecureContext) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isDevTrustedPkceOrigin(origin);
|
||||
}
|
||||
|
||||
@@ -38,9 +38,11 @@ describe("common cursor pagination fetch", () => {
|
||||
expect(response.items).toEqual([{ id: "tenant-1" }, { id: "tenant-2" }]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0][0].toString()).toContain(
|
||||
"/api/v1/admin/tenants?parentId=parent-1&limit=1&offset=0",
|
||||
"/api/v1/admin/tenants?parentId=parent-1&limit=1",
|
||||
);
|
||||
expect(fetchMock.mock.calls[0][0].toString()).not.toContain("offset=");
|
||||
expect(fetchMock.mock.calls[1][0].toString()).toContain("cursor=cursor-1");
|
||||
expect(fetchMock.mock.calls[1][0].toString()).not.toContain("offset=");
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({
|
||||
headers: { Authorization: "Bearer token" },
|
||||
credentials: "same-origin",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const CLIENT_DEBUG_LOG_ENABLED = new Set(["1", "true", "yes", "y", "on"]).has(
|
||||
String(import.meta.env.VITE_CLIENT_LOG_DEBUG ?? "").trim().toLowerCase(),
|
||||
String(import.meta.env.VITE_CLIENT_LOG_DEBUG ?? "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
|
||||
export function debugLog(...args: Parameters<typeof console.debug>) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, type Locale } from "../../../common/core/i18n";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_STORAGE_KEY,
|
||||
type Locale,
|
||||
} from "../../../common/core/i18n";
|
||||
|
||||
function isLocale(value: string): value is Locale {
|
||||
return value === "ko" || value === "en";
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ROLE_RP_ADMIN,
|
||||
ROLE_SUPER_ADMIN,
|
||||
ROLE_TENANT_ADMIN,
|
||||
ROLE_USER,
|
||||
isSuperAdminRole,
|
||||
normalizeAdminRole,
|
||||
ROLE_SUPER_ADMIN,
|
||||
ROLE_USER,
|
||||
} from "./roles";
|
||||
|
||||
describe("admin role helpers", () => {
|
||||
@@ -14,13 +12,13 @@ describe("admin role helpers", () => {
|
||||
["superadmin", ROLE_SUPER_ADMIN],
|
||||
["super-admin", ROLE_SUPER_ADMIN],
|
||||
[" SUPER-ADMIN ", ROLE_SUPER_ADMIN],
|
||||
["tenant_admin", ROLE_TENANT_ADMIN],
|
||||
["tenantadmin", ROLE_TENANT_ADMIN],
|
||||
["tenant-admin", ROLE_TENANT_ADMIN],
|
||||
["admin", ROLE_TENANT_ADMIN],
|
||||
["rp_admin", ROLE_RP_ADMIN],
|
||||
["rpadmin", ROLE_RP_ADMIN],
|
||||
["rp-admin", ROLE_RP_ADMIN],
|
||||
["tenant_admin", ROLE_USER],
|
||||
["tenantadmin", ROLE_USER],
|
||||
["tenant-admin", ROLE_USER],
|
||||
["admin", ROLE_USER],
|
||||
["rp_admin", ROLE_USER],
|
||||
["rpadmin", ROLE_USER],
|
||||
["rp-admin", ROLE_USER],
|
||||
["tenant_member", ROLE_USER],
|
||||
["member", ROLE_USER],
|
||||
["custom", ROLE_USER],
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
export const ROLE_SUPER_ADMIN = "super_admin";
|
||||
export const ROLE_TENANT_ADMIN = "tenant_admin";
|
||||
export const ROLE_RP_ADMIN = "rp_admin";
|
||||
export const ROLE_USER = "user";
|
||||
|
||||
export type AdminRole =
|
||||
| typeof ROLE_SUPER_ADMIN
|
||||
| typeof ROLE_TENANT_ADMIN
|
||||
| typeof ROLE_RP_ADMIN
|
||||
| typeof ROLE_USER;
|
||||
export type AdminRole = typeof ROLE_SUPER_ADMIN | typeof ROLE_USER;
|
||||
|
||||
export function normalizeAdminRole(role?: string | null): AdminRole {
|
||||
const normalized = role?.trim().toLowerCase() ?? "";
|
||||
@@ -17,16 +11,14 @@ export function normalizeAdminRole(role?: string | null): AdminRole {
|
||||
case "superadmin":
|
||||
case "super-admin":
|
||||
return ROLE_SUPER_ADMIN;
|
||||
case ROLE_TENANT_ADMIN:
|
||||
case ROLE_USER:
|
||||
case "tenant_admin":
|
||||
case "tenantadmin":
|
||||
case "tenant-admin":
|
||||
case "admin":
|
||||
return ROLE_TENANT_ADMIN;
|
||||
case ROLE_RP_ADMIN:
|
||||
case "rp_admin":
|
||||
case "rpadmin":
|
||||
case "rp-admin":
|
||||
return ROLE_RP_ADMIN;
|
||||
case ROLE_USER:
|
||||
case "tenant_member":
|
||||
case "member":
|
||||
return ROLE_USER;
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
readSessionExpiryEnabled,
|
||||
SESSION_RENEW_THRESHOLD_MS,
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
shouldSuppressDevelopmentSessionRedirect,
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
shouldSuppressDevelopmentSessionRedirect,
|
||||
writeSessionExpiryEnabled,
|
||||
} from "./sessionSliding";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user