forked from baron/baron-sso
Compare commits
353 Commits
backup1
...
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 | |||
| f19b694c0b | |||
| 8dfe6fed82 | |||
| 5bb1c5871c | |||
| 2d6ca2f66b | |||
| 79f99757ee | |||
| 49b78b3786 | |||
| 2c3cab78b1 | |||
| 8b61c054e7 | |||
| c46c700c60 | |||
| 8c991ec48d | |||
| 0af268021e | |||
| b55ab7bc67 | |||
| dcb442b68d | |||
| dd1238a4e4 | |||
| 16d43c5973 | |||
| c21ea29111 | |||
| fc4a2f3536 | |||
| c2dbc8fc88 | |||
| 528ceea754 | |||
| 63622dcf28 | |||
| 598f6ff9d1 | |||
| 611730f22a | |||
| c9664b5844 | |||
| a1f3604b24 | |||
| 097caf395c | |||
| 54fb7b4db6 | |||
| d1184613d8 | |||
| 222dc6f4a4 | |||
| 279bfae9ec | |||
| 7d99dba890 | |||
| e7dab0f8fd | |||
| c7d25f3611 | |||
| 5496735e2f | |||
| f4bfa7c129 | |||
| 11d535f4e3 | |||
| 53dacda5d5 | |||
| 0155ee4ee7 | |||
| 0031784c07 | |||
| 0f61425bbf | |||
| fd82dd9bdd | |||
| 58f968b0fe | |||
| 8f593cf6c0 | |||
| 42b49674cc | |||
| bb918932f4 | |||
| 9112c4fb36 | |||
| 0b54992309 | |||
| e29d056b9e | |||
| c71ece84b8 | |||
| 36fc945eaf | |||
| f22a914586 | |||
| b84c52366e | |||
| a4ffb49314 | |||
| 97c02fdba1 | |||
| f028aeb716 | |||
| e01b3475ec | |||
| cd16cb3a4a | |||
| eddab895e9 | |||
| 0f80ee4f4d | |||
| 9df69f22e8 | |||
| 974af01d34 | |||
| 18eede3a10 | |||
| 055a804f7f | |||
| 94f33a0a64 | |||
| 0bf8089120 | |||
| 0327409631 | |||
| c0894eeb8a | |||
| c9bf16cf8e | |||
| cb602de049 | |||
| faffb6dc05 | |||
| b3c360c54f | |||
| 12e37b24b0 | |||
| 153ea3bad5 | |||
| 3a0cd1cfed | |||
| 0a5ae51a68 | |||
| bdd42be57e | |||
| b387673a8a | |||
| 5ef8f933cc | |||
| 0d84dbcde1 | |||
| 4d0d4f6a63 | |||
| 55c44b1a6c | |||
| d4090b7d8d | |||
| 220e87494b | |||
| b7fbbf568d | |||
| 41fe1b09c6 | |||
| b1e617ff37 | |||
| cca8aea7a2 | |||
| 14fb155cd9 | |||
| d28a121d6c | |||
| 4346f48bbe | |||
| 16422f4e2e | |||
| eff21aaa82 | |||
| 1b483d4cbf | |||
| 8f57c6b15f | |||
| 565d03da43 | |||
| eb697e560a | |||
| 8010d3644d | |||
| 9f7b925e73 | |||
| 55d5e58783 | |||
| 4f952df003 | |||
| 412695841b | |||
| 62d765a77b | |||
| 4de7124a3c | |||
| e71e090eec | |||
| 1a0dddbd98 | |||
| dcab9205d2 | |||
| 8951de510e | |||
| 4ca562ce0e | |||
| 3f957d7a9f | |||
| c2a9e1044c | |||
| 254e34dbca | |||
| c06a5bf181 | |||
| 57456bd4cd | |||
| d1b550f6f7 | |||
| 0b92ad49da | |||
| 5cd3f04f69 | |||
| 574238c744 | |||
| 1b9687e9e8 | |||
| b4a3cc4318 | |||
| 258c91a740 | |||
| ece8df50f6 | |||
| 024e1cc5bd | |||
| 841e1f8ab2 | |||
| 79f5ace7ef | |||
| da10b4be15 | |||
| e803a0b150 | |||
| c7ed9186c9 | |||
| 76a63264fe | |||
| 481ec5fc15 | |||
| ee8cfb4ba8 | |||
| c8ac953b14 | |||
| 40d64acf15 | |||
| 4a0e5641cb | |||
| 8a8b5baaf6 | |||
| 187f0da29b | |||
| 498fdd802c | |||
| b9a351ca59 | |||
| a26093836f | |||
| d77199bdbc | |||
| d3e83332fb | |||
| 8bca127723 | |||
| 92e607aee8 | |||
| df543d6203 | |||
| 9ca73e8774 | |||
| f6f8e88342 | |||
| e36a973053 | |||
| 8a6e41d74c | |||
| 5e7b7b878c | |||
| f4ed1057a2 | |||
| f047c24a38 | |||
| 9681945f5a | |||
| 3a3bfd3c00 | |||
| a31eceaf16 | |||
| a4d707d4d8 | |||
| 629716f226 | |||
| 6ed9b2b734 | |||
| 8c2b2f71ef | |||
| ee24842225 | |||
| 5f48a1c172 | |||
| 72288f1d39 | |||
| def2f924c9 | |||
| ae0a516ee4 | |||
| 0c706a8936 | |||
| 298b919d1a | |||
| 937f2f9820 | |||
| e8a4d7544f | |||
| 878867f6cc | |||
| 250bc297fa | |||
| 68eeac90f7 | |||
| ee41083b73 | |||
| 45ce440569 | |||
| 084e8594ff | |||
| f810efd420 | |||
| 7259c62251 | |||
| 6709bf3029 | |||
| 3626584046 | |||
| a2a6938246 | |||
| a0713df85a | |||
| 48853aae99 | |||
| 5149fdc246 | |||
| 85e1a172dd | |||
| 7d7f17ab69 | |||
| 1c083dd586 | |||
| 1419c8db27 | |||
| 0655206f05 | |||
| d371bd32c8 | |||
| c0c5a23dc1 | |||
| 27f48baadc | |||
| b8a25135fc | |||
| efbf970a18 |
@@ -16,3 +16,4 @@
|
||||
**/*.log
|
||||
**/*.swp
|
||||
**/.DS_Store
|
||||
**/.pnpm-store
|
||||
|
||||
@@ -32,6 +32,11 @@ BACKEND_LOG_LEVEL=
|
||||
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
|
||||
|
||||
# --- NAVER WORKS API ---
|
||||
WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token
|
||||
|
||||
|
||||
# Audit System Configuration
|
||||
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
|
||||
AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Build and push adminfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./adminfront
|
||||
context: .
|
||||
file: ./adminfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
- name: Build and push devfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./devfront
|
||||
context: .
|
||||
file: ./devfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
- name: Build and push orgfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./orgfront
|
||||
context: .
|
||||
file: ./orgfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,8 @@ jobs:
|
||||
APP_ENV=stage
|
||||
BACKEND_LOG_LEVEL=debug
|
||||
CLIENT_LOG_DEBUG=true
|
||||
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
TZ=Asia/Seoul
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ jobs:
|
||||
APP_ENV=stage
|
||||
BACKEND_LOG_LEVEL=debug
|
||||
CLIENT_LOG_DEBUG=true
|
||||
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
TZ=Asia/Seoul
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
|
||||
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}
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# General
|
||||
.env
|
||||
.env_backup
|
||||
.temp
|
||||
.DS_Store
|
||||
.idea/
|
||||
@@ -17,6 +18,8 @@ config/.generated/
|
||||
reports
|
||||
reports/*
|
||||
config/*.pem
|
||||
common/node_modules
|
||||
common/.baron-deps-install.lock
|
||||
|
||||
# Docker Services Data (Volumes)
|
||||
postgres_data/
|
||||
@@ -47,6 +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: 시스템 관리자에게 문의하세요.
|
||||
78
Makefile
78
Makefile
@@ -29,7 +29,7 @@ ifneq (,$(wildcard ./.env))
|
||||
COMPOSE_DROP_ENV_ARGS += --env-file .env
|
||||
endif
|
||||
|
||||
.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app
|
||||
.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app
|
||||
|
||||
# --- 인증 설정 빌드/검증 ---
|
||||
build-auth-config:
|
||||
@@ -56,6 +56,7 @@ up: up-all
|
||||
up-all: ensure-networks render-ory-config
|
||||
@echo "Starting ALL stacks (infra + ory + app)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos
|
||||
|
||||
# --- 개별 스택 실행 ---
|
||||
up-infra: ensure-networks
|
||||
@@ -65,6 +66,7 @@ up-infra: ensure-networks
|
||||
up-ory: ensure-networks render-ory-config
|
||||
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos
|
||||
|
||||
up-app: ensure-networks render-ory-config
|
||||
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
|
||||
@@ -114,7 +116,8 @@ ensure-ory: ensure-networks render-ory-config
|
||||
echo "Starting missing Ory stack containers in daemon mode..."; \
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \
|
||||
else \
|
||||
echo "Ory stack is already running."; \
|
||||
echo "Ory stack is already running. Restarting Kratos to apply rendered dev config..."; \
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos; \
|
||||
fi
|
||||
|
||||
up-dev: ensure-infra ensure-ory
|
||||
@@ -125,7 +128,11 @@ up-front-dev: up-infra up-ory up-backend
|
||||
|
||||
dev: up-dev
|
||||
@echo "Starting development app containers in foreground attach mode..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
|
||||
BACKEND_LOG_LEVEL=info CLIENT_LOG_DEBUG=false VITE_CLIENT_LOG_DEBUG=false docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
|
||||
|
||||
dev-debug: up-dev
|
||||
@echo "Starting development app containers in foreground attach debug mode..."
|
||||
BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true VITE_CLIENT_LOG_DEBUG=true USERFRONT_FLUTTER_RUN_FLAGS=--debug docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
|
||||
|
||||
# --- 종료 (Down) ---
|
||||
down:
|
||||
@@ -246,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 && npm ci --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"
|
||||
@@ -276,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/"; \
|
||||
@@ -305,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; \
|
||||
@@ -334,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/"; \
|
||||
@@ -369,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();});")"; \
|
||||
|
||||
30
README.md
30
README.md
@@ -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)
|
||||
@@ -155,6 +161,7 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
|
||||
|
||||
### 한맥가족 User Import Email 정책
|
||||
- 전체 시스템에서 `users.email`은 unique입니다.
|
||||
- `active`, `temporary_leave`, `suspended`, `preboarding`, `baron_guest`, `extended_leave`, `archived` 등 모든 사용자 상태가 unique 검사 대상입니다. 특히 `preboarding`, `baron_guest`, `archived` 사용자는 email/local-part 선점 대상입니다.
|
||||
- 한맥가족 테넌트 root(`hanmac-family`)와 그 하위 subtree에서는 이메일 도메인과 무관하게 `@` 앞 local-part도 unique 해야 합니다.
|
||||
- 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다.
|
||||
- `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다.
|
||||
@@ -171,6 +178,22 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
|
||||
- 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다.
|
||||
- bulk import는 preview에서 제안/수정된 최종 email을 사용하되, backend가 생성 직전에 다시 unique 규칙을 검증합니다.
|
||||
|
||||
### User Status 정책
|
||||
| 상태 | 표시명 | Baron 사용 | Works 처리 | 일반 조직도 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `active` | 재직 | 가능 | 생성/갱신 | 노출 |
|
||||
| `temporary_leave` | 단기휴무 | 가능 | 계정 유지 | 노출 |
|
||||
| `suspended` | 정지 | 불가 | suspend | 노출 |
|
||||
| `preboarding` | 입사대기 | 불가 | 생성 안 함 | 비노출 |
|
||||
| `baron_guest` | Baron 게스트 | 가능 | 생성 금지, 기존 계정 delete/deprovision | 비노출 |
|
||||
| `extended_leave` | 장기휴직 | 불가 | delete/deprovision | 비노출 |
|
||||
| `archived` | 보관 | 불가 | delete/deprovision | 비노출 |
|
||||
|
||||
- 기존 `inactive` 입력은 `preboarding`으로, `leave_of_absence` 입력은 `temporary_leave`로 호환 처리합니다.
|
||||
- 이슈 #862의 초기 명칭 `baron_only`는 구현 명칭으로 사용하지 않고 `baron_guest`로 정리합니다.
|
||||
- backend bootstrap은 남아 있는 legacy `users.status` 값을 `inactive -> preboarding`, `leave_of_absence -> temporary_leave`, `baron_only -> baron_guest`로 자동 정규화합니다.
|
||||
- `archived` 사용자는 과거 이력 보존용 계정이며 AdminFront 같은 관리자 화면에서만 감사/운영/중복 확인 목적으로 조회할 수 있습니다.
|
||||
|
||||
|
||||
### 4. 주요 시나리오 (Core Scenarios)
|
||||
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
|
||||
@@ -541,11 +564,12 @@ KETO_WRITE_URL = "http://keto:4467"
|
||||
```
|
||||
|
||||
## 🌐 i18n 구조 (간략)
|
||||
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
|
||||
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
|
||||
- **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 현재 `userfront`와 전역 i18n 검증 기준 리소스입니다.
|
||||
- **Common locales**: `common/locales/template.toml`, `common/locales/ko.toml`, `common/locales/en.toml`은 `ui.common.*`, `msg.common.*` 같은 React 공통 문구 레이어입니다.
|
||||
- **React(Admin/Dev/Org)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`, `orgfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`를 사용하며 `common locale -> app locale override` 순서로 TOML을 `?raw` 로드합니다.
|
||||
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml`을 `tools/i18n-scanner/gen-flutter-i18n.js`로 `userfront/lib/i18n_data.dart`에 사전 생성합니다.
|
||||
- **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다.
|
||||
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다.
|
||||
- **검증**: `node tools/i18n-scanner/index.js`로 `root locales`와 `common/locales`의 코드-키-로케일 동기화 상태를 함께 점검합니다.
|
||||
|
||||
## 🧪 Code Check CI
|
||||
워크플로우 파일: `.gitea/workflows/code_check.yml`
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
FROM node:lts
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /workspace
|
||||
|
||||
# 패키지 정보 복사 및 의존성 설치
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
# Set CI environment variable to true to avoid TTY issues with pnpm
|
||||
ENV CI=true
|
||||
|
||||
# Install 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 pnpm install --filter adminfront... --filter baron-sso... --no-frozen-lockfile --ignore-scripts
|
||||
|
||||
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||
RUN npm install -g serve
|
||||
|
||||
# 소스 코드 복사
|
||||
COPY . .
|
||||
WORKDIR /workspace/adminfront
|
||||
|
||||
# Vite 기본 포트
|
||||
EXPOSE 5173
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time"
|
||||
"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"
|
||||
"Doe","Eric","eric.doe","eric@naver.com","eric2@company.com","Eric","Contract Employee","Manager","org.1|org.2|org.3|org.4|myteam","Manager","02-1234-0000","+1","9765412345","Japanese","General affairs","New York","Facebook","Eric","lunar","19840704","20240704","AB002","20240704 14:00"
|
||||
|
@@ -1,32 +1,7 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"style": {
|
||||
"useEnumInitializers": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"noLabelWithoutControl": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"root": true,
|
||||
"extends": ["../common/config/biome.base.json"],
|
||||
"files": {
|
||||
"ignore": [
|
||||
"dist",
|
||||
".vite",
|
||||
"node_modules",
|
||||
"tsconfig*.json",
|
||||
"test-results",
|
||||
"test-results.nobody-backup",
|
||||
"playwright-report"
|
||||
]
|
||||
"includes": [".vite"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
|
||||
"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","",""
|
||||
"인재성장","2","","","","hr@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"전산관리TF","4","한치영(cyhan@samaneng.com)","","","it@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"기술기획","8","김원기(ba.56669@baroncs.co.kr)","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"경영기획","0","","","","t_266py@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"ERP기획","0","","","","t_136ud@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"디자인기획","0","","","","t_618gm@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"협업증진","0","","","","t_752rp@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"솔루션통합","0","","","","t_683tq@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"네이버웍스관리용","2","슈퍼관리자(su-@samaneng.com)","","","su3@baroncs.co.kr","N","N","N","Y","","",""
|
||||
"기술개발센터","0","","","","t_536fc@baroncs.co.kr","Y","N","Y","Y","","",""
|
||||
"일반구조물 div","0","","","","t_568cz@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"DfMA","0","","","","t_538ub@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"일반구조물","0","","","","t_601cu@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"구조물계획","0","","","","t_388gh@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"하부구조","0","","","","t_131xd@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"CM기획","0","","","","t_349dy@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"터널","0","","","","t_068jk@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"CC","0","","","","t_116me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"공정관리","0","","","","t_628of@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
|
||||
"단가산출","0","","","","t_002sq@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
|
||||
"상하수도","0","","","","t_323pd@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"천지인","0","","","","t_859sx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"천지인셀","0","","","","t_827ax@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
|
||||
"용지도셀","0","","","","t_896yy@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
|
||||
"단지설계 개발","0","","","","t_602uo@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
|
||||
"인프라솔루션 개발","0","","","","t_566mk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"비탈면/구조물","0","","","","t_726dh@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
|
||||
"Way Draw","0","","","","t_504jn@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
|
||||
"Primal 평면","0","","","","t_284vk@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
|
||||
"Watch BIM","0","","","","t_170el@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
|
||||
"구조물S/W","0","","","","t_019ge@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"Strana","0","","","","t_595rj@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"그래픽스","0","","","","t_934zk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"Modeler","0","","","","t_932vs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
|
||||
"HmEG","0","","","","t_614xb@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
|
||||
"EG-BIM Draw","0","","","","t_563cv@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
|
||||
"Abut&시공통합관제","0","","","","t_762fs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
|
||||
"웹솔루션","0","","","","t_797wn@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"솔루션개발","0","","","","t_923oe@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
|
||||
"ERP","0","","","","t_481sa@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
|
||||
"웹디자인","0","","","","t_587ef@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
|
||||
"GSIM개발","0","","","","t_929kx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"bCMf","0","","","","t_833jy@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
|
||||
"GSIM","0","","","","t_263tv@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
|
||||
"PM","0","","","","t_335nb@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
|
||||
"수자원","0","","","","t_233cs@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"스마트건설","0","","","","t_842me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"시공BIM","0","","","","t_942jh@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
|
@@ -1,50 +0,0 @@
|
||||
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
|
||||
"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","",""
|
||||
"인재성장","2","","","","talent-growth@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"전산관리TF","4","","","","it-admin-tf@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"기술기획","8","","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"경영기획","0","","","","management-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"ERP기획","0","","","","erp-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"디자인기획","0","","","","design-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"협업증진","0","","","","collaboration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"솔루션통합","0","","","","solution-integration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"네이버웍스관리용","2","","","","su2@baroncs.co.kr","N","N","N","Y","","",""
|
||||
"기술개발센터","0","","","","tdc@baroncs.co.kr","Y","N","Y","Y","","",""
|
||||
"일반구조물 div","0","","","","structural-division@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"DfMA","0","","","","dfma@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"일반구조물","0","","","","structural-design@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"구조물계획","0","","","","structure-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"하부구조","0","","","","substructure@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"CM기획","0","","","","cm-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"터널","0","","","","tunnel@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"CC","0","","","","cost-control@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"공정관리","0","","","","schedule-control@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
|
||||
"단가산출","0","","","","cost-estimate@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
|
||||
"상하수도","0","","","","water-sewer@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"천지인","0","","","","cheonjijin@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"천지인셀","0","","","","cheonjijin-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
|
||||
"용지도셀","0","","","","land-map-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
|
||||
"단지설계 개발","0","","","","site-design-dev@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
|
||||
"인프라솔루션 개발","0","","","","infra-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"비탈면/구조물","0","","","","slope-structures@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
|
||||
"Way Draw","0","","","","way-draw@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
|
||||
"Primal 평면","0","","","","primal-plan@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
|
||||
"Watch BIM","0","","","","watch-bim@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
|
||||
"구조물S/W","0","","","","structural-software@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"Strana","0","","","","strana@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"그래픽스","0","","","","graphics@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"Modeler","0","","","","modeler@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
|
||||
"HmEG","0","","","","hmeg@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
|
||||
"EG-BIM Draw","0","","","","eg-bim-draw@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
|
||||
"Abut&시공통합관제","0","","","","abut-control@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
|
||||
"웹솔루션","0","","","","web-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"솔루션개발","0","","","","solution-dev@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
|
||||
"ERP","0","","","","erp@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
|
||||
"웹디자인","0","","","","web-design@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
|
||||
"GSIM개발","0","","","","gsim-dev@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"bCMf","0","","","","bcmf@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
|
||||
"GSIM","0","","","","gsim@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
|
||||
"PM","0","","","","project-management@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
|
||||
"수자원","0","","","","water-resources@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"스마트건설","0","","","","smart-construction@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"시공BIM","0","","","","construction-bim@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
|
@@ -1,474 +0,0 @@
|
||||
|
||||
> adminfront@0.0.0 i18n-scan
|
||||
> cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js
|
||||
|
||||
|
||||
ko.toml에 없는 키
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_history
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
|
||||
- ui.admin.users.list.table.msg.common.copied
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.email
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.history_title
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.password_done
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
|
||||
- ui.admin.users.list.table.ui.common.generate
|
||||
- ui.admin.users.list.table.ui.common.status.blocked
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
|
||||
- ui.admin.users.list.table.ui.dev.clients.registry.description
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.email
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
|
||||
- ui.admin.users.list.table.ui.dev.session.refresh
|
||||
- ui.admin.users.list.table.ui.dev.session.refreshing
|
||||
|
||||
en.toml에 없는 키
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_history
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
|
||||
- ui.admin.users.list.table.msg.common.copied
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.email
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.history_title
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.password_done
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
|
||||
- ui.admin.users.list.table.ui.common.generate
|
||||
- ui.admin.users.list.table.ui.common.status.blocked
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
|
||||
- ui.admin.users.list.table.ui.dev.clients.registry.description
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.email
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
|
||||
- ui.admin.users.list.table.ui.dev.session.refresh
|
||||
- ui.admin.users.list.table.ui.dev.session.refreshing
|
||||
|
||||
template.toml에 없는 코드 사용 키
|
||||
- msg.admin.users.detail.history_desc
|
||||
- msg.admin.users.detail.no_history
|
||||
- msg.admin.users.detail.no_tenants
|
||||
- msg.admin.users.detail.reset_auto_desc
|
||||
- msg.admin.users.detail.security_desc
|
||||
- msg.admin.users.detail.tenant_slug_help
|
||||
- msg.admin.users.detail.tenants_desc
|
||||
- msg.common.copied
|
||||
- msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- msg.dev.clients.general.public_key.cache_empty
|
||||
- msg.dev.clients.general.public_key.cache_help
|
||||
- msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- msg.dev.clients.general.public_key.cache_refreshed
|
||||
- msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- msg.dev.clients.general.public_key.cache_revoked
|
||||
- msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.detail.form.email
|
||||
- ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.detail.history_title
|
||||
- ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.detail.password_done
|
||||
- ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.detail.updated_at
|
||||
- ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.dev.clients.general.public_key.cache.error
|
||||
- ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.dev.clients.general.public_key.cache.status
|
||||
- ui.dev.clients.general.public_key.cache.title
|
||||
- ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.dev.clients.general.public_key.revoke_cache
|
||||
|
||||
코드에서 사용되지 않는 키
|
||||
- err.backend.authorization_pending
|
||||
- err.backend.bad_request
|
||||
- err.backend.conflict
|
||||
- err.backend.expired_token
|
||||
- err.backend.forbidden
|
||||
- err.backend.internal_error
|
||||
- err.backend.invalid_code
|
||||
- err.backend.invalid_or_expired_code
|
||||
- err.backend.invalid_session
|
||||
- err.backend.invalid_session_reference
|
||||
- err.backend.not_found
|
||||
- err.backend.not_supported
|
||||
- err.backend.password_or_email_mismatch
|
||||
- err.backend.rate_limited
|
||||
- err.backend.service_unavailable
|
||||
- err.backend.slow_down
|
||||
- msg.admin.groups.create.description
|
||||
- msg.admin.groups.create.title
|
||||
- msg.admin.groups.list.import_error
|
||||
- msg.admin.groups.list.import_success
|
||||
- msg.admin.header.subtitle
|
||||
- msg.admin.idp_env_prod
|
||||
- msg.admin.notice.idp_policy
|
||||
- msg.admin.notice.scope
|
||||
- msg.admin.overview.idp_fallback
|
||||
- msg.admin.overview.idp_primary
|
||||
- msg.admin.overview.playbook.description
|
||||
- msg.admin.overview.playbook.idp_body
|
||||
- msg.admin.overview.playbook.idp_title
|
||||
- msg.admin.overview.playbook.tenant_body
|
||||
- msg.admin.overview.playbook.tenant_title
|
||||
- msg.admin.overview.quick_links.description
|
||||
- msg.admin.overview.summary.audit_events_24h
|
||||
- msg.admin.overview.summary.oidc_clients
|
||||
- msg.admin.overview.summary.policy_gate
|
||||
- msg.admin.overview.summary.total_tenants
|
||||
- msg.admin.scope_admin
|
||||
- msg.admin.session_ttl
|
||||
- msg.admin.tenant_headers
|
||||
- msg.admin.users.create.form.login_id_help
|
||||
- msg.admin.users.detail.delete_error
|
||||
- msg.admin.users.detail.password_generated_help
|
||||
- msg.admin.users.detail.reset_password_confirm
|
||||
- msg.admin.users.detail.security.password_hint
|
||||
- msg.admin.users.detail.update_success
|
||||
- msg.common.copied_to_clipboard
|
||||
- msg.dev.audit.forbidden
|
||||
- msg.dev.clients.general.public_key.auth_method_client_secret_basic_help
|
||||
- msg.dev.clients.general.public_key.auth_method_none_help
|
||||
- msg.dev.clients.general.public_key.auth_method_private_key_jwt_help
|
||||
- msg.dev.clients.general.public_key.guide_example
|
||||
- msg.dev.clients.general.public_key.guide_intro
|
||||
- msg.dev.clients.general.public_key.guide_step_1
|
||||
- msg.dev.clients.general.public_key.guide_step_2
|
||||
- msg.dev.clients.general.public_key.guide_step_3
|
||||
- msg.dev.clients.general.public_key.jwks_inline_help
|
||||
- msg.dev.clients.general.public_key.request_object_alg_help
|
||||
- msg.dev.clients.general.public_key.source_help
|
||||
- msg.dev.clients.general.public_key.validation.headless_requires_alg
|
||||
- msg.dev.clients.general.public_key.validation.headless_requires_private_key_jwt
|
||||
- msg.dev.clients.general.public_key.validation.headless_requires_public_key
|
||||
- msg.dev.clients.general.public_key.validation.invalid_jwks_inline
|
||||
- msg.dev.clients.general.public_key.validation.missing_jwks_inline
|
||||
- msg.dev.clients.general.public_key.validation.private_key_jwt_requires_public_key
|
||||
- msg.userfront.signup.privacy_full
|
||||
- msg.userfront.signup.tos_full
|
||||
- non.existent.key
|
||||
- test.key
|
||||
- ui.admin.api_keys.list.breadcrumb.list
|
||||
- ui.admin.api_keys.list.breadcrumb.section
|
||||
- ui.admin.audit.breadcrumb.logs
|
||||
- ui.admin.audit.breadcrumb.section
|
||||
- ui.admin.groups.import_csv
|
||||
- ui.admin.overview.kicker
|
||||
- ui.admin.overview.playbook.title
|
||||
- ui.admin.overview.quick_links.add_tenant
|
||||
- ui.admin.overview.quick_links.api_key_management
|
||||
- ui.admin.overview.quick_links.user_management
|
||||
- ui.admin.overview.quick_links.view_audit_logs
|
||||
- ui.admin.tenants.breadcrumb.list
|
||||
- ui.admin.tenants.breadcrumb.section
|
||||
- ui.admin.tenants.create.breadcrumb.action
|
||||
- ui.admin.tenants.create.breadcrumb.section
|
||||
- ui.admin.tenants.detail.breadcrumb_list
|
||||
- ui.admin.tenants.detail.title
|
||||
- ui.admin.users.create.breadcrumb.new
|
||||
- ui.admin.users.create.breadcrumb.section
|
||||
- ui.admin.users.create.form.login_id
|
||||
- ui.admin.users.create.form.login_id_placeholder
|
||||
- ui.admin.users.detail.breadcrumb.section
|
||||
- ui.admin.users.detail.contact_title
|
||||
- ui.admin.users.detail.form.department_placeholder
|
||||
- ui.admin.users.detail.form.job_title_placeholder
|
||||
- ui.admin.users.detail.form.login_id
|
||||
- ui.admin.users.detail.form.login_id_placeholder
|
||||
- ui.admin.users.detail.form.name_placeholder
|
||||
- ui.admin.users.detail.form.phone_placeholder
|
||||
- ui.admin.users.detail.form.position_placeholder
|
||||
- ui.admin.users.detail.form.status_active
|
||||
- ui.admin.users.detail.form.status_inactive
|
||||
- ui.admin.users.detail.generate_password
|
||||
- ui.admin.users.detail.password_mode_generated
|
||||
- ui.admin.users.detail.password_mode_manual
|
||||
- ui.admin.users.detail.password_result_title
|
||||
- ui.admin.users.detail.reset_password_apply
|
||||
- ui.admin.users.detail.security.password
|
||||
- ui.admin.users.detail.security.password_placeholder
|
||||
- ui.admin.users.detail.security.title
|
||||
- ui.admin.users.detail.status_title
|
||||
- ui.admin.users.detail.tenants_section.additional
|
||||
- ui.admin.users.detail.tenants_section.primary
|
||||
- ui.admin.users.detail.tenants_section.title
|
||||
- ui.admin.users.detail.title
|
||||
- ui.admin.users.detail.toggle_password_visibility
|
||||
- ui.admin.users.list.breadcrumb.list
|
||||
- ui.admin.users.list.breadcrumb.section
|
||||
- ui.admin.users.list.empty
|
||||
- ui.admin.users.list.fetch_error
|
||||
- ui.admin.users.list.registry.count
|
||||
- ui.admin.users.list.subtitle
|
||||
- ui.admin.users.list.table.login_id
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_history
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
|
||||
- ui.admin.users.list.table.msg.common.copied
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.email
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.history_title
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.password_done
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
|
||||
- ui.admin.users.list.table.ui.common.generate
|
||||
- ui.admin.users.list.table.ui.common.status.blocked
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
|
||||
- ui.admin.users.list.table.ui.dev.clients.registry.description
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.email
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
|
||||
- ui.admin.users.list.table.ui.dev.session.refresh
|
||||
- ui.admin.users.list.table.ui.dev.session.refreshing
|
||||
- ui.common.generate
|
||||
- ui.common.status.blocked
|
||||
- ui.dev.clients.general.public_key.auth_method
|
||||
- ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.dev.clients.general.public_key.source
|
||||
- ui.dev.clients.general.public_key.source_uri
|
||||
- ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.dev.clients.help.docs_body
|
||||
- ui.dev.clients.help.subtitle
|
||||
- ui.dev.clients.registry.description
|
||||
- ui.dev.clients.scopes.email
|
||||
- ui.dev.clients.scopes.openid
|
||||
- ui.dev.clients.scopes.profile
|
||||
- ui.dev.session.refresh
|
||||
- ui.dev.session.refreshing
|
||||
|
||||
요약
|
||||
- [Sync Error] ko.toml 누락 키 84개
|
||||
- [Sync Error] en.toml 누락 키 84개
|
||||
- [Missing Key] template.toml 누락 키 59개
|
||||
2503
adminfront/package-lock.json
generated
2503
adminfront/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,52 +13,56 @@
|
||||
"lint:fix": "biome check . --write",
|
||||
"format": "biome format . --write",
|
||||
"preview": "vite preview",
|
||||
"test": "node ./node_modules/playwright/cli.js test",
|
||||
"test:unit": "vitest run",
|
||||
"test:ui": "node ./node_modules/playwright/cli.js test --ui",
|
||||
"test": "playwright test",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.1.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.66.8",
|
||||
"@tanstack/react-query-devtools": "^5.66.8",
|
||||
"axios": "^1.7.9",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"axios": "^1.16.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.24.1"
|
||||
"lucide-react": "^1.14.0",
|
||||
"oidc-client-ts": "^3.5.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-oidc-context": "^3.3.1",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"playwright": "1.60.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^4.0.18"
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const port = Number.parseInt(process.env.PORT ?? "5173", 10);
|
||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||
const reuseExistingServer = !process.env.CI && !process.env.PORT;
|
||||
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -56,7 +57,12 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
launchOptions: chromiumExecutablePath
|
||||
? { executablePath: chromiumExecutablePath }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
@@ -79,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,
|
||||
},
|
||||
});
|
||||
|
||||
3722
adminfront/pnpm-lock.yaml
generated
Normal file
3722
adminfront/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
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 |
@@ -1,44 +0,0 @@
|
||||
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
|
||||
"기술개발센터","1","","","","tdc@samaneng.com","N","N","N","Y","","",""
|
||||
"경영전략본부","0","","","","business-strategy@samaneng.com","Y","N","Y","Y","","",""
|
||||
"기획부","1","변역근(ykbyun@samaneng.com)","","","planning@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
|
||||
"업무팀","0","","","","t_226wn@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
|
||||
"PQ팀","0","","","","t_978bl@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
|
||||
"재무회계팀","0","","","","t_186qz@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
|
||||
"대외협력팀","0","","","","t_466et@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
|
||||
"인사총무부","0","","","","t_784bn@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
|
||||
"네이버웍스관리용","1","슈퍼관리자(su-@samaneng.com)","","","su1@samaneng.com","N","N","N","Y","","",""
|
||||
"자산경영실","0","","","","t_563wl@samaneng.com","Y","N","Y","Y","","",""
|
||||
"안전품질관리실","0","","","","t_793co@samaneng.com","Y","N","Y","Y","","",""
|
||||
"사업개발실","0","","","","t_468yk@samaneng.com","Y","N","Y","Y","","",""
|
||||
"CM본부","0","","","","t_838vr@samaneng.com","Y","N","Y","Y","","",""
|
||||
"CM사업부","0","","","","t_205ud@samaneng.com","Y","N","Y","Y","","","CM본부(t_838vr@samaneng.com)"
|
||||
"호남지역총괄본부","0","","","","t_143ep@samaneng.com","Y","N","Y","Y","","","CM사업부(t_205ud@samaneng.com)"
|
||||
"플랜트본부","0","","","","t_009bl@samaneng.com","Y","N","Y","Y","","",""
|
||||
"플랜트1부","0","","","","t_595bv@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
|
||||
"플랜트2부","0","","","","t_677ei@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
|
||||
"항만부","0","","","","t_446wi@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
|
||||
"국토개발본부","0","","","","t_405cl@samaneng.com","Y","N","Y","Y","","",""
|
||||
"도시계획부","0","","","","t_403or@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
|
||||
"도시개발부","0","","","","t_733kg@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
|
||||
"조경레저부","0","","","","t_931rr@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
|
||||
"도로본부","0","","","","t_402qv@samaneng.com","Y","N","Y","Y","","",""
|
||||
"도로부","0","","","","t_560mk@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
|
||||
"지반터널부","0","","","","t_918nd@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
|
||||
"교통계획부","0","","","","t_879qs@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
|
||||
"구조부","0","","","","t_772wv@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
|
||||
"안전진단팀","0","","","","t_875hr@samaneng.com","Y","N","Y","Y","","","구조부(t_772wv@samaneng.com)"
|
||||
"철도본부","0","","","","t_772tf@samaneng.com","Y","N","Y","Y","","",""
|
||||
"철도1부","0","","","","t_879yn@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
|
||||
"철도2부","0","","","","t_025sm@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
|
||||
"환경평가부","0","","","","t_974cd@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
|
||||
"물환경본부","0","","","","t_857zu@samaneng.com","Y","N","Y","Y","","",""
|
||||
"물환경1부","0","","","","t_881eq@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
|
||||
"물환경2부","0","","","","t_308je@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
|
||||
"물환경3부","0","","","","t_187qk@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
|
||||
"수자원본부","0","","","","t_415tw@samaneng.com","Y","N","Y","Y","","",""
|
||||
"수자원1부","0","","","","t_237op@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
|
||||
"수자원2부","0","","","","t_989os@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
|
||||
"수력부","0","","","","t_175zq@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
|
||||
"해외사업본부","0","","","","t_436jd@samaneng.com","Y","N","Y","Y","","",""
|
||||
"해외사업부","0","","","","t_099um@samaneng.com","Y","N","Y","Y","","","해외사업본부(t_436jd@samaneng.com)"
|
||||
|
@@ -1,44 +0,0 @@
|
||||
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
|
||||
"기술개발센터","1","","","","tech-dev-center@samaneng.com","N","N","N","Y","","",""
|
||||
"경영전략본부","0","","","","business-strategy@samaneng.com","Y","N","Y","Y","","",""
|
||||
"기획부","1","","","","planning@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
|
||||
"업무팀","0","","","","operations@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
|
||||
"PQ팀","0","","","","pq-team@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
|
||||
"재무회계팀","0","","","","finance@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
|
||||
"대외협력팀","0","","","","external-relations@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
|
||||
"인사총무부","0","","","","hr-admin@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
|
||||
"네이버웍스관리용","1","","","","nw-admin-saman@samaneng.com","N","N","N","Y","","",""
|
||||
"자산경영실","0","","","","asset-management@samaneng.com","Y","N","Y","Y","","",""
|
||||
"안전품질관리실","0","","","","safety-quality@samaneng.com","Y","N","Y","Y","","",""
|
||||
"사업개발실","0","","","","business-development@samaneng.com","Y","N","Y","Y","","",""
|
||||
"CM본부","0","","","","cm-headquarters@samaneng.com","Y","N","Y","Y","","",""
|
||||
"CM사업부","0","","","","cm-division@samaneng.com","Y","N","Y","Y","","","CM본부(cm-headquarters@samaneng.com)"
|
||||
"호남지역총괄본부","0","","","","honam-headquarters@samaneng.com","Y","N","Y","Y","","","CM사업부(cm-division@samaneng.com)"
|
||||
"플랜트본부","0","","","","plant-headquarters@samaneng.com","Y","N","Y","Y","","",""
|
||||
"플랜트1부","0","","","","plant-1@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
|
||||
"플랜트2부","0","","","","plant-2@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
|
||||
"항만부","0","","","","harbor@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
|
||||
"국토개발본부","0","","","","land-development@samaneng.com","Y","N","Y","Y","","",""
|
||||
"도시계획부","0","","","","urban-planning@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
|
||||
"도시개발부","0","","","","urban-development@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
|
||||
"조경레저부","0","","","","landscape-leisure@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
|
||||
"도로본부","0","","","","road-headquarters@samaneng.com","Y","N","Y","Y","","",""
|
||||
"도로부","0","","","","road@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
|
||||
"지반터널부","0","","","","geotech-tunnel@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
|
||||
"교통계획부","0","","","","transport-planning@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
|
||||
"구조부","0","","","","structures@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
|
||||
"안전진단팀","0","","","","safety-inspection@samaneng.com","Y","N","Y","Y","","","구조부(structures@samaneng.com)"
|
||||
"철도본부","0","","","","railway-headquarters@samaneng.com","Y","N","Y","Y","","",""
|
||||
"철도1부","0","","","","railway-1@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
|
||||
"철도2부","0","","","","railway-2@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
|
||||
"환경평가부","0","","","","environment-assessment@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
|
||||
"물환경본부","0","","","","water-environment-hq@samaneng.com","Y","N","Y","Y","","",""
|
||||
"물환경1부","0","","","","water-environment-1@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
|
||||
"물환경2부","0","","","","water-environment-2@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
|
||||
"물환경3부","0","","","","water-environment-3@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
|
||||
"수자원본부","0","","","","water-resources-hq@samaneng.com","Y","N","Y","Y","","",""
|
||||
"수자원1부","0","","","","water-resources-1@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
|
||||
"수자원2부","0","","","","water-resources-2@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
|
||||
"수력부","0","","","","hydropower@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
|
||||
"해외사업본부","0","","","","overseas-headquarters@samaneng.com","Y","N","Y","Y","","",""
|
||||
"해외사업부","0","","","","overseas-business@samaneng.com","Y","N","Y","Y","","","해외사업본부(overseas-headquarters@samaneng.com)"
|
||||
|
@@ -36,31 +36,105 @@ if [ "${1:-}" = "--print-mode" ]; then
|
||||
fi
|
||||
|
||||
ensure_frontend_dependencies() {
|
||||
if [ ! -f package.json ] || [ ! -f package-lock.json ]; then
|
||||
APP_PACKAGE_NAME="adminfront"
|
||||
|
||||
# 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
|
||||
return 0
|
||||
fi
|
||||
|
||||
lock_mode=""
|
||||
lock_file="$WORKSPACE_DIR/.baron-deps-install.lock"
|
||||
|
||||
acquire_install_lock() {
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
lock_mode="flock"
|
||||
exec 9>"$lock_file"
|
||||
flock 9
|
||||
trap 'release_install_lock' EXIT INT TERM
|
||||
return 0
|
||||
fi
|
||||
|
||||
lock_mode="mkdir"
|
||||
while ! mkdir "$lock_file" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
trap 'release_install_lock' EXIT INT TERM
|
||||
}
|
||||
|
||||
release_install_lock() {
|
||||
trap - EXIT INT TERM
|
||||
|
||||
if [ "$lock_mode" = "flock" ]; then
|
||||
flock -u 9 || true
|
||||
exec 9>&-
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$lock_mode" = "mkdir" ]; then
|
||||
rmdir "$lock_file" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum package.json package-lock.json | 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 package.json package-lock.json | 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)"
|
||||
|
||||
if [ "$installed_hash" != "$deps_hash" ]; then
|
||||
echo "Installing frontend dependencies from package-lock.json..."
|
||||
npm ci
|
||||
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" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
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
|
||||
|
||||
eval "$INSTALL_CMD"
|
||||
|
||||
mkdir -p node_modules
|
||||
printf '%s\n' "$deps_hash" > "$deps_stamp"
|
||||
release_install_lock
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_frontend_dependencies
|
||||
|
||||
if [ "$mode" = "production" ]; then
|
||||
echo "Running in production mode with Vite preview..."
|
||||
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
|
||||
echo "Running in production mode with custom static server..."
|
||||
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
|
||||
|
||||
echo "Running in development mode..."
|
||||
|
||||
160
adminfront/scripts/serve-prod.mjs
Normal file
160
adminfront/scripts/serve-prod.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
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 distDir = resolve(
|
||||
process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist",
|
||||
);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const port = Number(process.env.PORT ?? process.env.ADMINFRONT_PORT ?? 5173);
|
||||
const backendTarget = new URL(
|
||||
process.env.API_PROXY_TARGET || "http://localhost:3000",
|
||||
);
|
||||
|
||||
const contentTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
function getContentType(filePath) {
|
||||
return (
|
||||
contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, body) {
|
||||
res.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function toSafePath(pathname) {
|
||||
const decoded = decodeURIComponent(pathname);
|
||||
const relative = decoded.replace(/^\/+/, "");
|
||||
const safe = normalize(relative).replace(/^(\.\.(?:[\\/]|$))+/, "");
|
||||
return join(distDir, safe);
|
||||
}
|
||||
|
||||
async function tryReadFile(filePath) {
|
||||
try {
|
||||
return await readFile(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyToBackend(req, res, pathname, search) {
|
||||
const target = new URL(pathname + search, backendTarget);
|
||||
const headers = new Headers();
|
||||
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (!value) continue;
|
||||
if (key === "host" || key === "content-length" || key === "connection") {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
headers.set(key, value.join(", "));
|
||||
continue;
|
||||
}
|
||||
headers.set(key, value);
|
||||
}
|
||||
|
||||
const hasBody = !["GET", "HEAD"].includes(req.method ?? "GET");
|
||||
const response = await fetch(target, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? req : undefined,
|
||||
duplex: hasBody ? "half" : undefined,
|
||||
});
|
||||
|
||||
const responseHeaders = new Headers(response.headers);
|
||||
responseHeaders.delete("content-length");
|
||||
responseHeaders.delete("transfer-encoding");
|
||||
responseHeaders.delete("connection");
|
||||
|
||||
res.writeHead(response.status, Object.fromEntries(responseHeaders.entries()));
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
res.end(Buffer.from(arrayBuffer));
|
||||
}
|
||||
|
||||
async function serveStatic(req, res, pathname) {
|
||||
const indexPath = join(distDir, "index.html");
|
||||
const filePath = toSafePath(pathname);
|
||||
|
||||
let resolvedPath = filePath;
|
||||
try {
|
||||
const fileStat = await stat(resolvedPath);
|
||||
if (fileStat.isDirectory()) {
|
||||
resolvedPath = join(resolvedPath, "index.html");
|
||||
}
|
||||
} catch {
|
||||
resolvedPath = indexPath;
|
||||
}
|
||||
|
||||
let body = await tryReadFile(resolvedPath);
|
||||
if (!body) {
|
||||
body = await tryReadFile(indexPath);
|
||||
resolvedPath = indexPath;
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
sendJson(res, 500, { error: "dist_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": getContentType(resolvedPath),
|
||||
"Cache-Control": resolvedPath.endsWith("index.html")
|
||||
? "no-cache"
|
||||
: "public, max-age=31536000, immutable",
|
||||
});
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(
|
||||
req.url ?? "/",
|
||||
`http://${req.headers.host ?? "localhost"}`,
|
||||
);
|
||||
const { pathname, search } = url;
|
||||
|
||||
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
||||
await proxyToBackend(req, res, pathname, search);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPath = pathname === "/" ? "/index.html" : pathname;
|
||||
await serveStatic(req, res, normalizedPath);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: "internal_server_error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}).listen(port, host, () => {
|
||||
console.log(
|
||||
`Adminfront production server listening on http://${host}:${port}`,
|
||||
);
|
||||
});
|
||||
@@ -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,11 +1,7 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
defaultOptions: queryClientDefaultOptions,
|
||||
});
|
||||
|
||||
@@ -21,4 +21,32 @@ describe("admin routes", () => {
|
||||
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
|
||||
});
|
||||
|
||||
it("registers the super-admin data integrity management route", () => {
|
||||
const matches = matchRoutes(adminRoutes, "/system/data-integrity");
|
||||
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
|
||||
});
|
||||
|
||||
it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
|
||||
const rootRoute = adminRoutes.find((route) => route.path === "/");
|
||||
const protectedShellRoute = rootRoute?.children?.[0];
|
||||
|
||||
expect(getRouteElementName(rootRoute?.element)).toBe("AuthGuard");
|
||||
expect(getRouteElementName(protectedShellRoute?.element)).toBe("AppLayout");
|
||||
expect(protectedShellRoute?.children?.at(0)?.index).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function getRouteElementName(element: unknown) {
|
||||
if (
|
||||
typeof element === "object" &&
|
||||
element !== null &&
|
||||
"type" in element &&
|
||||
typeof element.type === "function"
|
||||
) {
|
||||
return element.type.name;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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";
|
||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
import AuthGuard from "../features/auth/AuthGuard";
|
||||
import AuthPage from "../features/auth/AuthPage";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
|
||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import UserProjectionPage from "../features/projections/UserProjectionPage";
|
||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||
@@ -17,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";
|
||||
@@ -34,34 +35,40 @@ export const adminRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AppLayout />,
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ index: true, element: <GlobalOverviewPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "auth", element: <AuthPage /> },
|
||||
{ path: "users", element: <UserListPage /> },
|
||||
{ path: "users/new", element: <UserCreatePage /> },
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <TenantProfilePage /> },
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
{ index: true, element: <GlobalOverviewPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "auth", element: <AuthPage /> },
|
||||
{ path: "users", element: <UserListPage /> },
|
||||
{ path: "users/new", element: <UserCreatePage /> },
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
children: [
|
||||
{ index: true, element: <TenantProfilePage /> },
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId/organization/:id",
|
||||
element: <TenantUserGroupsTab />,
|
||||
},
|
||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||
{ path: "system/projections/users", element: <UserProjectionPage /> },
|
||||
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId/organization/:id",
|
||||
element: <TenantUserGroupsTab />,
|
||||
},
|
||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||
{ path: "system/projections/users", element: <UserProjectionPage /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type * as React from "react";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { normalizeAdminRole } from "../../lib/roles";
|
||||
|
||||
interface RoleGuardProps {
|
||||
children: React.ReactNode;
|
||||
@@ -29,8 +30,10 @@ export function RoleGuard({
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
const userRole = profile?.role || "user";
|
||||
const hasAccess = roles.includes(userRole);
|
||||
const userRole = normalizeAdminRole(profile?.role);
|
||||
const hasAccess = roles
|
||||
.map((role) => normalizeAdminRole(role))
|
||||
.includes(userRole);
|
||||
|
||||
if (!hasAccess) {
|
||||
return <>{fallback}</>;
|
||||
|
||||
32
adminfront/src/components/common/LanguageSelector.test.tsx
Normal file
32
adminfront/src/components/common/LanguageSelector.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? "",
|
||||
}));
|
||||
|
||||
describe("LanguageSelector", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("updates locale without reloading the page", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
|
||||
render(<LanguageSelector />);
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), {
|
||||
target: { value: "en" },
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem("locale")).toBe("en");
|
||||
expect(
|
||||
dispatchSpy.mock.calls.some(
|
||||
([event]) => event instanceof Event && event.type === "localechange",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const LOCALE_STORAGE_KEY = "locale";
|
||||
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||
|
||||
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
@@ -28,13 +28,27 @@ function resolveLocale(): Locale {
|
||||
function LanguageSelector() {
|
||||
const [locale, setLocale] = useState<Locale>(resolveLocale());
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = () => {
|
||||
setLocale(resolveLocale());
|
||||
};
|
||||
|
||||
window.addEventListener("localechange", syncLocale);
|
||||
window.addEventListener("storage", syncLocale);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("localechange", syncLocale);
|
||||
window.removeEventListener("storage", syncLocale);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (next: Locale) => {
|
||||
if (next === locale) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||
setLocale(next);
|
||||
window.location.reload();
|
||||
window.dispatchEvent(new Event("localechange"));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import LocaleRefreshBoundary from "./LocaleRefreshBoundary";
|
||||
|
||||
let renderCount = 0;
|
||||
|
||||
function RenderCounter() {
|
||||
renderCount += 1;
|
||||
return <span>{renderCount}</span>;
|
||||
}
|
||||
|
||||
describe("LocaleRefreshBoundary", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
renderCount = 0;
|
||||
});
|
||||
|
||||
it("re-renders children when locale changes", async () => {
|
||||
render(
|
||||
<LocaleRefreshBoundary>
|
||||
<RenderCounter />
|
||||
</LocaleRefreshBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
window.dispatchEvent(new Event("localechange"));
|
||||
});
|
||||
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
27
adminfront/src/components/common/LocaleRefreshBoundary.tsx
Normal file
27
adminfront/src/components/common/LocaleRefreshBoundary.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Fragment, type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
type LocaleRefreshBoundaryProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) {
|
||||
const [localeVersion, setLocaleVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = () => {
|
||||
setLocaleVersion((current) => current + 1);
|
||||
};
|
||||
|
||||
window.addEventListener("localechange", syncLocale);
|
||||
window.addEventListener("storage", syncLocale);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("localechange", syncLocale);
|
||||
window.removeEventListener("storage", syncLocale);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Fragment key={localeVersion}>{children}</Fragment>;
|
||||
}
|
||||
|
||||
export default LocaleRefreshBoundary;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Moon,
|
||||
Network,
|
||||
NotebookTabs,
|
||||
ShieldCheck,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
User as UserIcon,
|
||||
@@ -19,30 +20,139 @@ import * as React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AppSidebar,
|
||||
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";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
} from "../../lib/sessionSliding";
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import RoleSwitcher from "./RoleSwitcher";
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
to: string;
|
||||
icon: React.ComponentType<{ size?: number | string }>;
|
||||
isExternal?: boolean;
|
||||
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
|
||||
|
||||
const staticNavItems: ShellSidebarNavItem[] = [
|
||||
{
|
||||
labelKey: "ui.admin.nav.overview",
|
||||
labelFallback: "Overview",
|
||||
to: "/",
|
||||
icon: LayoutDashboard,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.users",
|
||||
labelFallback: "Users",
|
||||
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",
|
||||
to: "/api-keys",
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.audit_logs",
|
||||
labelFallback: "Audit Logs",
|
||||
to: "/audit-logs",
|
||||
icon: NotebookTabs,
|
||||
},
|
||||
];
|
||||
|
||||
type SessionStatusProps = {
|
||||
expiresAtSec?: number | null;
|
||||
t: ShellTranslator;
|
||||
};
|
||||
|
||||
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
|
||||
}
|
||||
|
||||
const staticNavItems: NavItem[] = [
|
||||
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
||||
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
||||
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
||||
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
||||
];
|
||||
function SessionStatusBadge(props: SessionStatusProps) {
|
||||
const sessionStatus = useSessionStatus(props);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
shellLayoutClasses.sessionBadge,
|
||||
sessionStatus.toneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionStatus.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionStatusText(props: SessionStatusProps) {
|
||||
const sessionStatus = useSessionStatus(props);
|
||||
|
||||
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();
|
||||
@@ -52,47 +162,19 @@ function AppLayout() {
|
||||
const isRenewInFlightRef = useRef(false);
|
||||
const lastRenewAttemptAtRef = useRef(0);
|
||||
const lastVisitedRouteRef = useRef<string | null>(null);
|
||||
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">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
|
||||
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
|
||||
return stored !== "false";
|
||||
});
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: profile,
|
||||
isLoading: isProfileLoading,
|
||||
error: profileError,
|
||||
} = useQuery({
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||
);
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: async () => {
|
||||
console.debug("[AppLayout] Fetching profile...");
|
||||
debugLog("[AppLayout] Fetching profile...");
|
||||
try {
|
||||
const data = await fetchMe();
|
||||
console.debug("[AppLayout] Profile fetched successfully:", data.email);
|
||||
debugLog("[AppLayout] Profile fetched successfully:", data.email);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("[AppLayout] Failed to fetch profile:", err);
|
||||
@@ -106,79 +188,84 @@ function AppLayout() {
|
||||
._IS_TEST_MODE === true,
|
||||
});
|
||||
|
||||
const navItems = React.useMemo(() => {
|
||||
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 isSuperAdmin = isTest || effectiveRole === "super_admin";
|
||||
const isTenantAdmin = effectiveRole === "tenant_admin";
|
||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||
);
|
||||
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 (isTest) return true;
|
||||
if (item.to === "/api-keys") return isSuperAdmin;
|
||||
return true;
|
||||
});
|
||||
|
||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||
{ includeInternal: true },
|
||||
);
|
||||
|
||||
if (isSuperAdmin) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.tenants",
|
||||
labelKey: "ui.admin.nav.tenants",
|
||||
labelFallback: "Tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
filteredItems.splice(2, 0, {
|
||||
label: "ui.admin.nav.org_chart",
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
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, {
|
||||
label: "ui.admin.nav.user_projection",
|
||||
labelKey: "ui.admin.nav.user_projection",
|
||||
labelFallback: "User Projection",
|
||||
to: "/system/projections/users",
|
||||
icon: Database,
|
||||
});
|
||||
} else if (isTenantAdmin || manageableCount > 0) {
|
||||
if (manageableCount <= 1 && profile?.tenantId) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.my_tenant",
|
||||
to: `/tenants/${profile.tenantId}`,
|
||||
icon: Building2,
|
||||
});
|
||||
} else if (manageableCount > 1) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
}
|
||||
filteredItems.splice(
|
||||
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
|
||||
0,
|
||||
{
|
||||
label: "ui.admin.nav.org_chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
},
|
||||
);
|
||||
filteredItems.splice(5, 0, {
|
||||
labelKey: "ui.admin.nav.data_integrity",
|
||||
labelFallback: "Data Integrity",
|
||||
to: "/system/data-integrity",
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
} else {
|
||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
||||
// Non-superadmins
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.org_chart",
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
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 (
|
||||
@@ -195,7 +282,7 @@ function AppLayout() {
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
|
||||
console.debug("[AppLayout] Auth state check:", {
|
||||
debugLog("[AppLayout] Auth state check:", {
|
||||
isLoading: auth.isLoading,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isTest,
|
||||
@@ -214,16 +301,28 @@ function AppLayout() {
|
||||
}, [auth.user]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
if (theme === "light") {
|
||||
root.classList.add("light");
|
||||
} else {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
window.localStorage.setItem("admin_theme", theme);
|
||||
applyShellTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDevelopmentRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rerenderDevelopmentShell = () => {
|
||||
// Re-render when locale changes
|
||||
};
|
||||
|
||||
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
LOCALE_CHANGED_EVENT,
|
||||
rerenderDevelopmentShell,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
@@ -289,6 +388,10 @@ function AppLayout() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDevelopmentRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeKeepSessionAlive = async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
@@ -388,71 +491,83 @@ function AppLayout() {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
const profileName =
|
||||
profile?.name?.trim() ||
|
||||
auth.user?.profile.name?.toString().trim() ||
|
||||
auth.user?.profile.preferred_username?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_name", "Unknown User");
|
||||
const profileEmail =
|
||||
profile?.email?.trim() ||
|
||||
auth.user?.profile.email?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_email", "unknown@example.com");
|
||||
const profileInitial = profileName.charAt(0).toUpperCase();
|
||||
const profileRoleKey = mockRoleOverride || profile?.role || "user";
|
||||
const expiresAtSec = auth.user?.expires_at;
|
||||
const remainingMs =
|
||||
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
|
||||
const remainingTotalSec =
|
||||
remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null;
|
||||
const remainingMinutes =
|
||||
remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null;
|
||||
const remainingSeconds =
|
||||
remainingTotalSec !== null ? remainingTotalSec % 60 : null;
|
||||
|
||||
let sessionToneClass =
|
||||
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
let sessionText = t("ui.dev.session.active", "세션 활성");
|
||||
|
||||
if (remainingMs === null) {
|
||||
sessionToneClass = "border-border bg-card text-muted-foreground";
|
||||
sessionText = t("ui.dev.session.unknown", "알 수 없음");
|
||||
} else if (remainingMs <= 0) {
|
||||
sessionToneClass =
|
||||
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
|
||||
sessionText = t("ui.dev.session.expired", "세션 만료");
|
||||
} else if (
|
||||
remainingMinutes !== null &&
|
||||
remainingSeconds !== null &&
|
||||
remainingMinutes <= 5
|
||||
) {
|
||||
sessionToneClass =
|
||||
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
sessionText = t(
|
||||
"ui.dev.session.expiring",
|
||||
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes,
|
||||
seconds: remainingSeconds,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
sessionText = t(
|
||||
"ui.dev.session.remaining",
|
||||
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes ?? 0,
|
||||
seconds: remainingSeconds ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const profileSummary = buildShellProfileSummary({
|
||||
profileName:
|
||||
profile?.name ||
|
||||
auth.user?.profile.name?.toString() ||
|
||||
auth.user?.profile.preferred_username?.toString(),
|
||||
profileEmail: profile?.email || auth.user?.profile.email?.toString(),
|
||||
fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
|
||||
fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
|
||||
});
|
||||
const profileRoleKey = profile?.role || "user";
|
||||
const handleSessionExpiryToggle = () => {
|
||||
setIsSessionExpiryEnabled((prev) => {
|
||||
const next = !prev;
|
||||
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
|
||||
writeShellSessionExpiryEnabled(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const sidebarNavContent = (
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map((item) => {
|
||||
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={to}
|
||||
href={to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={[
|
||||
shellLayoutClasses.navItemBase,
|
||||
shellLayoutClasses.navItemIdle,
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={item.end ?? to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
item.isActive !== undefined
|
||||
? item.isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle
|
||||
: isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const sidebarFooterContent = (
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
@@ -463,87 +578,19 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t("ui.admin.brand", "Baron 로그인")}
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("ui.admin.title", "Admin Control")}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
{navItems.map((item: NavItem) => {
|
||||
const { label, to, icon: Icon, isExternal } = item;
|
||||
const isOrgChart = location.pathname === "/tenants/org-chart";
|
||||
const isTenantsRoot = to === "/tenants";
|
||||
const isCustomActive = isTenantsRoot
|
||||
? location.pathname.startsWith("/tenants") && !isOrgChart
|
||||
: to === "/"
|
||||
? location.pathname === "/"
|
||||
: location.pathname.startsWith(to);
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<AppSidebar
|
||||
brandLabel={t("ui.admin.brand", "Baron 로그인")}
|
||||
brandTitle={t("ui.admin.title", "Admin Control")}
|
||||
brandIcon={<ShieldHalf size={20} />}
|
||||
navContent={sidebarNavContent}
|
||||
footerContent={sidebarFooterContent}
|
||||
/>
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={to}
|
||||
href={to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(label, label)}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={() =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
isCustomActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(label, label)}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="relative min-w-0">
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={shellLayoutClasses.contentWide}>
|
||||
<header className={shellLayoutClasses.headerElevated}>
|
||||
<div className={shellLayoutClasses.headerInner}>
|
||||
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{t("ui.admin.header.plane", "ADMIN PLANE")}
|
||||
</p>
|
||||
@@ -552,12 +599,12 @@ function AppLayout() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={shellLayoutClasses.headerActions}>
|
||||
<LanguageSelector />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||
className={shellLayoutClasses.actionButton}
|
||||
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||
>
|
||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
@@ -566,14 +613,10 @@ function AppLayout() {
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
{isSessionExpiryEnabled ? (
|
||||
<span
|
||||
className={[
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
sessionToneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionText}
|
||||
</span>
|
||||
<SessionStatusBadge
|
||||
expiresAtSec={auth.user?.expires_at}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
<button
|
||||
@@ -582,17 +625,17 @@ function AppLayout() {
|
||||
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isProfileOpen}
|
||||
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
|
||||
aria-label={t("ui.shell.profile.menu_aria", "계정 메뉴 열기")}
|
||||
>
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||
{profileInitial}
|
||||
<div className={shellLayoutClasses.profileInitial}>
|
||||
{profileSummary.initial}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left md:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
@@ -602,45 +645,50 @@ function AppLayout() {
|
||||
</button>
|
||||
|
||||
{isProfileOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
||||
>
|
||||
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.dev.profile.menu_title", "Account")}
|
||||
{t("ui.shell.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.profileCard}>
|
||||
<div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-1">
|
||||
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
|
||||
{t(
|
||||
`ui.admin.role.${profileRoleKey}`,
|
||||
`ui.shell.role.${profileRoleKey}`,
|
||||
profileRoleKey.toUpperCase(),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.settingsCard}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
||||
{t(
|
||||
"ui.shell.session.auto_extend",
|
||||
"세션 만료 관리",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled
|
||||
? sessionText
|
||||
: t(
|
||||
"ui.dev.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
)}
|
||||
{isSessionExpiryEnabled ? (
|
||||
<SessionStatusText
|
||||
expiresAtSec={auth.user?.expires_at}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
t(
|
||||
"ui.shell.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -717,7 +765,7 @@ function AppLayout() {
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
|
||||
>
|
||||
<UserIcon size={16} className="text-muted-foreground" />
|
||||
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
|
||||
<span>{t("ui.shell.nav.profile", "내 정보")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -728,7 +776,7 @@ function AppLayout() {
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -736,10 +784,9 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10">
|
||||
<main className={shellLayoutClasses.mainMinWidth}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<RoleSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,175 +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 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.location.reload();
|
||||
};
|
||||
|
||||
const clearRoleOverride = () => {
|
||||
window.localStorage.removeItem("X-Mock-Role-Enabled");
|
||||
setIsOverrideEnabled(false);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
@@ -1,38 +1,21 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
import {
|
||||
type CommonBadgeVariant,
|
||||
getCommonBadgeClasses,
|
||||
} from "../../../../common/ui/badge";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full 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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "text-foreground",
|
||||
muted: "border-border bg-secondary/60 text-muted-foreground",
|
||||
success:
|
||||
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
warning:
|
||||
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CommonBadgeVariant;
|
||||
}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
<div
|
||||
className={cn(getCommonBadgeClasses({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export { Badge };
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type CommonButtonSize,
|
||||
type CommonButtonVariant,
|
||||
getCommonButtonClasses,
|
||||
} from "../../../../common/ui/button";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-6 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: CommonButtonVariant;
|
||||
size?: CommonButtonSize;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button };
|
||||
|
||||
@@ -1,72 +1,58 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
commonCardClass,
|
||||
commonCardContentClass,
|
||||
commonCardDescriptionClass,
|
||||
commonCardFooterClass,
|
||||
commonCardHeaderClass,
|
||||
commonCardTitleClass,
|
||||
} from "../../../../common/ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
};
|
||||
|
||||
23
adminfront/src/components/ui/dialog.focus-scope.test.tsx
Normal file
23
adminfront/src/components/ui/dialog.focus-scope.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "./dialog";
|
||||
|
||||
describe("Dialog FocusScope integration", () => {
|
||||
it("mounts an open dialog without a ref update loop", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Focus scope check</DialogTitle>
|
||||
<DialogDescription>Dialog content is mounted.</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Focus scope check")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,222 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
type DialogContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogContext = React.createContext<DialogContextValue | null>(null);
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
function useDialogContext(componentName: string) {
|
||||
const context = React.useContext(DialogContext);
|
||||
if (!context) {
|
||||
throw new Error(`${componentName} must be used within Dialog`);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
function composeEventHandlers<E extends React.SyntheticEvent>(
|
||||
theirs: ((event: E) => void) | undefined,
|
||||
ours: (event: E) => void,
|
||||
) {
|
||||
return (event: E) => {
|
||||
theirs?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
ours(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type DialogProps = {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function Dialog({
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: DialogProps) {
|
||||
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
||||
const isControlled = open !== undefined;
|
||||
const currentOpen = isControlled ? open : internalOpen;
|
||||
const setOpen = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[isControlled, onOpenChange],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ open: currentOpen, setOpen }),
|
||||
[currentOpen, setOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={value}>{children}</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
({ asChild = false, children, onClick, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogTrigger");
|
||||
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const child = children as React.ReactElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}>;
|
||||
return React.cloneElement(child, {
|
||||
...props,
|
||||
onClick: composeEventHandlers(
|
||||
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
|
||||
() => setOpen(true),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" ref={ref} onClick={handleOpen} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogTrigger.displayName = "DialogTrigger";
|
||||
|
||||
const DialogPortal = ({ children }: { children?: React.ReactNode }) => {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return createPortal(children, document.body);
|
||||
};
|
||||
DialogPortal.displayName = "DialogPortal";
|
||||
|
||||
const DialogClose = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
({ asChild = false, children, onClick, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogClose");
|
||||
const handleClose = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const child = children as React.ReactElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}>;
|
||||
return React.cloneElement(child, {
|
||||
...props,
|
||||
onClick: composeEventHandlers(
|
||||
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
|
||||
() => setOpen(false),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" ref={ref} onClick={handleClose} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogClose.displayName = "DialogClose";
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
>(({ className, onMouseDown, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogOverlay");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"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);
|
||||
}
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
/>
|
||||
);
|
||||
});
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
HTMLDialogElement,
|
||||
React.HTMLAttributes<HTMLDialogElement>
|
||||
>(({ className, children, onKeyDown, ...props }, ref) => {
|
||||
const { open, setOpen } = useDialogContext("DialogContent");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const onDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onDocumentKeyDown);
|
||||
return () => document.removeEventListener("keydown", onDocumentKeyDown);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<dialog
|
||||
ref={ref}
|
||||
open
|
||||
aria-modal="true"
|
||||
data-state="open"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 m-0 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 backdrop:bg-transparent data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
onKeyDown={onKeyDown}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</dialog>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
@@ -80,10 +247,10 @@ const DialogFooter = ({
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
@@ -92,29 +259,29 @@ const DialogTitle = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
DialogTitle.displayName = "DialogTitle";
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { commonInputClass } from "../../../../common/ui/input";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
@@ -9,10 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
className={cn(commonInputClass, className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,68 @@
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||
interface SwitchProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
onClick,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isControlled = checked !== undefined;
|
||||
const [internalChecked, setInternalChecked] =
|
||||
React.useState(defaultChecked);
|
||||
const currentChecked = isControlled ? checked : internalChecked;
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (event.defaultPrevented || disabled) {
|
||||
return;
|
||||
}
|
||||
const nextChecked = !currentChecked;
|
||||
if (!isControlled) {
|
||||
setInternalChecked(nextChecked);
|
||||
}
|
||||
onCheckedChange?.(nextChecked);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={currentChecked}
|
||||
data-state={currentChecked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-state={currentChecked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Switch.displayName = "Switch";
|
||||
|
||||
export { Switch };
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
commonTableBodyClass,
|
||||
commonTableCaptionClass,
|
||||
commonTableCellClass,
|
||||
commonTableClass,
|
||||
commonTableFooterClass,
|
||||
commonTableHeadClass,
|
||||
commonTableHeaderClass,
|
||||
commonTableRowClass,
|
||||
commonTableWrapperClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div className={commonTableWrapperClass}>
|
||||
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
@@ -19,7 +26,11 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(commonTableHeaderClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
@@ -27,11 +38,7 @@ const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn("bg-muted/50 font-medium text-foreground", className)}
|
||||
className={cn(commonTableFooterClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -51,14 +58,7 @@ const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
@@ -66,14 +66,7 @@ const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-foreground align-middle sticky top-0 bg-inherit",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
@@ -81,11 +74,7 @@ const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-6 align-middle text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
className={cn(commonTableCaptionClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -103,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 };
|
||||
|
||||
72
adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx
Normal file
72
adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } 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 { createApiKey } from "../../lib/adminApi";
|
||||
import ApiKeyCreatePage from "./ApiKeyCreatePage";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
createApiKey: vi.fn(async () => ({
|
||||
apiKey: {
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id",
|
||||
scopes: ["audit:read", "user:read", "org-context:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
},
|
||||
clientSecret: "secret",
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<ApiKeyCreatePage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiKeyCreatePage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders org-context:read as a selectable API key scope", () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText("조직 Context 조회")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: org-context:read")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes org-context:read in the create request when selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText("서비스 또는 목적 식별 이름"),
|
||||
"org-context-client",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
|
||||
await user.click(screen.getByRole("button", { name: /API 키 발급하기/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createApiKey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "org-context-client",
|
||||
scopes: expect.arrayContaining(["org-context:read"]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,51 +28,7 @@ import {
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const AVAILABLE_SCOPES = [
|
||||
{
|
||||
id: "audit:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
|
||||
labelFallback: "감사 로그 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
|
||||
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "audit:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
|
||||
labelFallback: "감사 로그 생성",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
|
||||
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
|
||||
},
|
||||
{
|
||||
id: "user:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_read.title",
|
||||
labelFallback: "사용자 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.user_read.desc",
|
||||
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "user:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_write.title",
|
||||
labelFallback: "사용자 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.user_write.desc",
|
||||
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
|
||||
labelFallback: "테넌트 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
|
||||
descFallback: "등록된 모든 조직 정보를 조회합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
|
||||
labelFallback: "테넌트 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
|
||||
descFallback: "테넌트 정보를 직접 제어합니다.",
|
||||
},
|
||||
];
|
||||
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||
|
||||
function ApiKeyCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -298,7 +254,7 @@ function ApiKeyCreatePage() {
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{AVAILABLE_SCOPES.map((scope) => {
|
||||
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
|
||||
const isSelected = selectedScopes.includes(scope.id);
|
||||
return (
|
||||
<button
|
||||
|
||||
125
adminfront/src/features/api-keys/ApiKeyListPage.test.tsx
Normal file
125
adminfront/src/features/api-keys/ApiKeyListPage.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } 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 {
|
||||
fetchApiKeys,
|
||||
rotateApiKeySecret,
|
||||
updateApiKeyScopes,
|
||||
} from "../../lib/adminApi";
|
||||
import ApiKeyListPage from "./ApiKeyListPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? "",
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchApiKeys: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id-stable",
|
||||
scopes: ["audit:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
deleteApiKey: vi.fn(async () => undefined),
|
||||
updateApiKeyScopes: vi.fn(async () => ({
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id-stable",
|
||||
scopes: ["audit:read", "org-context:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
})),
|
||||
rotateApiKeySecret: vi.fn(async () => ({
|
||||
apiKey: {
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id-stable",
|
||||
scopes: ["audit:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
},
|
||||
clientSecret: "rotated-secret",
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<ApiKeyListPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiKeyListPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("updates scopes without changing client_id", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /권한 수정/ }));
|
||||
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
|
||||
await user.click(screen.getByRole("button", { name: /권한 저장/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateApiKeyScopes).toHaveBeenCalledWith("api-key-id", {
|
||||
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rotates only the secret and shows the one-time secret", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Secret 재발급/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rotateApiKeySecret).toHaveBeenCalledWith("api-key-id");
|
||||
});
|
||||
expect(
|
||||
await screen.findByDisplayValue("rotated-secret"),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchApiKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh button refetches the list without navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("client-id-stable");
|
||||
|
||||
const refreshButton = screen.getByRole("button", { name: /새로고침/ });
|
||||
expect(refreshButton).toHaveAttribute("type", "button");
|
||||
|
||||
await user.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchApiKeys).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,19 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Key, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Copy,
|
||||
Edit3,
|
||||
Key,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { commonStickyTableHeaderClass } from "../../../../common/ui/table";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
@@ -11,6 +23,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,10 +40,27 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
|
||||
import {
|
||||
type ApiKeySummary,
|
||||
deleteApiKey,
|
||||
fetchApiKeys,
|
||||
rotateApiKeySecret,
|
||||
updateApiKeyScopes,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||
|
||||
function ApiKeyListPage() {
|
||||
const [editingKey, setEditingKey] = React.useState<ApiKeySummary | null>(
|
||||
null,
|
||||
);
|
||||
const [draftScopes, setDraftScopes] = React.useState<string[]>([]);
|
||||
const [rotatedSecret, setRotatedSecret] = React.useState<{
|
||||
key: ApiKeySummary;
|
||||
clientSecret: string;
|
||||
} | null>(null);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["api-keys", { limit: 50, offset: 0 }],
|
||||
queryFn: () => fetchApiKeys(50, 0),
|
||||
@@ -35,6 +73,27 @@ function ApiKeyListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateScopesMutation = useMutation({
|
||||
mutationFn: ({ id, scopes }: { id: string; scopes: string[] }) =>
|
||||
updateApiKeyScopes(id, { scopes }),
|
||||
onSuccess: () => {
|
||||
setEditingKey(null);
|
||||
setDraftScopes([]);
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const rotateSecretMutation = useMutation({
|
||||
mutationFn: (id: string) => rotateApiKeySecret(id),
|
||||
onSuccess: (data) => {
|
||||
setRotatedSecret({
|
||||
key: data.apiKey,
|
||||
clientSecret: data.clientSecret,
|
||||
});
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
@@ -62,42 +121,80 @@ function ApiKeyListPage() {
|
||||
deleteMutation.mutate(id);
|
||||
};
|
||||
|
||||
const openScopeEditor = (key: ApiKeySummary) => {
|
||||
setEditingKey(key);
|
||||
setDraftScopes(key.scopes);
|
||||
};
|
||||
|
||||
const toggleDraftScope = (scopeId: string) => {
|
||||
setDraftScopes((current) =>
|
||||
current.includes(scopeId)
|
||||
? current.filter((scope) => scope !== scopeId)
|
||||
: [...current, scopeId],
|
||||
);
|
||||
};
|
||||
|
||||
const saveScopes = () => {
|
||||
if (!editingKey || draftScopes.length === 0) return;
|
||||
updateScopesMutation.mutate({ id: editingKey.id, scopes: draftScopes });
|
||||
};
|
||||
|
||||
const handleRotateSecret = (key: ApiKeySummary) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.api_keys.list.rotate_confirm",
|
||||
'API 키 "{{name}}"의 Secret을 재발급할까요? 기존 Secret은 더 이상 사용할 수 없습니다.',
|
||||
{ name: key.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
rotateSecretMutation.mutate(key.id);
|
||||
};
|
||||
|
||||
const copyRotatedSecret = () => {
|
||||
if (!rotatedSecret) return;
|
||||
navigator.clipboard.writeText(rotatedSecret.clientSecret);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.api_keys.list.subtitle",
|
||||
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/api-keys/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<PageHeader
|
||||
sticky
|
||||
titleAs="h2"
|
||||
icon={<Key size={20} />}
|
||||
title={t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
|
||||
description={t(
|
||||
"msg.admin.api_keys.list.subtitle",
|
||||
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/api-keys/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -119,7 +216,7 @@ function ApiKeyListPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
||||
@@ -189,15 +286,40 @@ function ApiKeyListPage() {
|
||||
: t("ui.common.never", "Never")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openScopeEditor(key)}
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
{t(
|
||||
"ui.admin.api_keys.list.edit_scopes",
|
||||
"권한 수정",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRotateSecret(key)}
|
||||
disabled={rotateSecretMutation.isPending}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
{t(
|
||||
"ui.admin.api_keys.list.rotate_secret",
|
||||
"Secret 재발급",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -207,6 +329,137 @@ function ApiKeyListPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={editingKey !== null}
|
||||
onOpenChange={() => setEditingKey(null)}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.api_keys.list.edit_scopes", "권한 수정")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingKey
|
||||
? t(
|
||||
"msg.admin.api_keys.list.edit_scopes_desc",
|
||||
"{{clientId}}의 CLIENT_ID는 유지하고 권한만 변경합니다.",
|
||||
{ clientId: editingKey.client_id },
|
||||
)
|
||||
: null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
|
||||
const isSelected = draftScopes.includes(scope.id);
|
||||
return (
|
||||
<button
|
||||
key={scope.id}
|
||||
type="button"
|
||||
onClick={() => toggleDraftScope(scope.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-2 rounded-lg border-2 p-4 text-left transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-card hover:border-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<span className="font-bold text-sm">
|
||||
{t(scope.labelKey, scope.labelFallback)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t(scope.descKey, scope.descFallback)}
|
||||
</span>
|
||||
<code className="text-[9px] font-mono opacity-60 uppercase tracking-tighter">
|
||||
ID: {scope.id}
|
||||
</code>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{draftScopes.length === 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
{t(
|
||||
"msg.admin.api_keys.create.scope_required",
|
||||
"최소 하나 이상의 권한을 선택해야 합니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditingKey(null)}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveScopes}
|
||||
disabled={
|
||||
updateScopesMutation.isPending || draftScopes.length === 0
|
||||
}
|
||||
>
|
||||
<Save size={16} />
|
||||
{t("ui.admin.api_keys.list.save_scopes", "권한 저장")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={rotatedSecret !== null}
|
||||
onOpenChange={() => setRotatedSecret(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(
|
||||
"ui.admin.api_keys.list.rotate_secret_done",
|
||||
"Secret 재발급 완료",
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.api_keys.list.rotate_secret_notice",
|
||||
"새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{rotatedSecret && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
CLIENT ID
|
||||
</p>
|
||||
<code className="block rounded-md bg-muted px-3 py-2 text-sm">
|
||||
{rotatedSecret.key.client_id}
|
||||
</code>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
X-Baron-Key-Secret
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
readOnly
|
||||
value={rotatedSecret.clientSecret}
|
||||
className="font-mono pr-12"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
onClick={copyRotatedSecret}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setRotatedSecret(null)}>
|
||||
{t("ui.common.confirm", "확인")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
59
adminfront/src/features/api-keys/apiKeyScopes.ts
Normal file
59
adminfront/src/features/api-keys/apiKeyScopes.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type ApiKeyScopeOption = {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
labelFallback: string;
|
||||
descKey: string;
|
||||
descFallback: string;
|
||||
};
|
||||
|
||||
export const AVAILABLE_API_KEY_SCOPES: ApiKeyScopeOption[] = [
|
||||
{
|
||||
id: "audit:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
|
||||
labelFallback: "감사 로그 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
|
||||
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "audit:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
|
||||
labelFallback: "감사 로그 생성",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
|
||||
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
|
||||
},
|
||||
{
|
||||
id: "user:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_read.title",
|
||||
labelFallback: "사용자 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.user_read.desc",
|
||||
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "user:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_write.title",
|
||||
labelFallback: "사용자 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.user_write.desc",
|
||||
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
|
||||
labelFallback: "테넌트 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
|
||||
descFallback: "등록된 모든 조직 정보를 조회합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
|
||||
labelFallback: "테넌트 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
|
||||
descFallback: "테넌트 정보를 직접 제어합니다.",
|
||||
},
|
||||
{
|
||||
id: "org-context:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.org_context_read.title",
|
||||
labelFallback: "조직 Context 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.org_context_read.desc",
|
||||
descFallback: "외부 연동앱이 OrgFront SSOT 조직 JSON을 조회합니다.",
|
||||
},
|
||||
];
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
@@ -19,92 +13,18 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const defaultAuditFilters = [
|
||||
"method:POST path:/api/v1/*",
|
||||
"status:failure",
|
||||
"latency_ms:>1000",
|
||||
];
|
||||
|
||||
type AuditDetails = {
|
||||
request_id?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
status?: number;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
tenant_id?: string;
|
||||
actor_id?: string;
|
||||
action?: string;
|
||||
target?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
};
|
||||
|
||||
function parseDetails(details?: string): AuditDetails {
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(details);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as AuditDetails;
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
function formatCellValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatIsoDateTime(value: string) {
|
||||
if (!value) {
|
||||
return { date: "-", time: "-" };
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return { date: value, time: "-" };
|
||||
}
|
||||
const date = parsed.toISOString().slice(0, 10);
|
||||
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
|
||||
return { date, time };
|
||||
}
|
||||
import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable";
|
||||
|
||||
function AuditLogsPage() {
|
||||
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
||||
const [filterDraft, setFilterDraft] = React.useState("");
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
const [searchActorId, setSearchActorId] = React.useState("");
|
||||
const [searchAction, setSearchAction] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const deferredSearchActorId = React.useDeferredValue(searchActorId.trim());
|
||||
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -115,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,
|
||||
});
|
||||
@@ -127,477 +62,131 @@ function AuditLogsPage() {
|
||||
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
||||
) ?? [];
|
||||
|
||||
const handleAddFilter = () => {
|
||||
const trimmed = filterDraft.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed]));
|
||||
setFilterDraft("");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.admin.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.admin.audit.load_error", "Error loading logs: {{error}}", {
|
||||
error: errMsg,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.audit.title", "감사 로그")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.audit.subtitle",
|
||||
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button>
|
||||
<ListChecks size={16} />
|
||||
{t("ui.admin.audit.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.audit.registry.title", "Log Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t("ui.common.audit.title", "감사 로그")}
|
||||
description={t(
|
||||
"msg.admin.audit.subtitle",
|
||||
"관리자 작업 이력을 조회합니다.",
|
||||
)}
|
||||
icon={<NotebookTabs size={20} />}
|
||||
actions={
|
||||
<>
|
||||
<Badge variant="muted">
|
||||
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: logs.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button>
|
||||
<Download size={16} />
|
||||
{t("ui.common.export_csv", "CSV 내보내기")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
{t("ui.common.audit.registry.title", "Audit registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.audit.registry.description",
|
||||
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={filterDraft}
|
||||
onChange={(event) => setFilterDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
handleAddFilter();
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
"ui.admin.audit.filters.placeholder",
|
||||
"필터 추가 (예: status:failure)",
|
||||
)}
|
||||
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddFilter}>
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</div>
|
||||
{filters.length === 0 ? (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.filters.empty", "필터 없음")}
|
||||
</span>
|
||||
) : (
|
||||
filters.map((filter) => (
|
||||
<span
|
||||
key={filter}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||
{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]"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
{filter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters((prev) =>
|
||||
prev.filter((item) => item !== filter),
|
||||
)
|
||||
}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.filters.remove",
|
||||
"{{filter}} 필터 제거",
|
||||
{ filter },
|
||||
)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[140px]">
|
||||
{t("ui.admin.audit.table.time", "TIME")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px]">
|
||||
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.audit.table.request", "REQUEST")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.audit.table.path", "PATH")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{t("ui.admin.audit.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.admin.audit.table.action_target",
|
||||
"Action / Target",
|
||||
<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",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && logs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t(
|
||||
"msg.admin.audit.empty",
|
||||
"아직 수집된 감사 로그가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{logs.map((row, index) => {
|
||||
const details = parseDetails(row.details);
|
||||
const actionLabel =
|
||||
details.action ||
|
||||
(details.method && details.path
|
||||
? `${details.method} ${details.path}`
|
||||
: row.event_type);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const isExpanded = Boolean(expandedRows[rowKey]);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow className="bg-card/40">
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
{(() => {
|
||||
const { date, time } = formatIsoDateTime(
|
||||
row.timestamp,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{row.user_id || details.actor_id || "-"}
|
||||
</code>
|
||||
{(row.user_id || details.actor_id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.actor_id",
|
||||
"Copy actor id",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
row.user_id || details.actor_id || "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="break-all">
|
||||
{formatCellValue(details.request_id)}
|
||||
</span>
|
||||
{details.request_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.request_id",
|
||||
"Copy request id",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(details.request_id || "")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{formatCellValue(details.method)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{formatCellValue(details.path)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status === "success" || row.status === "ok"
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{actionLabel}
|
||||
</div>
|
||||
{details.target && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.target",
|
||||
"Target · {{target}}",
|
||||
{
|
||||
target: details.target,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.target",
|
||||
"Copy target",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(details.target || "")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !isExpanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell colSpan={7} className="text-xs">
|
||||
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t(
|
||||
"ui.admin.audit.details.request",
|
||||
"Request",
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{
|
||||
value: formatCellValue(
|
||||
details.request_id,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.event_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.ip",
|
||||
"IP · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.ip_address),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.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.admin.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.actor_id",
|
||||
"Actor ID · {{value}}",
|
||||
{
|
||||
value:
|
||||
row.user_id ||
|
||||
details.actor_id ||
|
||||
"-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.tenant",
|
||||
"Tenant · {{value}}",
|
||||
{
|
||||
value: formatCellValue(
|
||||
details.tenant_id,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.device",
|
||||
"Device · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.device_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t(
|
||||
"ui.admin.audit.details.result",
|
||||
"Result",
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.error",
|
||||
"Error · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.error),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.before",
|
||||
"Before · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.before),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.after",
|
||||
"After · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.after),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 text-center flex-shrink-0">
|
||||
{hasNextPage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.admin.audit.load_more", "Load more")}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
data-testid="audit-search-action"
|
||||
value={searchAction}
|
||||
onChange={(event) =>
|
||||
setSearchAction(event.target.value.toUpperCase())
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import { ShieldHalf } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { debugLog } from "../../lib/debugLog";
|
||||
|
||||
function AuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("[AuthCallbackPage] State:", {
|
||||
debugLog("[AuthCallbackPage] State:", {
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
error: auth.error,
|
||||
|
||||
41
adminfront/src/features/auth/AuthGuard.tsx
Normal file
41
adminfront/src/features/auth/AuthGuard.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
const isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
|
||||
if (isTest) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
if (auth.isLoading || auth.activeNavigator) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (auth.error) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
|
||||
<div className="mb-4 text-destructive">
|
||||
<h2 className="text-xl font-bold">인증 오류</h2>
|
||||
<p>{auth.error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||
return (
|
||||
<Navigate
|
||||
to={`/login?returnTo=${encodeURIComponent(returnTo)}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
38
adminfront/src/features/auth/AuthPage.test.tsx
Normal file
38
adminfront/src/features/auth/AuthPage.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import AuthPage from "./AuthPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AuthPage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
});
|
||||
|
||||
it("renders localized auth guard labels in English", () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText("Auth Guard")).toBeInTheDocument();
|
||||
expect(screen.getByText("ReBAC permission checker")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Check permission" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,20 @@
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { ShieldHalf } from "lucide-react";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { t } from "../../lib/i18n";
|
||||
import PermissionChecker from "./components/PermissionChecker";
|
||||
|
||||
function AuthPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Admin auth
|
||||
</p>
|
||||
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<KeyRound size={22} className="text-primary" />
|
||||
인증가드
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
titleAs="h2"
|
||||
icon={<ShieldHalf size={20} />}
|
||||
title={t("ui.admin.auth_guard.title", "Auth Guard")}
|
||||
description={t(
|
||||
"ui.admin.auth_guard.subtitle",
|
||||
"Verify admin privileges and ReBAC relationships against the policy engine.",
|
||||
)}
|
||||
/>
|
||||
|
||||
<PermissionChecker />
|
||||
</div>
|
||||
|
||||
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,17 +10,39 @@ 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(() => {
|
||||
console.debug("[LoginPage] Auth state check:", {
|
||||
debugLog("[LoginPage] Auth state check:", {
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
returnTo,
|
||||
@@ -41,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 (
|
||||
@@ -84,8 +131,7 @@ function LoginPage() {
|
||||
variant="ghost"
|
||||
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
window.location.href =
|
||||
window.location.origin + window.location.pathname;
|
||||
void handleSSOLogin();
|
||||
}}
|
||||
>
|
||||
다시 시도하기
|
||||
@@ -123,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 />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckCircle2, ShieldAlert, XCircle } from "lucide-react";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import apiClient from "../../../lib/apiClient";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type CheckPermissionResponse = {
|
||||
allowed: boolean;
|
||||
@@ -46,50 +47,82 @@ function PermissionChecker() {
|
||||
return (
|
||||
<Card className="border-primary/20 bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldAlert size={20} className="text-primary" />
|
||||
ReBAC 권한 검증 도구
|
||||
<CardTitle className="text-lg font-bold">
|
||||
{t("ui.admin.auth_guard.checker.title", "ReBAC permission checker")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory
|
||||
Keto를 통해 실시간으로 확인합니다.
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.description",
|
||||
"Check in real time whether a subject has access to a resource through Ory Keto.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Namespace</Label>
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
|
||||
</Label>
|
||||
<select
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="Tenant">Tenant</option>
|
||||
<option value="TenantGroup">TenantGroup</option>
|
||||
<option value="RelyingParty">RelyingParty</option>
|
||||
<option value="System">System</option>
|
||||
<option value="Tenant">
|
||||
{t("ui.admin.auth_guard.checker.namespace.tenant", "Tenant")}
|
||||
</option>
|
||||
<option value="TenantGroup">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.namespace.tenant_group",
|
||||
"TenantGroup",
|
||||
)}
|
||||
</option>
|
||||
<option value="RelyingParty">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.namespace.relying_party",
|
||||
"RelyingParty",
|
||||
)}
|
||||
</option>
|
||||
<option value="System">
|
||||
{t("ui.admin.auth_guard.checker.namespace.system", "System")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Relation</Label>
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.relation", "Relation")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="view, manage, admins..."
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.relation_placeholder",
|
||||
"view, manage, admins...",
|
||||
)}
|
||||
value={relation}
|
||||
onChange={(e) => setRelation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Object ID</Label>
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.object_id", "Object ID")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="Tenant UUID 등"
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.object_id_placeholder",
|
||||
"Tenant UUID, etc.",
|
||||
)}
|
||||
value={object}
|
||||
onChange={(e) => setObject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subject (User:ID)</Label>
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.subject", "Subject (User:ID)")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="User:uuid 또는 Namespace:ID#Relation"
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.subject_placeholder",
|
||||
"User:uuid or Namespace:ID#Relation",
|
||||
)}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
@@ -102,7 +135,9 @@ function PermissionChecker() {
|
||||
disabled={!object || !subject || checkMutation.isPending}
|
||||
className="w-full px-12 md:w-auto"
|
||||
>
|
||||
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
|
||||
{checkMutation.isPending
|
||||
? t("ui.admin.auth_guard.checker.checking", "Checking...")
|
||||
: t("ui.admin.auth_guard.checker.check", "Check permission")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -117,18 +152,27 @@ function PermissionChecker() {
|
||||
{result.allowed ? (
|
||||
<>
|
||||
<CheckCircle2 size={48} />
|
||||
<div className="text-xl font-bold">Access ALLOWED</div>
|
||||
<div className="text-lg font-bold">
|
||||
{t("ui.admin.auth_guard.checker.allowed", "Access ALLOWED")}
|
||||
</div>
|
||||
<p className="text-center text-sm opacity-80">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속
|
||||
포함)
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.allowed_description",
|
||||
"The subject has access to the requested resource, including inherited permissions.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle size={48} />
|
||||
<div className="text-xl font-bold">Access DENIED</div>
|
||||
<div className="text-lg font-bold">
|
||||
{t("ui.admin.auth_guard.checker.denied", "Access DENIED")}
|
||||
</div>
|
||||
<p className="text-center text-sm opacity-80">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.denied_description",
|
||||
"The subject does not have access to the requested resource.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
244
adminfront/src/features/integrity/DataIntegrityPage.test.tsx
Normal file
244
adminfront/src/features/integrity/DataIntegrityPage.test.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
deleteOrphanUserLoginIDs,
|
||||
fetchDataIntegrityReport,
|
||||
fetchMe,
|
||||
fetchOrphanUserLoginIDs,
|
||||
fetchUserProjectionStatus,
|
||||
reconcileUserProjection,
|
||||
resetUserProjection,
|
||||
} from "../../lib/adminApi";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import DataIntegrityPage from "./DataIntegrityPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
let currentRole = "super_admin";
|
||||
|
||||
const integrityReport = {
|
||||
status: "fail",
|
||||
checkedAt: "2026-05-14T00:00:00Z",
|
||||
summary: {
|
||||
totalChecks: 2,
|
||||
passed: 1,
|
||||
warnings: 0,
|
||||
failures: 1,
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
key: "tenant_integrity",
|
||||
label: "테넌트 정합성",
|
||||
status: "fail",
|
||||
checks: [
|
||||
{
|
||||
key: "duplicate_tenant_slugs",
|
||||
label: "중복 테넌트 slug",
|
||||
description: "active tenant slug의 대소문자 무시 중복을 검사합니다.",
|
||||
status: "fail",
|
||||
severity: "error",
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: currentRole })),
|
||||
fetchDataIntegrityReport: vi.fn(async () => integrityReport),
|
||||
fetchOrphanUserLoginIDs: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "login-id-1",
|
||||
userId: "user-1",
|
||||
userEmail: "missing@example.com",
|
||||
tenantId: "tenant-1",
|
||||
tenantSlug: "deleted-tenant",
|
||||
fieldKey: "emp_id",
|
||||
loginId: "EMP001",
|
||||
reasons: ["deleted_tenant"],
|
||||
},
|
||||
],
|
||||
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: [
|
||||
{
|
||||
id: "login-id-1",
|
||||
userId: "user-1",
|
||||
tenantId: "tenant-1",
|
||||
fieldKey: "emp_id",
|
||||
loginId: "EMP001",
|
||||
reasons: ["deleted_tenant"],
|
||||
},
|
||||
],
|
||||
skippedIds: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DataIntegrityPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("DataIntegrityPage", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
});
|
||||
|
||||
it("renders integrity report for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "정합성 검사" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "사용자 동기화" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("테넌트 정합성")).toBeInTheDocument();
|
||||
expect(screen.getByText("중복 테넌트 slug")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("1").length).toBeGreaterThan(0);
|
||||
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();
|
||||
|
||||
expect(await screen.findByText("유령 로그인 ID 정리")).toBeInTheDocument();
|
||||
expect(await screen.findByText("EMP001")).toBeInTheDocument();
|
||||
expect(screen.getByText("삭제된 테넌트")).toBeInTheDocument();
|
||||
expect(fetchOrphanUserLoginIDs).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "EMP001 선택" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "선택 삭제" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteOrphanUserLoginIDs).toHaveBeenCalled();
|
||||
});
|
||||
expect(vi.mocked(deleteOrphanUserLoginIDs).mock.calls[0][0]).toEqual([
|
||||
"login-id-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables recheck button and shows manual recheck progress", async () => {
|
||||
let finishRecheck: (value: typeof integrityReport) => void = () => {};
|
||||
const pendingRecheck = new Promise<typeof integrityReport>((resolve) => {
|
||||
finishRecheck = resolve;
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("중복 테넌트 slug")).toBeInTheDocument();
|
||||
vi.mocked(fetchDataIntegrityReport).mockImplementationOnce(
|
||||
() => pendingRecheck,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "다시 검사" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "검사 중" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("정합성 검사를 실행 중입니다."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
finishRecheck(integrityReport);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "다시 검사" })).toBeEnabled();
|
||||
});
|
||||
expect(screen.getByText("검사가 완료되었습니다.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks non-super admins", async () => {
|
||||
currentRole = "tenant_admin";
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||
expect(fetchMe).toHaveBeenCalled();
|
||||
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders localized integrity labels in English", async () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Data Integrity Check")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"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(
|
||||
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
639
adminfront/src/features/integrity/DataIntegrityPage.tsx
Normal file
639
adminfront/src/features/integrity/DataIntegrityPage.tsx
Normal file
@@ -0,0 +1,639 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
type DataIntegrityCheck,
|
||||
type DataIntegrityStatus,
|
||||
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) {
|
||||
case "pass":
|
||||
return t("ui.admin.integrity.status.pass", "정상");
|
||||
case "warning":
|
||||
return t("ui.admin.integrity.status.warning", "주의");
|
||||
case "fail":
|
||||
return t("ui.admin.integrity.status.fail", "실패");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "success";
|
||||
case "warning":
|
||||
return "warning";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(getAdminDateLocale(), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function CheckIcon({ check }: { check: DataIntegrityCheck }) {
|
||||
if (check.status === "pass") {
|
||||
return <CheckCircle2 className="text-emerald-600" size={18} />;
|
||||
}
|
||||
if (check.status === "warning") {
|
||||
return <AlertTriangle className="text-amber-600" size={18} />;
|
||||
}
|
||||
return <ShieldAlert className="text-destructive" size={18} />;
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string) {
|
||||
switch (reason) {
|
||||
case "missing_user":
|
||||
return t("ui.admin.integrity.reason.missing_user", "사용자 없음");
|
||||
case "deleted_user":
|
||||
return t("ui.admin.integrity.reason.deleted_user", "삭제된 사용자");
|
||||
case "missing_tenant":
|
||||
return t("ui.admin.integrity.reason.missing_tenant", "테넌트 없음");
|
||||
case "deleted_tenant":
|
||||
return t("ui.admin.integrity.reason.deleted_tenant", "삭제된 테넌트");
|
||||
default:
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
function integritySectionLabel(key: string, fallback: string) {
|
||||
switch (key) {
|
||||
case "tenant_integrity":
|
||||
return t("ui.admin.integrity.section.tenant_integrity", fallback);
|
||||
case "user_integrity":
|
||||
return t("ui.admin.integrity.section.user_integrity", fallback);
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function integritySectionDescription(key: string) {
|
||||
switch (key) {
|
||||
case "tenant_integrity":
|
||||
return t(
|
||||
"msg.admin.integrity.section.tenant_integrity.description",
|
||||
"테넌트 slug 중복과 부모 관계 이상을 확인합니다.",
|
||||
);
|
||||
case "user_integrity":
|
||||
return t(
|
||||
"msg.admin.integrity.section.user_integrity.description",
|
||||
"사용자와 로그인 ID 참조의 고아 레코드를 확인합니다.",
|
||||
);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function integrityCheckLabel(key: string, fallback: string) {
|
||||
switch (key) {
|
||||
case "duplicate_tenant_slugs":
|
||||
return t(
|
||||
"ui.admin.integrity.check.duplicate_tenant_slugs.title",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_tenant_parents":
|
||||
return t(
|
||||
"ui.admin.integrity.check.orphan_tenant_parents.title",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_tenant_memberships":
|
||||
return t(
|
||||
"ui.admin.integrity.check.orphan_user_tenant_memberships.title",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_login_id_tenants":
|
||||
return t(
|
||||
"ui.admin.integrity.check.orphan_user_login_id_tenants.title",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_login_id_users":
|
||||
return t(
|
||||
"ui.admin.integrity.check.orphan_user_login_id_users.title",
|
||||
fallback,
|
||||
);
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function integrityCheckDescription(key: string, fallback: string) {
|
||||
switch (key) {
|
||||
case "duplicate_tenant_slugs":
|
||||
return t(
|
||||
"msg.admin.integrity.check.duplicate_tenant_slugs.description",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_tenant_parents":
|
||||
return t(
|
||||
"msg.admin.integrity.check.orphan_tenant_parents.description",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_tenant_memberships":
|
||||
return t(
|
||||
"msg.admin.integrity.check.orphan_user_tenant_memberships.description",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_login_id_tenants":
|
||||
return t(
|
||||
"msg.admin.integrity.check.orphan_user_login_id_tenants.description",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_login_id_users":
|
||||
return t(
|
||||
"msg.admin.integrity.check.orphan_user_login_id_users.description",
|
||||
fallback,
|
||||
);
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function recheckStatusText(status: "idle" | "running" | "success" | "error") {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return t(
|
||||
"msg.admin.integrity.recheck.running",
|
||||
"정합성 검사를 실행 중입니다.",
|
||||
);
|
||||
case "success":
|
||||
return t("msg.admin.integrity.recheck.success", "검사가 완료되었습니다.");
|
||||
case "error":
|
||||
return t("msg.admin.integrity.recheck.error", "검사에 실패했습니다.");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
onToggle,
|
||||
}: {
|
||||
items: OrphanUserLoginID[];
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-border/60 px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.empty",
|
||||
"삭제할 유령 로그인 ID가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSet = new Set(selectedIds);
|
||||
return (
|
||||
<div className="overflow-x-auto rounded border border-border/60">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead className="bg-muted/50 text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-12 px-3 py-2">
|
||||
{t("ui.admin.integrity.table.select", "선택")}
|
||||
</th>
|
||||
<th className="px-3 py-2">
|
||||
{t("ui.admin.integrity.table.login_id", "Login ID")}
|
||||
</th>
|
||||
<th className="px-3 py-2">
|
||||
{t("ui.admin.integrity.table.field", "Field")}
|
||||
</th>
|
||||
<th className="px-3 py-2">
|
||||
{t("ui.admin.integrity.table.user", "User")}
|
||||
</th>
|
||||
<th className="px-3 py-2">
|
||||
{t("ui.admin.integrity.table.tenant", "Tenant")}
|
||||
</th>
|
||||
<th className="px-3 py-2">
|
||||
{t("ui.admin.integrity.table.reason", "사유")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t(
|
||||
"ui.admin.integrity.table.select_item",
|
||||
"{{loginId}} 선택",
|
||||
{ loginId: item.loginId },
|
||||
)}
|
||||
checked={selectedSet.has(item.id)}
|
||||
onChange={() => onToggle(item.id)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium">{item.loginId}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{item.fieldKey}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div>{item.userEmail || "-"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.userId}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div>{item.tenantSlug || "-"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.tenantId}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.reasons.map((reason) => (
|
||||
<Badge key={reason} variant="warning">
|
||||
{reasonLabel(reason)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
>("idle");
|
||||
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
|
||||
queryKey: ["data-integrity-report"],
|
||||
queryFn: fetchDataIntegrityReport,
|
||||
});
|
||||
const orphanLoginIDsQuery = useQuery({
|
||||
queryKey: ["orphan-user-login-ids"],
|
||||
queryFn: fetchOrphanUserLoginIDs,
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrphanUserLoginIDs,
|
||||
onSuccess: async () => {
|
||||
setSelectedOrphanIds([]);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["data-integrity-report"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["orphan-user-login-ids"] }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const orphanItems = orphanLoginIDsQuery.data?.items ?? [];
|
||||
const toggleOrphanID = (id: string) => {
|
||||
setSelectedOrphanIds((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((selectedID) => selectedID !== id)
|
||||
: [...current, id],
|
||||
);
|
||||
};
|
||||
const handleDeleteSelected = () => {
|
||||
if (selectedOrphanIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(
|
||||
t(
|
||||
"msg.admin.integrity.orphan_login_ids.delete_confirm",
|
||||
"선택한 {{count}}개의 유령 로그인 ID를 삭제하시겠습니까?",
|
||||
{ count: selectedOrphanIds.length },
|
||||
),
|
||||
);
|
||||
if (confirmed) {
|
||||
deleteMutation.mutate(selectedOrphanIds);
|
||||
}
|
||||
};
|
||||
const isManualRechecking = recheckStatus === "running";
|
||||
const handleRecheck = async () => {
|
||||
if (isManualRechecking) {
|
||||
return;
|
||||
}
|
||||
setRecheckStatus("running");
|
||||
const result = await refetch();
|
||||
setRecheckStatus(result.isError ? "error" : "success");
|
||||
};
|
||||
const recheckMessage = recheckStatusText(recheckStatus);
|
||||
|
||||
return (
|
||||
<main className="space-y-6">
|
||||
<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">
|
||||
<Database size={20} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.integrity.title", "데이터 정합성 검증")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.subtitle",
|
||||
"Review integrity status and inspect checks across the admin data model.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === "integrity" ? (
|
||||
<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>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DataIntegrityPage() {
|
||||
return (
|
||||
<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.integrity.forbidden.title", "접근 권한이 없습니다")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.forbidden.description",
|
||||
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<DataIntegrityContent />
|
||||
</RoleGuard>
|
||||
);
|
||||
}
|
||||
@@ -3,19 +3,39 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchAdminRPUsageDaily } from "../../lib/adminApi";
|
||||
import {
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchDataIntegrityReport,
|
||||
} from "../../lib/adminApi";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import AuthPage from "../auth/AuthPage";
|
||||
import GlobalOverviewPage from "./GlobalOverviewPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
let currentRole = "super_admin";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
|
||||
fetchMe: vi.fn(async () => ({ role: currentRole })),
|
||||
fetchAdminOverviewStats: vi.fn(async () => ({
|
||||
totalTenants: 10,
|
||||
totalUsers: 152,
|
||||
oidcClients: 3,
|
||||
auditEvents24h: 18,
|
||||
})),
|
||||
fetchTenants: vi.fn(async () => ({
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "group-1",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥그룹",
|
||||
slug: "hanmac-group",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "company-1",
|
||||
type: "COMPANY",
|
||||
@@ -52,7 +72,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
total: 3,
|
||||
total: 4,
|
||||
})),
|
||||
fetchAdminRPUsageDaily: vi.fn(async () => ({
|
||||
days: 14,
|
||||
@@ -93,6 +113,30 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchDataIntegrityReport: vi.fn(async () => ({
|
||||
status: "fail",
|
||||
checkedAt: "2026-05-14T00:00:00Z",
|
||||
summary: {
|
||||
totalChecks: 5,
|
||||
passed: 4,
|
||||
warnings: 0,
|
||||
failures: 1,
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
key: "tenant_integrity",
|
||||
label: "테넌트 정합성",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
},
|
||||
{
|
||||
key: "user_integrity",
|
||||
label: "사용자 정합성",
|
||||
status: "fail",
|
||||
checks: [],
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
@@ -112,6 +156,7 @@ function renderWithProviders(ui: React.ReactElement) {
|
||||
|
||||
describe("admin overview and auth guard pages", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -119,7 +164,7 @@ describe("admin overview and auth guard pages", () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"),
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByLabelText("일 단위 RP 요청 현황"),
|
||||
@@ -132,50 +177,85 @@ describe("admin overview and auth guard pages", () => {
|
||||
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders overview summary metrics from the admin stats API", async () => {
|
||||
it("renders overview tenant count from the fully fetched tenant list", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
(await screen.findByText("전체 테넌트 수")).parentElement,
|
||||
).toHaveTextContent("10");
|
||||
).toHaveTextContent("4");
|
||||
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
|
||||
"3",
|
||||
);
|
||||
expect(screen.getByText("전체 사용자 수").parentElement).toHaveTextContent(
|
||||
"152",
|
||||
);
|
||||
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
|
||||
"18",
|
||||
);
|
||||
});
|
||||
|
||||
it("changes the RP usage perspective and targets a permitted organization", async () => {
|
||||
it("limits the overview graph choices to company tenants", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
|
||||
expect(
|
||||
await screen.findByRole("checkbox", { name: "한맥 (hanmac)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("한맥그룹 (hanmac-group)"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("changes the RP usage perspective and targets a permitted company", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
fireEvent.click(screen.getByRole("button", { name: "주" }));
|
||||
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
|
||||
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "월" }));
|
||||
fireEvent.change(screen.getByLabelText("조직 검색"), {
|
||||
target: { value: "개발" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("대상 조직"), {
|
||||
target: { value: "org-1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
||||
days: 90,
|
||||
period: "month",
|
||||
tenantId: "org-1",
|
||||
});
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("한맥그룹 (hanmac-group)"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows the latest integrity summary at the bottom for super admins only", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(await screen.findByText("정합성 최종 검증")).toBeInTheDocument();
|
||||
expect(screen.getByText("실패 1건")).toBeInTheDocument();
|
||||
expect(screen.getByText("테넌트 정합성")).toBeInTheDocument();
|
||||
expect(screen.getByText("사용자 정합성")).toBeInTheDocument();
|
||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fetch or show the integrity summary for non-super admins", async () => {
|
||||
currentRole = "tenant_admin";
|
||||
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument();
|
||||
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
|
||||
renderWithProviders(<AuthPage />);
|
||||
|
||||
expect(screen.getByText("인증가드")).toBeInTheDocument();
|
||||
expect(screen.getByText("인증 가드")).toBeInTheDocument();
|
||||
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
LayoutDashboard,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
OverviewAxisNotes,
|
||||
OverviewMetric,
|
||||
OverviewSelectionChips,
|
||||
} from "../../../../common/core/components/overview";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
type TenantSummary,
|
||||
type DataIntegrityStatus,
|
||||
fetchAdminOverviewStats,
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchTenants,
|
||||
fetchAllTenants,
|
||||
fetchDataIntegrityReport,
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
@@ -26,10 +34,8 @@ type DailyPoint = {
|
||||
|
||||
type SeriesSummary = {
|
||||
key: string;
|
||||
tenantLabel: string;
|
||||
clientLabel: string;
|
||||
loginRequests: number;
|
||||
otherRequests: number;
|
||||
uniqueSubjects: number;
|
||||
};
|
||||
|
||||
@@ -55,19 +61,16 @@ function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
|
||||
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||
const bySeries = new Map<string, SeriesSummary>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.tenantId}:${row.clientId}`;
|
||||
const key = row.clientId;
|
||||
const current =
|
||||
bySeries.get(key) ??
|
||||
({
|
||||
key,
|
||||
tenantLabel: row.tenantName || row.tenantId || "-",
|
||||
clientLabel: row.clientName || row.clientId,
|
||||
loginRequests: 0,
|
||||
otherRequests: 0,
|
||||
uniqueSubjects: 0,
|
||||
} satisfies SeriesSummary);
|
||||
current.loginRequests += row.loginRequests;
|
||||
current.otherRequests += row.otherRequests;
|
||||
current.uniqueSubjects = Math.max(
|
||||
current.uniqueSubjects,
|
||||
row.uniqueSubjects,
|
||||
@@ -75,10 +78,7 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||
bySeries.set(key, current);
|
||||
}
|
||||
return Array.from(bySeries.values())
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
|
||||
)
|
||||
.sort((a, b) => b.loginRequests - a.loginRequests)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
@@ -133,30 +133,132 @@ function formatPeriodLabel(date: string, period: RPUsagePeriod) {
|
||||
return `${parts.monthText}.${parts.dayText}`;
|
||||
}
|
||||
|
||||
function OverviewMetric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
function formatOverviewDateTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function integrityStatusText(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return t("ui.admin.integrity.status.pass", "정상");
|
||||
case "warning":
|
||||
return t("ui.admin.integrity.status.warning", "주의");
|
||||
default:
|
||||
return t("ui.admin.integrity.status.fail", "실패");
|
||||
}
|
||||
}
|
||||
|
||||
function integrityStatusClass(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "text-emerald-700 dark:text-emerald-300";
|
||||
case "warning":
|
||||
return "text-amber-700 dark:text-amber-300";
|
||||
default:
|
||||
return "text-destructive";
|
||||
}
|
||||
}
|
||||
|
||||
function IntegrityOverviewSummary() {
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ["admin-overview-integrity"],
|
||||
queryFn: fetchDataIntegrityReport,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<section className="border-t border-border/60 pt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle size={16} />
|
||||
<span>
|
||||
{t(
|
||||
"ui.admin.integrity.fetch_error",
|
||||
"정합성 최종 검증 결과를 불러오지 못했습니다.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-semibold tabular-nums">{value}</span>
|
||||
</span>
|
||||
<section className="border-t border-border/60 pt-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{data.status === "pass" ? (
|
||||
<CheckCircle2 size={18} className="text-emerald-600" />
|
||||
) : (
|
||||
<AlertTriangle size={18} className="text-amber-600" />
|
||||
)}
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t("ui.admin.integrity.summary.title", "정합성 최종 검증")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span
|
||||
className={`font-semibold ${integrityStatusClass(data.status)}`}
|
||||
>
|
||||
{integrityStatusText(data.status)}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{t("ui.admin.integrity.summary.failures_text", "실패 {{count}}건", {
|
||||
count: data.summary.failures,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatOverviewDateTime(data.checkedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||
{data.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2"
|
||||
>
|
||||
<span>{integritySectionLabel(section.key, section.label)}</span>
|
||||
<span
|
||||
className={`font-medium ${integrityStatusClass(section.status)}`}
|
||||
>
|
||||
{integrityStatusText(section.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function integritySectionLabel(key: string, fallback: string) {
|
||||
switch (key) {
|
||||
case "tenant_integrity":
|
||||
return t("ui.admin.integrity.section.tenant_integrity", fallback);
|
||||
case "user_integrity":
|
||||
return t("ui.admin.integrity.section.user_integrity", fallback);
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function RPUsageMixedChart({
|
||||
rows,
|
||||
periodControls,
|
||||
filters,
|
||||
period,
|
||||
}: {
|
||||
rows: RPUsageDailyMetric[];
|
||||
periodControls: ReactNode;
|
||||
filters: ReactNode;
|
||||
period: RPUsagePeriod;
|
||||
}) {
|
||||
@@ -185,142 +287,131 @@ function RPUsageMixedChart({
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 size={18} className="text-primary" />
|
||||
<h3 className="text-base font-semibold">
|
||||
회사별 앱별 로그인요청/기타 요청 현황
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t("ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{filters}
|
||||
{periodControls}
|
||||
</div>
|
||||
|
||||
{filters}
|
||||
|
||||
{daily.length === 0 ? (
|
||||
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
||||
표시할 RP 이용 집계가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="일 단위 RP 요청 현황"
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
className="h-[235px] min-w-[720px] w-full"
|
||||
>
|
||||
<title>일 단위 RP 요청 현황</title>
|
||||
<g transform="translate(510 10)">
|
||||
<rect
|
||||
x="0"
|
||||
y="3"
|
||||
width="10"
|
||||
height="10"
|
||||
rx="2"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
|
||||
기타 요청
|
||||
</text>
|
||||
<line
|
||||
x1="78"
|
||||
x2="98"
|
||||
y1="8"
|
||||
y2="8"
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="일 단위 RP 요청 현황"
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
className="h-[235px] min-w-[720px] w-full"
|
||||
>
|
||||
<title>일 단위 RP 요청 현황</title>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const gridY = padTop + innerHeight * ratio;
|
||||
const label = Math.round(maxValue * (1 - ratio));
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padX}
|
||||
x2={chartWidth - padX}
|
||||
y1={gridY}
|
||||
y2={gridY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padX - 12}
|
||||
y={gridY + 4}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{daily.map((point, index) => {
|
||||
const center = x(index);
|
||||
const otherHeight =
|
||||
(point.otherRequests / maxValue) * innerHeight;
|
||||
return (
|
||||
<g key={point.date}>
|
||||
<rect
|
||||
x={center - barWidth / 2}
|
||||
y={padTop + innerHeight - otherHeight}
|
||||
width={barWidth}
|
||||
height={otherHeight}
|
||||
rx="3"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={chartHeight - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{formatPeriodLabel(point.date, period)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<polyline
|
||||
points={linePoints}
|
||||
fill="none"
|
||||
className="stroke-emerald-500"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<text
|
||||
x="104"
|
||||
y="12"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
로그인 요청
|
||||
</text>
|
||||
</g>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const gridY = padTop + innerHeight * ratio;
|
||||
const label = Math.round(maxValue * (1 - ratio));
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padX}
|
||||
x2={chartWidth - padX}
|
||||
y1={gridY}
|
||||
y2={gridY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padX - 12}
|
||||
y={gridY + 4}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{daily.map((point, index) => {
|
||||
const center = x(index);
|
||||
const otherHeight =
|
||||
(point.otherRequests / maxValue) * innerHeight;
|
||||
return (
|
||||
<g key={point.date}>
|
||||
<rect
|
||||
x={center - barWidth / 2}
|
||||
y={padTop + innerHeight - otherHeight}
|
||||
width={barWidth}
|
||||
height={otherHeight}
|
||||
rx="3"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={chartHeight - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{formatPeriodLabel(point.date, period)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<polyline
|
||||
points={linePoints}
|
||||
fill="none"
|
||||
className="stroke-emerald-500"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{daily.map((point, index) => (
|
||||
<circle
|
||||
key={`${point.date}-login`}
|
||||
cx={x(index)}
|
||||
cy={y(point.loginRequests)}
|
||||
r="4"
|
||||
className="fill-emerald-500 stroke-background"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{daily.map((point, index) => (
|
||||
<circle
|
||||
key={`${point.date}-login`}
|
||||
cx={x(index)}
|
||||
cy={y(point.loginRequests)}
|
||||
r="4"
|
||||
className="fill-emerald-500 stroke-background"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<OverviewAxisNotes
|
||||
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
|
||||
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{series.length > 0 && (
|
||||
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
|
||||
{series.map((item) => (
|
||||
<div key={item.key} className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{item.clientLabel}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{item.tenantLabel}
|
||||
</span>
|
||||
<span className="ml-auto whitespace-nowrap tabular-nums">
|
||||
로그인 {item.loginRequests.toLocaleString()} / 기타{" "}
|
||||
{item.otherRequests.toLocaleString()} / 사용자{" "}
|
||||
{item.uniqueSubjects.toLocaleString()}
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
|
||||
>
|
||||
<span className="font-medium">{item.clientLabel}</span>
|
||||
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{t(
|
||||
"ui.common.chart.series_summary.login_users",
|
||||
"로그인 {{login}} / 사용자 {{subjects}}",
|
||||
{
|
||||
login: item.loginRequests.toLocaleString(),
|
||||
subjects: item.uniqueSubjects.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -332,8 +423,7 @@ function RPUsageMixedChart({
|
||||
|
||||
function GlobalOverviewPage() {
|
||||
const [period, setPeriod] = useState<RPUsagePeriod>("day");
|
||||
const [tenantSearch, setTenantSearch] = useState("");
|
||||
const [selectedTenantId, setSelectedTenantId] = useState("");
|
||||
const [selectedTenantIds, setSelectedTenantIds] = useState<string[]>([]);
|
||||
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["admin-overview-stats"],
|
||||
@@ -342,97 +432,97 @@ function GlobalOverviewPage() {
|
||||
});
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["admin-overview-tenant-options"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
retry: false,
|
||||
});
|
||||
const tenantOptions = useMemo(() => {
|
||||
const term = tenantSearch.trim().toLowerCase();
|
||||
return (tenantsQuery.data?.items ?? [])
|
||||
.filter(
|
||||
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
|
||||
)
|
||||
.filter((tenant) => {
|
||||
if (!term) return true;
|
||||
return (
|
||||
tenant.name.toLowerCase().includes(term) ||
|
||||
tenant.slug.toLowerCase().includes(term) ||
|
||||
tenant.id.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [tenantSearch, tenantsQuery.data?.items]);
|
||||
return (tenantsQuery.data?.items ?? []).filter(
|
||||
(tenant) => tenant.type === "COMPANY",
|
||||
);
|
||||
}, [tenantsQuery.data?.items]);
|
||||
const usageQuery = useQuery({
|
||||
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
|
||||
queryKey: ["admin-rp-usage-daily", usageDays, period],
|
||||
queryFn: () =>
|
||||
fetchAdminRPUsageDaily({
|
||||
days: usageDays,
|
||||
period,
|
||||
tenantId: selectedTenantId || undefined,
|
||||
}),
|
||||
retry: false,
|
||||
});
|
||||
const stats = statsQuery.data;
|
||||
const visibleTenantCount = tenantsQuery.data?.items.length;
|
||||
const usageRows = usageQuery.data?.items ?? [];
|
||||
const filteredUsageRows = useMemo(() => {
|
||||
if (selectedTenantIds.length === 0) {
|
||||
return usageRows;
|
||||
}
|
||||
const selectedSet = new Set(selectedTenantIds);
|
||||
return usageRows.filter((row) => selectedSet.has(row.tenantId));
|
||||
}, [selectedTenantIds, usageRows]);
|
||||
const metric = (value: number | undefined) =>
|
||||
value === undefined ? "-" : value.toLocaleString();
|
||||
const periodControls = (
|
||||
<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", "주")],
|
||||
["month", t("ui.common.chart.period.month", "월")],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={period === value}
|
||||
onClick={() => setPeriod(value as RPUsagePeriod)}
|
||||
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
|
||||
period === value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/60 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
const chartFilters = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||
{[
|
||||
["day", "일"],
|
||||
["week", "주"],
|
||||
["month", "월"],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={period === value}
|
||||
onClick={() => setPeriod(value as RPUsagePeriod)}
|
||||
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
|
||||
period === value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/60 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
aria-label="조직 검색"
|
||||
value={tenantSearch}
|
||||
onChange={(event) => setTenantSearch(event.target.value)}
|
||||
placeholder="조직 검색"
|
||||
className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44"
|
||||
<div>
|
||||
<OverviewSelectionChips
|
||||
allLabel="전체"
|
||||
options={tenantOptions.map((tenant) => ({
|
||||
id: tenant.id,
|
||||
label: `${tenant.name} (${tenant.slug})`,
|
||||
}))}
|
||||
selectedIds={selectedTenantIds}
|
||||
onSelectAll={() => setSelectedTenantIds([])}
|
||||
onToggle={(tenantId) => {
|
||||
setSelectedTenantIds((current) =>
|
||||
current.includes(tenantId)
|
||||
? current.filter((item) => item !== tenantId)
|
||||
: [...current, tenantId],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
aria-label="대상 조직"
|
||||
value={selectedTenantId}
|
||||
onChange={(event) => setSelectedTenantId(event.target.value)}
|
||||
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-52"
|
||||
>
|
||||
<option value="">전체 조직</option>
|
||||
{tenantOptions.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.id}>
|
||||
{tenant.name} ({tenant.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-in fade-in duration-500">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("ui.admin.overview.title", "Dashboard")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.overview.description",
|
||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<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">
|
||||
<LayoutDashboard size={20} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.common.overview.title", "운영 현황")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.overview.description",
|
||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -444,7 +534,7 @@ function GlobalOverviewPage() {
|
||||
"ui.admin.overview.summary.total_tenants",
|
||||
"전체 테넌트 수",
|
||||
)}
|
||||
value={metric(stats?.totalTenants)}
|
||||
value={metric(visibleTenantCount ?? stats?.totalTenants)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<ShieldCheck size={14} />}
|
||||
@@ -454,6 +544,11 @@ function GlobalOverviewPage() {
|
||||
)}
|
||||
value={metric(stats?.oidcClients)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Users size={14} />}
|
||||
label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")}
|
||||
value={metric(stats?.totalUsers)}
|
||||
/>
|
||||
</RoleGuard>
|
||||
<OverviewMetric
|
||||
icon={<Activity size={14} />}
|
||||
@@ -472,12 +567,24 @@ function GlobalOverviewPage() {
|
||||
|
||||
{usageQuery.isError ? (
|
||||
<section className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold">
|
||||
회사별 앱별 로그인요청/기타 요청 현황
|
||||
</h3>
|
||||
{chartFilters}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.overview.chart.title",
|
||||
"회사별 앱별 로그인 요청 현황",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{periodControls}
|
||||
</div>
|
||||
{chartFilters}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
||||
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
||||
@@ -486,11 +593,16 @@ function GlobalOverviewPage() {
|
||||
</section>
|
||||
) : (
|
||||
<RPUsageMixedChart
|
||||
rows={usageRows}
|
||||
rows={filteredUsageRows}
|
||||
periodControls={periodControls}
|
||||
filters={chartFilters}
|
||||
period={period}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<IntegrityOverviewSummary />
|
||||
</RoleGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
reconcileUserProjection,
|
||||
resetUserProjection,
|
||||
} from "../../lib/adminApi";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import UserProjectionPage from "./UserProjectionPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
let currentRole = "super_admin";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
@@ -52,18 +55,20 @@ describe("UserProjectionPage", () => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
});
|
||||
|
||||
it("renders projection status for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("사용자 Projection 관리"),
|
||||
await screen.findByText(
|
||||
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Kratos users projection"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("ready")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
|
||||
expect(screen.getByText("준비됨")).toBeInTheDocument();
|
||||
expect(screen.getByText("152")).toBeInTheDocument();
|
||||
expect(fetchUserProjectionStatus).toHaveBeenCalled();
|
||||
});
|
||||
@@ -71,7 +76,7 @@ describe("UserProjectionPage", () => {
|
||||
it("runs reconcile and reset actions for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("사용자 Projection 관리");
|
||||
await screen.findByText("사용자 동기화 관리");
|
||||
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -91,9 +96,21 @@ describe("UserProjectionPage", () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("사용자 Projection 관리"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("사용자 동기화 관리")).not.toBeInTheDocument();
|
||||
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders localized labels in English", async () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("User Projection Management"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Review and sync the Kratos user read model."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Re-sync")).toBeInTheDocument();
|
||||
expect(await screen.findByText("ready")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Database, RefreshCw, RotateCcw } from "lucide-react";
|
||||
import { AlertTriangle, RefreshCw, RotateCcw, Users } from "lucide-react";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
reconcileUserProjection,
|
||||
resetUserProjection,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { getAdminDateLocale } from "../../lib/locale";
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) {
|
||||
@@ -17,7 +19,7 @@ function formatDateTime(value?: string) {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
return new Intl.DateTimeFormat(getAdminDateLocale(), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
@@ -31,15 +33,33 @@ function ProjectionStatusBadge({
|
||||
status: string;
|
||||
}) {
|
||||
if (ready) {
|
||||
return <Badge variant="success">ready</Badge>;
|
||||
return (
|
||||
<Badge variant="success">
|
||||
{t("ui.admin.user_projection.status.ready", "ready")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === "failed") {
|
||||
return <Badge variant="warning">failed</Badge>;
|
||||
return (
|
||||
<Badge variant="warning">
|
||||
{t("ui.admin.user_projection.status.failed", "failed")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">{status || "not ready"}</Badge>;
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{status
|
||||
? status
|
||||
: t("ui.admin.user_projection.status.not_ready", "not ready")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function UserProjectionContent() {
|
||||
export function UserProjectionContent({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["user-projection-status"],
|
||||
@@ -64,7 +84,10 @@ function UserProjectionContent() {
|
||||
|
||||
const handleReset = () => {
|
||||
const confirmed = window.confirm(
|
||||
"사용자 projection을 Kratos 기준으로 다시 구축하시겠습니까?",
|
||||
t(
|
||||
"msg.admin.user_projection.reset_confirm",
|
||||
"Rebuild user projection from the Kratos source of truth?",
|
||||
),
|
||||
);
|
||||
if (confirmed) {
|
||||
resetMutation.mutate();
|
||||
@@ -75,75 +98,113 @@ function UserProjectionContent() {
|
||||
const actionResult = reconcileMutation.data ?? resetMutation.data;
|
||||
const actionError = reconcileMutation.error ?? resetMutation.error;
|
||||
|
||||
return (
|
||||
<main className="space-y-6 p-6 md:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">System</p>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
사용자 Projection 관리
|
||||
</h2>
|
||||
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} />
|
||||
재동기화
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleReset}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
초기화 후 재구축
|
||||
</Button>
|
||||
<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>
|
||||
</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 ||
|
||||
"projection 상태를 불러오지 못했습니다."}
|
||||
t(
|
||||
"msg.admin.user_projection.load_error",
|
||||
"Failed to load projection status.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{actionResult ? (
|
||||
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||
{actionResult.syncedUsers}명 기준으로 projection을 갱신했습니다.
|
||||
{t(
|
||||
"msg.admin.user_projection.action_success",
|
||||
"Refreshed the projection for {{count}} users.",
|
||||
{ count: actionResult.syncedUsers },
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(actionError as Error)?.message || "projection 작업에 실패했습니다."}
|
||||
{(actionError as Error)?.message ||
|
||||
t(
|
||||
"msg.admin.user_projection.action_error",
|
||||
"Projection operation failed.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-3 border-b border-border pb-4">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||
<Database size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Kratos users projection</h3>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.user_projection.card.title",
|
||||
"Kratos users projection",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Backend DB 통계가 참조하는 사용자 read model 상태입니다.
|
||||
{t(
|
||||
"ui.admin.user_projection.card.description",
|
||||
"Current user read model state referenced by backend DB statistics.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">불러오는 중</div>
|
||||
<div className="py-8 text-sm text-muted-foreground">
|
||||
{t("ui.admin.user_projection.loading", "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">상태</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.user_projection.summary.status", "Status")}
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<ProjectionStatusBadge
|
||||
ready={data?.ready ?? false}
|
||||
@@ -153,20 +214,30 @@ function UserProjectionContent() {
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
Projection 사용자
|
||||
{t(
|
||||
"ui.admin.user_projection.summary.projected_users",
|
||||
"Projected users",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.projectedUsers ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">마지막 동기화</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.user_projection.summary.last_synced",
|
||||
"Last synced",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.lastSyncedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">상태 갱신</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.user_projection.summary.updated_at", "Updated at")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.updatedAt)}
|
||||
</dd>
|
||||
@@ -181,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>
|
||||
);
|
||||
}
|
||||
@@ -192,9 +279,14 @@ export default function UserProjectionPage() {
|
||||
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">접근 권한이 없습니다</h2>
|
||||
<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">
|
||||
이 화면은 super_admin 권한으로만 접근할 수 있습니다.
|
||||
{t(
|
||||
"msg.admin.user_projection.forbidden.description",
|
||||
"This screen is only available to super_admin users.",
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
|
||||
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
|
||||
|
||||
export function filterParentTenants(
|
||||
tenants: TenantSummary[],
|
||||
search: string,
|
||||
companyOnly: boolean,
|
||||
excludeTenantId = "",
|
||||
) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
return tenants.filter((tenant) => {
|
||||
if (excludeTenantId && tenant.id === excludeTenantId) return false;
|
||||
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
|
||||
if (!normalizedSearch) return true;
|
||||
|
||||
return [tenant.name, tenant.slug, tenant.type]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { filterParentTenants } from "./ParentTenantSelector";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
const tenants: TenantSummary[] = [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "../../users/orgChartPicker";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
type ParentTenantSelectorProps = {
|
||||
id: string;
|
||||
@@ -31,28 +33,10 @@ type ParentTenantSelectorProps = {
|
||||
orgChartPickerLabel?: string;
|
||||
localPickerLabel?: string;
|
||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||
compact?: boolean;
|
||||
controlTestId?: string;
|
||||
};
|
||||
|
||||
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
|
||||
|
||||
export function filterParentTenants(
|
||||
tenants: TenantSummary[],
|
||||
search: string,
|
||||
companyOnly: boolean,
|
||||
excludeTenantId = "",
|
||||
) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
return tenants.filter((tenant) => {
|
||||
if (excludeTenantId && tenant.id === excludeTenantId) return false;
|
||||
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
|
||||
if (!normalizedSearch) return true;
|
||||
|
||||
return [tenant.name, tenant.slug, tenant.type]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
}
|
||||
|
||||
export function ParentTenantSelector({
|
||||
id,
|
||||
label,
|
||||
@@ -67,6 +51,8 @@ export function ParentTenantSelector({
|
||||
orgChartPickerLabel,
|
||||
localPickerLabel,
|
||||
localTenantFilter,
|
||||
compact = false,
|
||||
controlTestId,
|
||||
}: ParentTenantSelectorProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
||||
@@ -99,45 +85,145 @@ 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">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{orgChartPickerLabel ??
|
||||
selectedTenant?.name ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</Button>
|
||||
<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"
|
||||
className={compact ? "h-8 shrink-0 px-2" : undefined}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{orgChartPickerLabel ??
|
||||
(compact ? undefined : selectedTenant?.name) ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.parent.picker_description",
|
||||
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<iframe
|
||||
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
src={pickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{localPickerLabel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLocalPickerOpen(true)}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{localPickerLabel}
|
||||
</Button>
|
||||
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{localPickerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{localPickerLabel ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_description",
|
||||
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
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={localSearch}
|
||||
onChange={(event) => setLocalSearch(event.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.parent.local_search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색",
|
||||
)}
|
||||
/>
|
||||
<div className="max-h-[360px] space-y-2 overflow-y-auto">
|
||||
{localCandidates.map((tenant) => (
|
||||
<Button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-start px-3 py-2 text-left"
|
||||
onClick={() => {
|
||||
onChange(tenant.id);
|
||||
setLocalPickerOpen(false);
|
||||
setLocalSearch("");
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">
|
||||
{tenant.name}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{tenant.slug} · {tenant.type}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{localCandidates.length === 0 && (
|
||||
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_empty",
|
||||
"선택할 수 있는 테넌트가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{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}
|
||||
>
|
||||
@@ -145,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">
|
||||
@@ -156,85 +250,6 @@ export function ParentTenantSelector({
|
||||
{helpText && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
|
||||
)}
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.parent.picker_description",
|
||||
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<iframe
|
||||
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
src={pickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{localPickerLabel ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_description",
|
||||
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
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={localSearch}
|
||||
onChange={(event) => setLocalSearch(event.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.parent.local_search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색",
|
||||
)}
|
||||
/>
|
||||
<div className="max-h-[360px] space-y-2 overflow-y-auto">
|
||||
{localCandidates.map((tenant) => (
|
||||
<Button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-start px-3 py-2 text-left"
|
||||
onClick={() => {
|
||||
onChange(tenant.id);
|
||||
setLocalPickerOpen(false);
|
||||
setLocalSearch("");
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">
|
||||
{tenant.name}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{tenant.slug} · {tenant.type}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{localCandidates.length === 0 && (
|
||||
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_empty",
|
||||
"선택할 수 있는 테넌트가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
@@ -40,7 +39,6 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type TenantAdmin,
|
||||
addTenantAdmin,
|
||||
addTenantOwner,
|
||||
fetchTenantAdmins,
|
||||
@@ -48,20 +46,34 @@ import {
|
||||
fetchUsers,
|
||||
removeTenantAdmin,
|
||||
removeTenantOwner,
|
||||
type TenantAdmin,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type DialogMode = "owner" | "admin";
|
||||
|
||||
function mergePendingMembers(
|
||||
members: TenantAdmin[],
|
||||
pendingMembers: TenantAdmin[],
|
||||
) {
|
||||
const existingIds = new Set(members.map((member) => member.id));
|
||||
return [
|
||||
...members,
|
||||
...pendingMembers.filter((member) => !existingIds.has(member.id)),
|
||||
];
|
||||
}
|
||||
|
||||
export function TenantAdminsAndOwnersTab() {
|
||||
const auth = useAuth();
|
||||
const currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const navigate = useNavigate();
|
||||
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);
|
||||
|
||||
if (!tenantId) return null;
|
||||
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
|
||||
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
|
||||
|
||||
const ownersQuery = useQuery({
|
||||
queryKey: ["tenant-owners", tenantId],
|
||||
@@ -95,18 +107,22 @@ export function TenantAdminsAndOwnersTab() {
|
||||
// Optimistically add to the list to prevent immediate double clicks
|
||||
const addedUser = searchResults.find((u) => u.id === userId);
|
||||
if (addedUser) {
|
||||
const optimisticOwner = {
|
||||
id: userId,
|
||||
name: addedUser.name,
|
||||
email: addedUser.email,
|
||||
};
|
||||
setPendingOwners((old) =>
|
||||
old.some((owner) => owner.id === userId)
|
||||
? old
|
||||
: [...old, optimisticOwner],
|
||||
);
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-owners", tenantId],
|
||||
(old) => {
|
||||
if (!old)
|
||||
return [
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
if (!old) return [optimisticOwner];
|
||||
if (old.some((o) => o.id === userId)) return old;
|
||||
return [
|
||||
...old,
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
return [...old, optimisticOwner];
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -125,6 +141,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
setSearchTerm("");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
|
||||
if (context?.previousOwners) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-owners", tenantId],
|
||||
@@ -148,6 +165,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
"tenant-owners",
|
||||
tenantId,
|
||||
]);
|
||||
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-owners", tenantId],
|
||||
(old) => (old ? old.filter((o) => o.id !== userId) : []),
|
||||
@@ -167,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],
|
||||
@@ -194,18 +212,22 @@ export function TenantAdminsAndOwnersTab() {
|
||||
|
||||
const addedUser = searchResults.find((u) => u.id === userId);
|
||||
if (addedUser) {
|
||||
const optimisticAdmin = {
|
||||
id: userId,
|
||||
name: addedUser.name,
|
||||
email: addedUser.email,
|
||||
};
|
||||
setPendingAdmins((old) =>
|
||||
old.some((admin) => admin.id === userId)
|
||||
? old
|
||||
: [...old, optimisticAdmin],
|
||||
);
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-admins", tenantId],
|
||||
(old) => {
|
||||
if (!old)
|
||||
return [
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
if (!old) return [optimisticAdmin];
|
||||
if (old.some((a) => a.id === userId)) return old;
|
||||
return [
|
||||
...old,
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
return [...old, optimisticAdmin];
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -223,6 +245,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
setSearchTerm("");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
|
||||
if (context?.previousAdmins) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-admins", tenantId],
|
||||
@@ -246,6 +269,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
"tenant-admins",
|
||||
tenantId,
|
||||
]);
|
||||
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-admins", tenantId],
|
||||
(old) => (old ? old.filter((a) => a.id !== userId) : []),
|
||||
@@ -262,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],
|
||||
@@ -284,7 +308,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveOwner = (userId: string, userName: string) => {
|
||||
const _handleRemoveOwner = (userId: string, userName: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
@@ -298,7 +322,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
const _handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
@@ -312,8 +336,12 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const currentOwners = ownersQuery.data || [];
|
||||
const currentAdmins = adminsQuery.data || [];
|
||||
if (!tenantId) return null;
|
||||
|
||||
const serverOwners = ownersQuery.data || [];
|
||||
const serverAdmins = adminsQuery.data || [];
|
||||
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
||||
const currentAdmins = mergePendingMembers(serverAdmins, pendingAdmins);
|
||||
const searchResults = usersQuery.data?.items || [];
|
||||
const isDialogOpen = dialogMode !== null;
|
||||
|
||||
@@ -363,7 +391,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.owners.table_name", "이름")}
|
||||
@@ -371,22 +399,19 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.owners.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.owners.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ownersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<TableCell colSpan={2} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentOwners.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
colSpan={2}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -404,7 +429,8 @@ export function TenantAdminsAndOwnersTab() {
|
||||
currentOwners.map((owner) => (
|
||||
<TableRow
|
||||
key={owner.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
className="hover:bg-muted/30 transition-colors group cursor-pointer"
|
||||
onClick={() => navigate(`/users/${owner.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -417,46 +443,6 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{owner.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="relative inline-block group/tt">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveOwner(owner.id, owner.name)
|
||||
}
|
||||
disabled={
|
||||
removeOwnerMutation.isPending ||
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
|
||||
{owner.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentOwners.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_last",
|
||||
"마지막 소유자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.owners.remove_title",
|
||||
"소유자 권한 회수",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
@@ -494,7 +480,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.admins.table_name", "이름")}
|
||||
@@ -502,22 +488,19 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.admins.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<TableCell colSpan={2} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentAdmins.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
colSpan={2}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -535,7 +518,8 @@ export function TenantAdminsAndOwnersTab() {
|
||||
currentAdmins.map((admin) => (
|
||||
<TableRow
|
||||
key={admin.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
className="hover:bg-muted/30 transition-colors group cursor-pointer"
|
||||
onClick={() => navigate(`/users/${admin.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -548,46 +532,6 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{admin.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="relative inline-block group/tt">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveAdmin(admin.id, admin.name)
|
||||
}
|
||||
disabled={
|
||||
removeAdminMutation.isPending ||
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
|
||||
{admin.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentAdmins.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_last",
|
||||
"마지막 관리자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.admins.remove_title",
|
||||
"관리자 권한 회수",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Building2, Sparkles } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -11,34 +11,43 @@ 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";
|
||||
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
||||
import { createTenant, fetchAllTenants } from "../../../lib/adminApi";
|
||||
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 = {
|
||||
selectTenantParent?: (tenantId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function TenantCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("COMPANY");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [parentId, setParentId] = useState(
|
||||
() => searchParams.get("parentId") ?? "",
|
||||
);
|
||||
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[]>([]);
|
||||
@@ -47,8 +56,8 @@ function TenantCreatePage() {
|
||||
);
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", { limit: 1000 }],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
const tenants = parentQuery.data?.items ?? [];
|
||||
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
|
||||
@@ -74,10 +83,22 @@ function TenantCreatePage() {
|
||||
"ui.admin.tenants.create.parent_context.pick_required",
|
||||
"상위 테넌트 선택 필요",
|
||||
);
|
||||
const handleParentChange = (nextParentId: string) => {
|
||||
const handleParentChange = useCallback((nextParentId: string) => {
|
||||
setParentId(nextParentId);
|
||||
setParentStepConfirmed(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const testWindow = window as Window &
|
||||
typeof globalThis & {
|
||||
__adminfrontTestHooks?: AdminFrontTestHooks;
|
||||
};
|
||||
const hooks = testWindow.__adminfrontTestHooks ?? {};
|
||||
hooks.selectTenantParent = async (tenantId: string) => {
|
||||
handleParentChange(tenantId);
|
||||
};
|
||||
testWindow.__adminfrontTestHooks = hooks;
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (overrideForceDomains?: string[]) =>
|
||||
@@ -90,7 +111,11 @@ function TenantCreatePage() {
|
||||
status,
|
||||
domains,
|
||||
config: canConfigureHanmacOrg
|
||||
? mergeTenantOrgConfig(undefined, { orgUnitType, visibility })
|
||||
? mergeTenantOrgConfig(undefined, {
|
||||
orgUnitType,
|
||||
visibility,
|
||||
worksmobileExcluded,
|
||||
})
|
||||
: undefined,
|
||||
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||
}),
|
||||
@@ -205,6 +230,14 @@ function TenantCreatePage() {
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tenant-test-select-hanmac-parent"
|
||||
hidden
|
||||
onClick={() => handleParentChange("family-1")}
|
||||
>
|
||||
test-select-hanmac-parent
|
||||
</button>
|
||||
</div>
|
||||
{canConfigureHanmacOrg && (
|
||||
<>
|
||||
@@ -257,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,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage";
|
||||
|
||||
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,18 +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";
|
||||
|
||||
export function canShowWorksmobileEntry(tenant?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
parentId?: string | null;
|
||||
}) {
|
||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
||||
}
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
|
||||
function TenantDetailPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
@@ -30,9 +22,8 @@ function TenantDetailPage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const canAccessSchema =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccessSchema = profileRole === "super_admin";
|
||||
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
const isOrganizationTab = location.pathname.includes("/organization");
|
||||
@@ -131,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
@@ -52,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";
|
||||
@@ -238,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("");
|
||||
@@ -513,7 +513,7 @@ function TenantGroupsPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.name", "NAME")}
|
||||
@@ -610,7 +610,7 @@ function TenantGroupsPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.name", "이름")}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
filterTenantsByScope,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
tenantMatchesListSearch,
|
||||
} from "./tenantListView";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
name: string,
|
||||
slug: string,
|
||||
parentId?: string,
|
||||
): TenantSummary {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
parentId,
|
||||
type: parentId ? "ORGANIZATION" : "COMPANY",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
const tenants = [
|
||||
tenant("company-1", "한맥기술", "hanmac"),
|
||||
tenant("dept-1", "기술기획", "planning", "company-1"),
|
||||
tenant("team-1", "플랫폼팀", "platform", "dept-1"),
|
||||
tenant("company-2", "삼안", "saman"),
|
||||
];
|
||||
|
||||
describe("TenantListPage tenant list helpers", () => {
|
||||
it("selects a parent tenant together with every descendant", () => {
|
||||
expect(
|
||||
resolveTenantSelectionIds({
|
||||
currentIds: [],
|
||||
tenant: tenants[0],
|
||||
checked: true,
|
||||
tenants,
|
||||
deletableTenants: tenants,
|
||||
}),
|
||||
).toEqual(["company-1", "dept-1", "team-1"]);
|
||||
});
|
||||
|
||||
it("removes a parent tenant together with every descendant", () => {
|
||||
expect(
|
||||
resolveTenantSelectionIds({
|
||||
currentIds: ["company-1", "dept-1", "team-1", "company-2"],
|
||||
tenant: tenants[0],
|
||||
checked: false,
|
||||
tenants,
|
||||
deletableTenants: tenants,
|
||||
}),
|
||||
).toEqual(["company-2"]);
|
||||
});
|
||||
|
||||
it("filters to descendants of the selected scope tenant", () => {
|
||||
expect(
|
||||
filterTenantsByScope(tenants, "company-1").map((item) => item.id),
|
||||
).toEqual(["dept-1", "team-1"]);
|
||||
});
|
||||
|
||||
it("searches tenants by name, slug, and UUID", () => {
|
||||
expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true);
|
||||
expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true);
|
||||
expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true);
|
||||
});
|
||||
|
||||
it("can return tree rows or same-level table rows", () => {
|
||||
expect(getTenantViewRows(tenants, "tree").map((row) => row.depth)).toEqual([
|
||||
0, 1, 2, 0,
|
||||
]);
|
||||
expect(getTenantViewRows(tenants, "table").map((row) => row.depth)).toEqual(
|
||||
[0, 0, 0, 0],
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,47 +18,43 @@ import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
approveTenant,
|
||||
deleteTenant,
|
||||
fetchAllTenants,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
updateTenant,
|
||||
} from "../../../lib/adminApi";
|
||||
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({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
const [name, setName] = useState("");
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage";
|
||||
import { createSchemaField, normalizeSchemaField } from "./tenantSchemaFields";
|
||||
|
||||
describe("TenantSchemaPage schema field helpers", () => {
|
||||
it("creates text fields without varchar maxLength policy", () => {
|
||||
|
||||
@@ -16,81 +16,13 @@ import { Label } from "../../../components/ui/label";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
export type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
export type SchemaField = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: SchemaFieldType;
|
||||
required: boolean;
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function isSchemaFieldType(value: unknown): value is SchemaFieldType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "date" ||
|
||||
value === "float" ||
|
||||
value === "datetime"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSchemaField(field: unknown): SchemaField {
|
||||
const source =
|
||||
typeof field === "object" && field !== null
|
||||
? (field as Record<string, unknown>)
|
||||
: {};
|
||||
const type = isSchemaFieldType(source.type) ? source.type : "text";
|
||||
const isLoginId = Boolean(source.isLoginId);
|
||||
|
||||
return {
|
||||
id: typeof source.id === "string" ? source.id : createFieldId(),
|
||||
key: typeof source.key === "string" ? source.key : "",
|
||||
label: typeof source.label === "string" ? source.label : "",
|
||||
type,
|
||||
required: Boolean(source.required),
|
||||
adminOnly: Boolean(source.adminOnly),
|
||||
validation: typeof source.validation === "string" ? source.validation : "",
|
||||
unsigned: Boolean(source.unsigned),
|
||||
isLoginId,
|
||||
indexed: isLoginId || Boolean(source.indexed),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSchemaField(): SchemaField {
|
||||
return {
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
indexed: false,
|
||||
};
|
||||
}
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import {
|
||||
createSchemaField,
|
||||
isSchemaFieldType,
|
||||
normalizeSchemaField,
|
||||
type SchemaField,
|
||||
} from "./tenantSchemaFields";
|
||||
|
||||
export function TenantSchemaPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
@@ -101,8 +33,8 @@ export function TenantSchemaPage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const canAccess =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccess = profileRole === "super_admin";
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
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,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -18,7 +23,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { fetchTenants } from "../../../lib/adminApi";
|
||||
import { fetchAllTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantSubTenantsPage() {
|
||||
@@ -27,7 +32,7 @@ function TenantSubTenantsPage() {
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["sub-tenants", tenantId],
|
||||
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
|
||||
queryFn: () => fetchAllTenants({ parentId: tenantId ?? undefined }),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
@@ -58,10 +63,10 @@ function TenantSubTenantsPage() {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.name", "NAME")}
|
||||
@@ -72,16 +77,13 @@ function TenantSubTenantsPage() {
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.sub.table.action", "ACTION")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subTenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -92,7 +94,11 @@ function TenantSubTenantsPage() {
|
||||
</TableRow>
|
||||
)}
|
||||
{subTenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableRow
|
||||
key={tenant.id}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
<TableCell className="font-semibold">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
@@ -108,16 +114,6 @@ function TenantSubTenantsPage() {
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
|
||||
<ArrowRight size={12} className="ml-1" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
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 { Link, useParams } from "react-router-dom";
|
||||
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";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -18,12 +11,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -38,6 +25,7 @@ import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantUsersPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -78,7 +66,7 @@ function TenantUsersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemoveMember = (userId: string, userName: string) => {
|
||||
const _handleRemoveMember = (userId: string, userName: string) => {
|
||||
if (!tenantSlug) return;
|
||||
if (
|
||||
window.confirm(
|
||||
@@ -123,7 +111,7 @@ function TenantUsersPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.name", "NAME")}
|
||||
@@ -137,15 +125,12 @@ function TenantUsersPage() {
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
{t("ui.admin.tenants.members.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-20">
|
||||
<TableCell colSpan={4} className="text-center py-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2
|
||||
className="animate-spin text-muted-foreground"
|
||||
@@ -160,7 +145,7 @@ function TenantUsersPage() {
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -171,7 +156,11 @@ function TenantUsersPage() {
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => navigate(`/users/${user.id}`)}
|
||||
>
|
||||
<TableCell className="font-semibold">
|
||||
{user.name}
|
||||
</TableCell>
|
||||
@@ -198,43 +187,6 @@ function TenantUsersPage() {
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/users/${user.id}`}>
|
||||
<User size={14} className="mr-2" />
|
||||
{t(
|
||||
"ui.admin.tenants.members.view_profile",
|
||||
"상세 정보",
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() =>
|
||||
handleRemoveMember(user.id, user.name)
|
||||
}
|
||||
disabled={removeTenantMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="mr-2" />
|
||||
{t(
|
||||
"ui.admin.tenants.members.remove",
|
||||
"조직에서 제외",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -4,22 +4,31 @@ import {
|
||||
canCreateWorksmobileRow,
|
||||
canOpenWorksmobilePasswordManage,
|
||||
canSelectWorksmobileRow,
|
||||
comparisonFilterOptions,
|
||||
filterVisibleWorksmobileComparisonRows,
|
||||
filterWorksmobileComparisonRows,
|
||||
filterWorksmobileComparisonRowsBySearch,
|
||||
formatWorksmobileOrgDetails,
|
||||
formatWorksmobilePersonName,
|
||||
formatWorksmobileUpdateDetails,
|
||||
getDefaultGroupComparisonFilters,
|
||||
getDefaultUserComparisonFilters,
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
isImmutableWorksmobileAccount,
|
||||
summarizeWorksmobileComparison,
|
||||
userFilterOptions,
|
||||
} from "./TenantWorksmobilePage";
|
||||
} from "./worksmobileComparison";
|
||||
|
||||
describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
it("summarizes comparison rows by status", () => {
|
||||
const summary = summarizeWorksmobileComparison([
|
||||
{ resourceType: "USER", status: "matched" },
|
||||
{ resourceType: "GROUP", status: "needs_update" },
|
||||
{ resourceType: "USER", status: "missing_in_worksmobile" },
|
||||
{ resourceType: "USER", status: "missing_in_baron" },
|
||||
{ resourceType: "USER", status: "missing_external_key" },
|
||||
@@ -27,8 +36,9 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
]);
|
||||
|
||||
expect(summary).toEqual({
|
||||
total: 5,
|
||||
total: 6,
|
||||
matched: 1,
|
||||
needsUpdate: 1,
|
||||
missingInWorksmobile: 1,
|
||||
missingInBaron: 2,
|
||||
missingExternalKey: 1,
|
||||
@@ -46,6 +56,9 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
|
||||
"ex_key 없음",
|
||||
);
|
||||
expect(getWorksmobileComparisonStatusLabel("needs_update")).toBe(
|
||||
"업데이트 필요",
|
||||
);
|
||||
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
|
||||
"unknown_status",
|
||||
);
|
||||
@@ -143,6 +156,42 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides protected WORKS member accounts from comparison lists", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "su-@samaneng.com",
|
||||
worksmobileId: "works-su",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronEmail: "CYHAN1@HANMACENG.CO.KR",
|
||||
baronId: "baron-cyhan1",
|
||||
worksmobileEmail: "cyhan1@hanmaceng.co.kr",
|
||||
worksmobileId: "works-cyhan1",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "normal@samaneng.com",
|
||||
worksmobileId: "works-normal",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "su-@samaneng.com",
|
||||
worksmobileId: "works-group",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterVisibleWorksmobileComparisonRows(rows)).toEqual([
|
||||
rows[2],
|
||||
rows[3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps row selection keys separate from Baron action ids", () => {
|
||||
const rows = [
|
||||
{
|
||||
@@ -231,7 +280,8 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
|
||||
).toEqual([rows[0], rows[1], rows[3]]);
|
||||
expect(filterWorksmobileComparisonRows(rows, [])).toEqual(rows);
|
||||
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
|
||||
expect(filterWorksmobileComparisonRows(rows, [])).toEqual([]);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, [
|
||||
"baron_only",
|
||||
@@ -239,16 +289,209 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
"matched",
|
||||
]),
|
||||
).toEqual(rows);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(
|
||||
rows,
|
||||
["baron_only", "works_only", "matched"],
|
||||
true,
|
||||
),
|
||||
).toEqual([rows[0], rows[2], rows[3]]);
|
||||
});
|
||||
|
||||
it("narrows works-only rows to missing external key rows from the detail filter", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-only",
|
||||
baronName: "Baron only",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
worksmobileName: "WORKS only",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "missing-external-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "matched",
|
||||
baronId: "matched",
|
||||
worksmobileId: "works-matched",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, ["works_only"], false),
|
||||
).toEqual([rows[1], rows[2]]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["works_only"], true)).toEqual(
|
||||
[rows[2]],
|
||||
);
|
||||
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["baron_only"], true)).toEqual(
|
||||
[rows[0]],
|
||||
);
|
||||
});
|
||||
|
||||
it("filters comparison rows by names and identifiers in real time", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronId: "baron-user-uuid",
|
||||
baronName: "홍길동",
|
||||
worksmobileName: "Hong Gildong",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-org-uuid",
|
||||
worksmobileName: "기술연구소",
|
||||
worksmobileParentName: "한맥가족",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-org-uuid",
|
||||
baronSlug: "baron-group-design",
|
||||
baronName: "디자인팀",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "")).toEqual(rows);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "홍길동")).toEqual([
|
||||
rows[0],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "WORKS-ORG")).toEqual([
|
||||
rows[1],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "design")).toEqual([
|
||||
rows[2],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "없음")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only selected missing-external-key WORKS orgunit ids for delete", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-missing-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-user-missing-key",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getWorksmobileSelectedMissingExternalKeyOrgUnitIds(rows, [
|
||||
getWorksmobileRowSelectionKey(rows[0]),
|
||||
getWorksmobileRowSelectionKey(rows[1]),
|
||||
getWorksmobileRowSelectionKey(rows[2]),
|
||||
]),
|
||||
).toEqual(["works-missing-key"]);
|
||||
});
|
||||
|
||||
it("returns selected WORKS-only orgunit ids for Baron SSOT cleanup", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-missing-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
externalKey: "legacy-external-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "matched",
|
||||
baronId: "baron-matched",
|
||||
worksmobileId: "works-matched",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds(
|
||||
rows,
|
||||
rows.map(getWorksmobileRowSelectionKey),
|
||||
),
|
||||
).toEqual(["works-missing-key", "works-only"]);
|
||||
});
|
||||
|
||||
it("orders user comparison filter options from Baron-only first", () => {
|
||||
expect(userFilterOptions.map((option) => option.value)).toEqual([
|
||||
"baron_only",
|
||||
"needs_update",
|
||||
"works_only",
|
||||
"matched",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps all organization/group comparison filter labels available", () => {
|
||||
expect(comparisonFilterOptions).toEqual([
|
||||
{ value: "baron_only", label: "바론에만 있음" },
|
||||
{ value: "needs_update", label: "업데이트 필요" },
|
||||
{ value: "works_only", label: "웍스에만 있음" },
|
||||
{ value: "matched", label: "양쪽 다 있음" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows update-needed group rows by default", () => {
|
||||
const rows = [
|
||||
{ resourceType: "GROUP", status: "needs_update", baronId: "org-1" },
|
||||
{ resourceType: "GROUP", status: "matched", baronId: "org-2" },
|
||||
];
|
||||
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, getDefaultGroupComparisonFilters()),
|
||||
).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({
|
||||
resourceType: "GROUP",
|
||||
status: "needs_update",
|
||||
baronId: "818c856b-9545-442f-b827-d1c569f200b0",
|
||||
baronName: "삼안기술개발센터(조직도용)",
|
||||
worksmobileName: "기술개발센터(조직도용)",
|
||||
baronParentId: "9caf62e1-297d-4e8f-870b-61780998bbeb",
|
||||
baronParentWorksmobileId: "works-saman",
|
||||
baronParentWorksmobileName: "삼안",
|
||||
worksmobileParentId: "works-other",
|
||||
worksmobileParentName: "다른 상위",
|
||||
}),
|
||||
).toEqual([
|
||||
"이름: 기술개발센터(조직도용) -> 삼안기술개발센터(조직도용)",
|
||||
"상위: 다른 상위 -> 삼안",
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats WORKS account name with level on one line", () => {
|
||||
expect(
|
||||
formatWorksmobilePersonName({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
131
adminfront/src/features/tenants/routes/tenantListView.ts
Normal file
131
adminfront/src/features/tenants/routes/tenantListView.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
|
||||
|
||||
export type TenantViewMode = "tree" | "table";
|
||||
export type TenantViewRow = TenantNode & { depth: number };
|
||||
|
||||
export function tenantMatchesListSearch(
|
||||
tenant: Pick<TenantSummary, "id" | "name" | "slug" | "type">,
|
||||
search: string,
|
||||
) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) return true;
|
||||
|
||||
return [tenant.name, tenant.slug, tenant.id, tenant.type]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
}
|
||||
|
||||
function collectTenantTreeRows(
|
||||
nodes: TenantNode[],
|
||||
depth: number,
|
||||
rows: TenantViewRow[],
|
||||
) {
|
||||
for (const node of nodes) {
|
||||
rows.push({ ...node, depth });
|
||||
collectTenantTreeRows(node.children, depth + 1, rows);
|
||||
}
|
||||
}
|
||||
|
||||
function collectTenantDescendantIds(
|
||||
tenantId: string,
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
const childrenByParent = new Map<string, TenantSummary[]>();
|
||||
for (const tenant of tenants) {
|
||||
if (!tenant.parentId) continue;
|
||||
const children = childrenByParent.get(tenant.parentId) ?? [];
|
||||
children.push(tenant);
|
||||
childrenByParent.set(tenant.parentId, children);
|
||||
}
|
||||
|
||||
const ids: string[] = [];
|
||||
const visitedIds = new Set<string>();
|
||||
const visit = (parentId: string) => {
|
||||
for (const child of childrenByParent.get(parentId) ?? []) {
|
||||
if (visitedIds.has(child.id)) continue;
|
||||
visitedIds.add(child.id);
|
||||
ids.push(child.id);
|
||||
visit(child.id);
|
||||
}
|
||||
};
|
||||
visit(tenantId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function filterTenantsByScope(
|
||||
tenants: TenantSummary[],
|
||||
scopeTenantId: string,
|
||||
) {
|
||||
if (!scopeTenantId) return tenants;
|
||||
const descendantIds = new Set(
|
||||
collectTenantDescendantIds(scopeTenantId, tenants),
|
||||
);
|
||||
return tenants.filter((tenant) => descendantIds.has(tenant.id));
|
||||
}
|
||||
|
||||
export function getTenantViewRows(
|
||||
tenants: TenantSummary[],
|
||||
viewMode: TenantViewMode,
|
||||
scopeTenantId = "",
|
||||
isSearchActive = false,
|
||||
): TenantViewRow[] {
|
||||
const { subTree } = buildTenantFullTree(
|
||||
tenants,
|
||||
scopeTenantId || undefined,
|
||||
isSearchActive,
|
||||
);
|
||||
const treeRows: TenantViewRow[] = [];
|
||||
collectTenantTreeRows(subTree, 0, treeRows);
|
||||
|
||||
if (viewMode === "tree") {
|
||||
return treeRows;
|
||||
}
|
||||
|
||||
const rowsById = new Map(treeRows.map((row) => [row.id, row]));
|
||||
const flatSource = scopeTenantId
|
||||
? filterTenantsByScope(tenants, scopeTenantId)
|
||||
: tenants;
|
||||
|
||||
return flatSource.map((tenant) => ({
|
||||
...(rowsById.get(tenant.id) ?? {
|
||||
...tenant,
|
||||
children: [],
|
||||
recursiveMemberCount: Number(tenant.memberCount) || 0,
|
||||
}),
|
||||
depth: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveTenantSelectionIds({
|
||||
currentIds,
|
||||
tenant,
|
||||
checked,
|
||||
tenants,
|
||||
deletableTenants,
|
||||
}: {
|
||||
currentIds: string[];
|
||||
tenant: TenantSummary;
|
||||
checked: boolean;
|
||||
tenants: TenantSummary[];
|
||||
deletableTenants: TenantSummary[];
|
||||
}) {
|
||||
const allowedIds = new Set(deletableTenants.map((item) => item.id));
|
||||
const targetIds = [
|
||||
tenant.id,
|
||||
...collectTenantDescendantIds(tenant.id, tenants),
|
||||
].filter((id) => allowedIds.has(id));
|
||||
const next = new Set(currentIds.filter((id) => allowedIds.has(id)));
|
||||
|
||||
if (checked) {
|
||||
for (const id of targetIds) {
|
||||
next.add(id);
|
||||
}
|
||||
} else {
|
||||
for (const id of targetIds) {
|
||||
next.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(next);
|
||||
}
|
||||
74
adminfront/src/features/tenants/routes/tenantSchemaFields.ts
Normal file
74
adminfront/src/features/tenants/routes/tenantSchemaFields.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
export type SchemaField = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: SchemaFieldType;
|
||||
required: boolean;
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function isSchemaFieldType(value: unknown): value is SchemaFieldType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "date" ||
|
||||
value === "float" ||
|
||||
value === "datetime"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSchemaField(field: unknown): SchemaField {
|
||||
const source =
|
||||
typeof field === "object" && field !== null
|
||||
? (field as Record<string, unknown>)
|
||||
: {};
|
||||
const type = isSchemaFieldType(source.type) ? source.type : "text";
|
||||
const isLoginId = Boolean(source.isLoginId);
|
||||
|
||||
return {
|
||||
id: typeof source.id === "string" ? source.id : createFieldId(),
|
||||
key: typeof source.key === "string" ? source.key : "",
|
||||
label: typeof source.label === "string" ? source.label : "",
|
||||
type,
|
||||
required: Boolean(source.required),
|
||||
adminOnly: Boolean(source.adminOnly),
|
||||
validation: typeof source.validation === "string" ? source.validation : "",
|
||||
unsigned: Boolean(source.unsigned),
|
||||
isLoginId,
|
||||
indexed: isLoginId || Boolean(source.indexed),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSchemaField(): SchemaField {
|
||||
return {
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
indexed: false,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
430
adminfront/src/features/tenants/routes/worksmobileComparison.ts
Normal file
430
adminfront/src/features/tenants/routes/worksmobileComparison.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import type { WorksmobileComparisonItem } from "../../../lib/adminApi";
|
||||
|
||||
export type WorksmobileComparisonFilter =
|
||||
| "works_only"
|
||||
| "baron_only"
|
||||
| "needs_update"
|
||||
| "matched";
|
||||
|
||||
export type WorksmobileComparisonSummary = {
|
||||
total: number;
|
||||
matched: number;
|
||||
needsUpdate: number;
|
||||
missingInWorksmobile: number;
|
||||
missingInBaron: number;
|
||||
missingExternalKey: number;
|
||||
};
|
||||
|
||||
export type WorksmobileComparisonColumnKey =
|
||||
| "status"
|
||||
| "baronId"
|
||||
| "baron"
|
||||
| "baronOrg"
|
||||
| "worksmobileId"
|
||||
| "externalKey"
|
||||
| "worksmobileDomain"
|
||||
| "worksmobile"
|
||||
| "worksmobileOrg"
|
||||
| "manage";
|
||||
|
||||
export type WorksmobileComparisonColumnVisibility = Record<
|
||||
WorksmobileComparisonColumnKey,
|
||||
boolean
|
||||
>;
|
||||
|
||||
export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
|
||||
return {
|
||||
status: true,
|
||||
baronId: false,
|
||||
baron: true,
|
||||
baronOrg: true,
|
||||
worksmobileId: false,
|
||||
externalKey: false,
|
||||
worksmobileDomain: true,
|
||||
worksmobile: true,
|
||||
worksmobileOrg: true,
|
||||
manage: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeWorksmobileComparison(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
): WorksmobileComparisonSummary {
|
||||
return rows.reduce<WorksmobileComparisonSummary>(
|
||||
(summary, row) => {
|
||||
if (row.status === "matched") {
|
||||
summary.matched += 1;
|
||||
} else if (row.status === "needs_update") {
|
||||
summary.needsUpdate += 1;
|
||||
} else if (row.status === "missing_in_worksmobile") {
|
||||
summary.missingInWorksmobile += 1;
|
||||
} else if (row.status === "missing_in_baron") {
|
||||
summary.missingInBaron += 1;
|
||||
} else if (row.status === "missing_external_key") {
|
||||
summary.missingExternalKey += 1;
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
total: rows.length,
|
||||
matched: 0,
|
||||
needsUpdate: 0,
|
||||
missingInWorksmobile: 0,
|
||||
missingInBaron: 0,
|
||||
missingExternalKey: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorksmobileComparisonStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "matched":
|
||||
return "일치";
|
||||
case "missing_in_worksmobile":
|
||||
return "WORKS 없음";
|
||||
case "needs_update":
|
||||
return "업데이트 필요";
|
||||
case "missing_in_baron":
|
||||
return "Baron 없음";
|
||||
case "missing_external_key":
|
||||
return "ex_key 없음";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
|
||||
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
|
||||
}
|
||||
|
||||
const immutableWorksmobileAccountEmails = new Set([
|
||||
"cyhan@samaneng.com",
|
||||
"cyhan1@hanmaceng.co.kr",
|
||||
"cyhan2@baroncs.co.kr",
|
||||
"cyhan3@brsw.kr",
|
||||
"su-@samaneng.com",
|
||||
]);
|
||||
|
||||
const hiddenWorksmobileMemberEmails = new Set([
|
||||
"su-@samaneng.com",
|
||||
"cyhan1@hanmaceng.co.kr",
|
||||
"cyhan2@baroncs.co.kr",
|
||||
"cyhan3@brsw.kr",
|
||||
]);
|
||||
|
||||
function normalizeWorksmobileEmail(email?: string) {
|
||||
return email?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) {
|
||||
return (
|
||||
row.resourceType === "USER" &&
|
||||
immutableWorksmobileAccountEmails.has(
|
||||
normalizeWorksmobileEmail(row.worksmobileEmail),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function isHiddenWorksmobileMember(row: WorksmobileComparisonItem) {
|
||||
if (row.resourceType !== "USER") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [row.worksmobileEmail, row.baronEmail].some((email) =>
|
||||
hiddenWorksmobileMemberEmails.has(normalizeWorksmobileEmail(email)),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterVisibleWorksmobileComparisonRows(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
) {
|
||||
return rows.filter((row) => !isHiddenWorksmobileMember(row));
|
||||
}
|
||||
|
||||
export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) {
|
||||
if (row.baronId) {
|
||||
return `${row.resourceType}:baron:${row.baronId}`;
|
||||
}
|
||||
if (row.worksmobileId) {
|
||||
return `${row.resourceType}:works:${row.worksmobileId}`;
|
||||
}
|
||||
if (row.externalKey) {
|
||||
return `${row.resourceType}:external:${row.externalKey}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) {
|
||||
return (
|
||||
Boolean(getWorksmobileRowSelectionKey(row)) &&
|
||||
!isImmutableWorksmobileAccount(row)
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedActionIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
const selected = new Set(selectedKeys);
|
||||
return rows
|
||||
.filter((row) => selected.has(getWorksmobileRowSelectionKey(row)))
|
||||
.map((row) => row.baronId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
return getWorksmobileSelectedWorksOnlyOrgUnitIds(rows, selectedKeys).filter(
|
||||
(id) =>
|
||||
rows.some(
|
||||
(row) =>
|
||||
row.worksmobileId === id && row.status === "missing_external_key",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedWorksOnlyOrgUnitIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
const selected = new Set(selectedKeys);
|
||||
return rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.resourceType === "GROUP" &&
|
||||
(row.status === "missing_external_key" ||
|
||||
row.status === "missing_in_baron") &&
|
||||
selected.has(getWorksmobileRowSelectionKey(row)),
|
||||
)
|
||||
.map((row) => row.worksmobileId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
const worksmobileComparisonSearchFields: Array<
|
||||
keyof WorksmobileComparisonItem
|
||||
> = [
|
||||
"baronId",
|
||||
"baronSlug",
|
||||
"baronName",
|
||||
"baronEmail",
|
||||
"baronPrimaryOrgId",
|
||||
"baronPrimaryOrgSlug",
|
||||
"baronPrimaryOrgName",
|
||||
"baronParentId",
|
||||
"baronParentSlug",
|
||||
"baronParentName",
|
||||
"worksmobileId",
|
||||
"externalKey",
|
||||
"worksmobileName",
|
||||
"worksmobileEmail",
|
||||
"worksmobileLevelId",
|
||||
"worksmobileLevelName",
|
||||
"worksmobileTask",
|
||||
"worksmobileDomainId",
|
||||
"worksmobileDomainName",
|
||||
"worksmobilePrimaryOrgId",
|
||||
"worksmobilePrimaryOrgName",
|
||||
"worksmobilePrimaryOrgPositionId",
|
||||
"worksmobilePrimaryOrgPositionName",
|
||||
"baronParentWorksmobileId",
|
||||
"baronParentWorksmobileName",
|
||||
"baronParentWorksmobileEmail",
|
||||
"worksmobileParentId",
|
||||
"worksmobileParentName",
|
||||
"worksmobileParentEmail",
|
||||
"worksmobileParentExternalKey",
|
||||
];
|
||||
|
||||
export function filterWorksmobileComparisonRowsBySearch(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
search: string,
|
||||
) {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) =>
|
||||
worksmobileComparisonSearchFields.some((field) => {
|
||||
const value = row[field];
|
||||
if (value === undefined || value === null) {
|
||||
return false;
|
||||
}
|
||||
return String(value).toLowerCase().includes(keyword);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterWorksmobileComparisonRows(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
filters: WorksmobileComparisonFilter[],
|
||||
onlyMissingExternalKey = false,
|
||||
) {
|
||||
const allowedStatuses = new Set(
|
||||
filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
|
||||
);
|
||||
if (filters.includes("works_only")) {
|
||||
if (onlyMissingExternalKey) {
|
||||
allowedStatuses.delete("missing_in_baron");
|
||||
}
|
||||
allowedStatuses.add("missing_external_key");
|
||||
}
|
||||
return rows.filter((row) => allowedStatuses.has(row.status));
|
||||
}
|
||||
|
||||
export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) {
|
||||
return [
|
||||
row.worksmobileName,
|
||||
row.worksmobileLevelName ?? row.worksmobileLevelId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
|
||||
const details: string[] = [];
|
||||
const position =
|
||||
row.worksmobilePrimaryOrgPositionName ??
|
||||
row.worksmobilePrimaryOrgPositionId;
|
||||
if (position) {
|
||||
details.push(`직책 ${position}`);
|
||||
}
|
||||
if (row.worksmobileTask) {
|
||||
details.push(`직무 ${row.worksmobileTask}`);
|
||||
}
|
||||
if (typeof row.worksmobilePrimaryOrgIsManager === "boolean") {
|
||||
details.push(row.worksmobilePrimaryOrgIsManager ? "조직장" : "조직장 아님");
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||
if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) {
|
||||
return [`최근 실패: ${row.worksmobileLastError}`];
|
||||
}
|
||||
if (row.status !== "needs_update") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const details: string[] = [];
|
||||
const baronName = row.baronName?.trim();
|
||||
const worksmobileName = row.worksmobileName?.trim();
|
||||
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 ??
|
||||
row.baronParentName ??
|
||||
row.baronParentWorksmobileId ??
|
||||
row.baronParentId ??
|
||||
"";
|
||||
const actualParent =
|
||||
row.worksmobileParentName ??
|
||||
row.worksmobileParentExternalKey ??
|
||||
row.worksmobileParentId ??
|
||||
"";
|
||||
const expectedParentKey =
|
||||
row.baronParentWorksmobileId ?? row.baronParentId ?? "";
|
||||
const actualParentKey =
|
||||
row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? "";
|
||||
if (expectedParentKey !== actualParentKey) {
|
||||
details.push(
|
||||
`상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
export function buildWorksmobilePasswordManageUrl({
|
||||
tenantId,
|
||||
domainId,
|
||||
userIdNo,
|
||||
}: {
|
||||
tenantId?: string;
|
||||
domainId?: number;
|
||||
userIdNo?: string;
|
||||
}) {
|
||||
const normalizedTenantId = tenantId?.trim();
|
||||
const normalizedUserIdNo = userIdNo?.trim();
|
||||
if (
|
||||
!normalizedTenantId ||
|
||||
!domainId ||
|
||||
domainId <= 0 ||
|
||||
!normalizedUserIdNo
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
|
||||
url.searchParams.set("usage", "admin");
|
||||
url.searchParams.set("targetUserTenantId", normalizedTenantId);
|
||||
url.searchParams.set("targetUserDomainId", String(domainId));
|
||||
url.searchParams.set("targetUserIdNo", normalizedUserIdNo);
|
||||
url.searchParams.set(
|
||||
"accessUrl",
|
||||
"https://admin.worksmobile.com/assets/self-close.html",
|
||||
);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function canOpenWorksmobilePasswordManage(
|
||||
row: WorksmobileComparisonItem,
|
||||
tenantId?: string,
|
||||
) {
|
||||
return (
|
||||
row.resourceType === "USER" &&
|
||||
!isImmutableWorksmobileAccount(row) &&
|
||||
Boolean(
|
||||
buildWorksmobilePasswordManageUrl({
|
||||
tenantId,
|
||||
domainId: row.worksmobileDomainId,
|
||||
userIdNo: row.worksmobileId,
|
||||
}),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const comparisonFilterOptions: Array<{
|
||||
value: WorksmobileComparisonFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "baron_only", label: "바론에만 있음" },
|
||||
{ value: "needs_update", label: "업데이트 필요" },
|
||||
{ value: "works_only", label: "웍스에만 있음" },
|
||||
{ value: "matched", label: "양쪽 다 있음" },
|
||||
];
|
||||
|
||||
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"];
|
||||
}
|
||||
|
||||
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
|
||||
{
|
||||
baron_only: ["missing_in_worksmobile"],
|
||||
needs_update: ["needs_update"],
|
||||
works_only: ["missing_in_baron"],
|
||||
matched: ["matched"],
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user