forked from baron/baron-sso
Compare commits
189 Commits
backup1
...
feature/87
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
**/*.log
|
||||||
**/*.swp
|
**/*.swp
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
**/.pnpm-store
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ BACKEND_LOG_LEVEL=
|
|||||||
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
|
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 System Configuration
|
||||||
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
|
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
|
||||||
AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
|
AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
- name: Build and push adminfront RC image
|
- name: Build and push adminfront RC image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./adminfront
|
context: .
|
||||||
file: ./adminfront/Dockerfile
|
file: ./adminfront/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
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
|
- name: Build and push devfront RC image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./devfront
|
context: .
|
||||||
file: ./devfront/Dockerfile
|
file: ./devfront/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
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
|
- name: Build and push orgfront RC image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./orgfront
|
context: .
|
||||||
file: ./orgfront/Dockerfile
|
file: ./orgfront/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||||
|
|||||||
@@ -57,11 +57,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: |
|
|
||||||
adminfront/package-lock.json
|
|
||||||
devfront/package-lock.json
|
|
||||||
orgfront/package-lock.json
|
|
||||||
|
|
||||||
- name: i18n resource check
|
- name: i18n resource check
|
||||||
run: |
|
run: |
|
||||||
@@ -91,7 +86,8 @@ jobs:
|
|||||||
- name: Install adminfront dependencies
|
- name: Install adminfront dependencies
|
||||||
run: |
|
run: |
|
||||||
cd adminfront
|
cd adminfront
|
||||||
npm ci
|
npm install -g pnpm
|
||||||
|
pnpm install -C ../common --no-frozen-lockfile
|
||||||
|
|
||||||
- name: Biome check adminfront (lint + format)
|
- name: Biome check adminfront (lint + format)
|
||||||
run: |
|
run: |
|
||||||
@@ -102,7 +98,8 @@ jobs:
|
|||||||
- name: Install devfront dependencies
|
- name: Install devfront dependencies
|
||||||
run: |
|
run: |
|
||||||
cd devfront
|
cd devfront
|
||||||
npm ci
|
npm install -g pnpm
|
||||||
|
pnpm install -C ../common --no-frozen-lockfile
|
||||||
|
|
||||||
- name: Biome check devfront (lint + format)
|
- name: Biome check devfront (lint + format)
|
||||||
run: |
|
run: |
|
||||||
@@ -113,7 +110,8 @@ jobs:
|
|||||||
- name: Install orgfront dependencies
|
- name: Install orgfront dependencies
|
||||||
run: |
|
run: |
|
||||||
cd orgfront
|
cd orgfront
|
||||||
npm ci
|
npm install -g pnpm
|
||||||
|
pnpm install -C ../common --no-frozen-lockfile
|
||||||
|
|
||||||
- name: Biome check orgfront (lint + format)
|
- name: Biome check orgfront (lint + format)
|
||||||
run: |
|
run: |
|
||||||
@@ -336,8 +334,7 @@ jobs:
|
|||||||
- name: Get Playwright version
|
- name: Get Playwright version
|
||||||
id: playwright-version
|
id: playwright-version
|
||||||
run: |
|
run: |
|
||||||
cd userfront-e2e
|
node scripts/playwrightPackageVersion.cjs userfront-e2e >> "$GITHUB_OUTPUT"
|
||||||
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache Playwright Browsers
|
- name: Cache Playwright Browsers
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -561,14 +558,11 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: adminfront/package-lock.json
|
|
||||||
|
|
||||||
- name: Get Playwright version
|
- name: Get Playwright version
|
||||||
id: playwright-version
|
id: playwright-version
|
||||||
run: |
|
run: |
|
||||||
cd adminfront
|
node scripts/playwrightPackageVersion.cjs adminfront >> "$GITHUB_OUTPUT"
|
||||||
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache Playwright Browsers
|
- name: Cache Playwright Browsers
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -656,14 +650,12 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: devfront/package-lock.json
|
|
||||||
|
|
||||||
- name: Get Playwright version
|
- name: Get Playwright version
|
||||||
id: playwright-version
|
id: playwright-version
|
||||||
|
working-directory: devfront
|
||||||
run: |
|
run: |
|
||||||
cd devfront
|
node ../scripts/playwrightPackageVersion.cjs . >> "$GITHUB_OUTPUT"
|
||||||
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache Playwright Browsers
|
- name: Cache Playwright Browsers
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -675,13 +667,12 @@ jobs:
|
|||||||
${{ runner.os }}-playwright-
|
${{ runner.os }}-playwright-
|
||||||
|
|
||||||
- name: Install devfront dependencies
|
- name: Install devfront dependencies
|
||||||
|
working-directory: devfront
|
||||||
run: |
|
run: |
|
||||||
mkdir -p reports
|
mkdir -p ../reports
|
||||||
set +e
|
set +e
|
||||||
cd devfront
|
pnpm install -C ../common --no-frozen-lockfile 2>&1 | tee ../reports/devfront-install.log
|
||||||
npm ci 2>&1 | tee ../reports/devfront-install.log
|
|
||||||
install_exit_code=${PIPESTATUS[0]}
|
install_exit_code=${PIPESTATUS[0]}
|
||||||
cd ..
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if [ "$install_exit_code" -ne 0 ]; then
|
if [ "$install_exit_code" -ne 0 ]; then
|
||||||
@@ -694,23 +685,22 @@ jobs:
|
|||||||
echo "- Exit Code: \`$install_exit_code\`"
|
echo "- Exit Code: \`$install_exit_code\`"
|
||||||
echo
|
echo
|
||||||
echo "## Command"
|
echo "## Command"
|
||||||
echo "\`cd devfront && npm ci\`"
|
echo "\`cd devfront && pnpm install -C ../common --no-frozen-lockfile\`"
|
||||||
echo
|
echo
|
||||||
echo "## Install Log Tail (last 200 lines)"
|
echo "## Install Log Tail (last 200 lines)"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 reports/devfront-install.log
|
tail -n 200 ../reports/devfront-install.log
|
||||||
echo '```'
|
echo '```'
|
||||||
} > reports/devfront-test-failure-report.md
|
} > ../reports/devfront-test-failure-report.md
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Provision browsers for devfront tests
|
- name: Provision browsers for devfront tests
|
||||||
|
working-directory: devfront
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
cd devfront
|
pnpm exec playwright install --with-deps 2>&1 | tee ../reports/devfront-provision.log
|
||||||
npx playwright install --with-deps 2>&1 | tee ../reports/devfront-provision.log
|
|
||||||
provision_exit_code=${PIPESTATUS[0]}
|
provision_exit_code=${PIPESTATUS[0]}
|
||||||
cd ..
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if [ "$provision_exit_code" -ne 0 ]; then
|
if [ "$provision_exit_code" -ne 0 ]; then
|
||||||
@@ -723,26 +713,25 @@ jobs:
|
|||||||
echo "- Exit Code: \`$provision_exit_code\`"
|
echo "- Exit Code: \`$provision_exit_code\`"
|
||||||
echo
|
echo
|
||||||
echo "## Command"
|
echo "## Command"
|
||||||
echo "\`cd devfront && npx playwright install --with-deps\`"
|
echo "\`cd devfront && pnpm exec playwright install --with-deps\`"
|
||||||
echo
|
echo
|
||||||
echo "## Provision Log Tail (last 200 lines)"
|
echo "## Provision Log Tail (last 200 lines)"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 reports/devfront-provision.log
|
tail -n 200 ../reports/devfront-provision.log
|
||||||
echo '```'
|
echo '```'
|
||||||
} > reports/devfront-test-failure-report.md
|
} > ../reports/devfront-test-failure-report.md
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Run devfront tests
|
- name: Run devfront tests
|
||||||
|
working-directory: devfront
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_WORKERS: 2
|
PLAYWRIGHT_WORKERS: 2
|
||||||
run: |
|
run: |
|
||||||
mkdir -p reports
|
mkdir -p ../reports
|
||||||
set +e
|
set +e
|
||||||
cd devfront
|
pnpm test 2>&1 | tee ../reports/devfront-test.log
|
||||||
npm test 2>&1 | tee ../reports/devfront-test.log
|
|
||||||
test_exit_code=${PIPESTATUS[0]}
|
test_exit_code=${PIPESTATUS[0]}
|
||||||
cd ..
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if [ "$test_exit_code" -ne 0 ]; then
|
if [ "$test_exit_code" -ne 0 ]; then
|
||||||
@@ -755,15 +744,15 @@ jobs:
|
|||||||
echo
|
echo
|
||||||
echo "## Commands"
|
echo "## Commands"
|
||||||
echo "1. \`cd devfront\`"
|
echo "1. \`cd devfront\`"
|
||||||
echo "2. \`npm ci\`"
|
echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`"
|
||||||
echo "3. \`npx playwright install --with-deps\`"
|
echo "3. \`pnpm exec playwright install --with-deps\`"
|
||||||
echo "4. \`npm test\`"
|
echo "4. \`pnpm test\`"
|
||||||
echo
|
echo
|
||||||
echo "## Log Tail (last 200 lines)"
|
echo "## Log Tail (last 200 lines)"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 reports/devfront-test.log
|
tail -n 200 ../reports/devfront-test.log
|
||||||
echo '```'
|
echo '```'
|
||||||
} > reports/devfront-test-failure-report.md
|
} > ../reports/devfront-test-failure-report.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit "$test_exit_code"
|
exit "$test_exit_code"
|
||||||
@@ -839,14 +828,11 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: orgfront/package-lock.json
|
|
||||||
|
|
||||||
- name: Get Playwright version
|
- name: Get Playwright version
|
||||||
id: playwright-version
|
id: playwright-version
|
||||||
run: |
|
run: |
|
||||||
cd orgfront
|
node scripts/playwrightPackageVersion.cjs orgfront >> "$GITHUB_OUTPUT"
|
||||||
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache Playwright Browsers
|
- name: Cache Playwright Browsers
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -862,7 +848,8 @@ jobs:
|
|||||||
mkdir -p reports
|
mkdir -p reports
|
||||||
set +e
|
set +e
|
||||||
cd orgfront
|
cd orgfront
|
||||||
npm ci 2>&1 | tee ../reports/orgfront-install.log
|
npm install -g pnpm
|
||||||
|
pnpm install -C ../common --no-frozen-lockfile 2>&1 | tee ../reports/orgfront-install.log
|
||||||
install_exit_code=${PIPESTATUS[0]}
|
install_exit_code=${PIPESTATUS[0]}
|
||||||
cd ..
|
cd ..
|
||||||
set -e
|
set -e
|
||||||
@@ -891,7 +878,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
cd orgfront
|
cd orgfront
|
||||||
npx playwright install --with-deps 2>&1 | tee ../reports/orgfront-provision.log
|
pnpm exec playwright install --with-deps 2>&1 | tee ../reports/orgfront-provision.log
|
||||||
provision_exit_code=${PIPESTATUS[0]}
|
provision_exit_code=${PIPESTATUS[0]}
|
||||||
cd ..
|
cd ..
|
||||||
set -e
|
set -e
|
||||||
@@ -906,7 +893,7 @@ jobs:
|
|||||||
echo "- Exit Code: \`$provision_exit_code\`"
|
echo "- Exit Code: \`$provision_exit_code\`"
|
||||||
echo
|
echo
|
||||||
echo "## Command"
|
echo "## Command"
|
||||||
echo "\`cd orgfront && npx playwright install --with-deps\`"
|
echo "\`cd orgfront && pnpm exec playwright install --with-deps\`"
|
||||||
echo
|
echo
|
||||||
echo "## Provision Log Tail (last 200 lines)"
|
echo "## Provision Log Tail (last 200 lines)"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
@@ -923,7 +910,7 @@ jobs:
|
|||||||
mkdir -p reports
|
mkdir -p reports
|
||||||
set +e
|
set +e
|
||||||
cd orgfront
|
cd orgfront
|
||||||
npm test 2>&1 | tee ../reports/orgfront-test.log
|
pnpm run test 2>&1 | tee ../reports/orgfront-test.log
|
||||||
test_exit_code=${PIPESTATUS[0]}
|
test_exit_code=${PIPESTATUS[0]}
|
||||||
cd ..
|
cd ..
|
||||||
set -e
|
set -e
|
||||||
@@ -939,8 +926,8 @@ jobs:
|
|||||||
echo "## Commands"
|
echo "## Commands"
|
||||||
echo "1. \`cd orgfront\`"
|
echo "1. \`cd orgfront\`"
|
||||||
echo "2. \`npm ci\`"
|
echo "2. \`npm ci\`"
|
||||||
echo "3. \`npx playwright install --with-deps\`"
|
echo "3. \`pnpm exec playwright install --with-deps\`"
|
||||||
echo "4. \`npm test\`"
|
echo "4. \`pnpm run test\`"
|
||||||
echo
|
echo
|
||||||
echo "## Log Tail (last 200 lines)"
|
echo "## Log Tail (last 200 lines)"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ jobs:
|
|||||||
APP_ENV=stage
|
APP_ENV=stage
|
||||||
BACKEND_LOG_LEVEL=debug
|
BACKEND_LOG_LEVEL=debug
|
||||||
CLIENT_LOG_DEBUG=true
|
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
|
TZ=Asia/Seoul
|
||||||
IDP_PROVIDER=ory
|
IDP_PROVIDER=ory
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ jobs:
|
|||||||
APP_ENV=stage
|
APP_ENV=stage
|
||||||
BACKEND_LOG_LEVEL=debug
|
BACKEND_LOG_LEVEL=debug
|
||||||
CLIENT_LOG_DEBUG=true
|
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
|
TZ=Asia/Seoul
|
||||||
IDP_PROVIDER=ory
|
IDP_PROVIDER=ory
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# General
|
# General
|
||||||
.env
|
.env
|
||||||
|
.env_backup
|
||||||
.temp
|
.temp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
@@ -17,6 +18,8 @@ config/.generated/
|
|||||||
reports
|
reports
|
||||||
reports/*
|
reports/*
|
||||||
config/*.pem
|
config/*.pem
|
||||||
|
common/node_modules
|
||||||
|
common/.baron-deps-install.lock
|
||||||
|
|
||||||
# Docker Services Data (Volumes)
|
# Docker Services Data (Volumes)
|
||||||
postgres_data/
|
postgres_data/
|
||||||
@@ -50,3 +53,4 @@ orgfront/playwright-report/
|
|||||||
orgfront/node_modules/
|
orgfront/node_modules/
|
||||||
orgfront/dist/
|
orgfront/dist/
|
||||||
orgfront/.vite/
|
orgfront/.vite/
|
||||||
|
.pnpm-store
|
||||||
|
|||||||
23
.playwright-mcp/page-2026-05-20T02-00-01-354Z.yml
Normal file
23
.playwright-mcp/page-2026-05-20T02-00-01-354Z.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
- generic [ref=e4]:
|
||||||
|
- generic [ref=e5]:
|
||||||
|
- img [ref=e7]
|
||||||
|
- generic [ref=e9]:
|
||||||
|
- heading "Baron SSO" [level=1] [ref=e10]
|
||||||
|
- paragraph [ref=e11]: Admin Control Plane
|
||||||
|
- generic [ref=e12]:
|
||||||
|
- generic [ref=e13]:
|
||||||
|
- heading "관리자 로그인" [level=3] [ref=e14]:
|
||||||
|
- img [ref=e15]
|
||||||
|
- text: 관리자 로그인
|
||||||
|
- paragraph [ref=e18]: Baron 통합 인증(SSO)을 통해 관리자 페이지에 접속합니다.
|
||||||
|
- generic [ref=e19]:
|
||||||
|
- button "SSO 계정으로 로그인" [ref=e20] [cursor=pointer]:
|
||||||
|
- img [ref=e21]
|
||||||
|
- text: SSO 계정으로 로그인
|
||||||
|
- img [ref=e23]
|
||||||
|
- paragraph [ref=e27]:
|
||||||
|
- text: 관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
|
||||||
|
- text: 민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||||
|
- paragraph [ref=e32]:
|
||||||
|
- text: 인증 정보가 없거나 로그인이 되지 않는 경우
|
||||||
|
- text: 시스템 관리자에게 문의하세요.
|
||||||
15
Makefile
15
Makefile
@@ -29,7 +29,7 @@ ifneq (,$(wildcard ./.env))
|
|||||||
COMPOSE_DROP_ENV_ARGS += --env-file .env
|
COMPOSE_DROP_ENV_ARGS += --env-file .env
|
||||||
endif
|
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:
|
build-auth-config:
|
||||||
@@ -56,6 +56,7 @@ up: up-all
|
|||||||
up-all: ensure-networks render-ory-config
|
up-all: ensure-networks render-ory-config
|
||||||
@echo "Starting ALL stacks (infra + ory + app)..."
|
@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) up --build -d
|
||||||
|
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos
|
||||||
|
|
||||||
# --- 개별 스택 실행 ---
|
# --- 개별 스택 실행 ---
|
||||||
up-infra: ensure-networks
|
up-infra: ensure-networks
|
||||||
@@ -65,6 +66,7 @@ up-infra: ensure-networks
|
|||||||
up-ory: ensure-networks render-ory-config
|
up-ory: ensure-networks render-ory-config
|
||||||
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
|
@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) up -d
|
||||||
|
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos
|
||||||
|
|
||||||
up-app: ensure-networks render-ory-config
|
up-app: ensure-networks render-ory-config
|
||||||
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
|
@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..."; \
|
echo "Starting missing Ory stack containers in daemon mode..."; \
|
||||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \
|
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \
|
||||||
else \
|
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
|
fi
|
||||||
|
|
||||||
up-dev: ensure-infra ensure-ory
|
up-dev: ensure-infra ensure-ory
|
||||||
@@ -125,7 +128,11 @@ up-front-dev: up-infra up-ory up-backend
|
|||||||
|
|
||||||
dev: up-dev
|
dev: up-dev
|
||||||
@echo "Starting development app containers in foreground attach mode..."
|
@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) ---
|
||||||
down:
|
down:
|
||||||
@@ -256,7 +263,7 @@ code-check-userfront-lint:
|
|||||||
code-check-front-lint:
|
code-check-front-lint:
|
||||||
@echo "==> adminfront biome lint/format check"
|
@echo "==> adminfront biome lint/format check"
|
||||||
rm -rf adminfront/playwright-report adminfront/test-results
|
rm -rf adminfront/playwright-report adminfront/test-results
|
||||||
cd adminfront && npm ci --ignore-scripts
|
cd adminfront && pnpm install --frozen-lockfile --ignore-scripts
|
||||||
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||||
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||||
@echo "==> devfront biome lint/format check"
|
@echo "==> devfront biome lint/format check"
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -155,6 +155,7 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
|
|||||||
|
|
||||||
### 한맥가족 User Import Email 정책
|
### 한맥가족 User Import Email 정책
|
||||||
- 전체 시스템에서 `users.email`은 unique입니다.
|
- 전체 시스템에서 `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 해야 합니다.
|
- 한맥가족 테넌트 root(`hanmac-family`)와 그 하위 subtree에서는 이메일 도메인과 무관하게 `@` 앞 local-part도 unique 해야 합니다.
|
||||||
- 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다.
|
- 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다.
|
||||||
- `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다.
|
- `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다.
|
||||||
@@ -171,6 +172,22 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
|
|||||||
- 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다.
|
- 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다.
|
||||||
- bulk import는 preview에서 제안/수정된 최종 email을 사용하되, backend가 생성 직전에 다시 unique 규칙을 검증합니다.
|
- 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)
|
### 4. 주요 시나리오 (Core Scenarios)
|
||||||
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
|
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
|
||||||
@@ -541,11 +558,12 @@ KETO_WRITE_URL = "http://keto:4467"
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 🌐 i18n 구조 (간략)
|
## 🌐 i18n 구조 (간략)
|
||||||
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
|
- **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 현재 `userfront`와 전역 i18n 검증 기준 리소스입니다.
|
||||||
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
|
- **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`에 사전 생성합니다.
|
- **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`과 런타임 번역 리소스를 동기화합니다.
|
- **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
|
## 🧪 Code Check CI
|
||||||
워크플로우 파일: `.gitea/workflows/code_check.yml`
|
워크플로우 파일: `.gitea/workflows/code_check.yml`
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
FROM node:lts
|
FROM node:lts
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /workspace
|
||||||
|
|
||||||
# 패키지 정보 복사 및 의존성 설치
|
# Install pnpm
|
||||||
COPY package*.json ./
|
RUN npm install -g pnpm
|
||||||
RUN npm ci
|
|
||||||
|
# Copy workspace configs and common package
|
||||||
|
COPY common ./common
|
||||||
|
COPY adminfront ./adminfront
|
||||||
|
|
||||||
|
# Install dependencies for the workspace
|
||||||
|
RUN cd common && pnpm install --no-frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||||
RUN npm install -g serve
|
RUN npm install -g serve
|
||||||
|
|
||||||
# 소스 코드 복사
|
WORKDIR /workspace/adminfront
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Vite 기본 포트
|
# Vite 기본 포트
|
||||||
EXPOSE 5173
|
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,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
"extends": ["../common/config/biome.base.json"],
|
||||||
"formatter": {
|
|
||||||
"enabled": true,
|
|
||||||
"indentStyle": "space"
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": true,
|
|
||||||
"rules": {
|
|
||||||
"style": {
|
|
||||||
"useEnumInitializers": "off"
|
|
||||||
},
|
|
||||||
"a11y": {
|
|
||||||
"noLabelWithoutControl": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"organizeImports": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": [
|
"ignore": [".vite"]
|
||||||
"dist",
|
|
||||||
".vite",
|
|
||||||
"node_modules",
|
|
||||||
"tsconfig*.json",
|
|
||||||
"test-results",
|
|
||||||
"test-results.nobody-backup",
|
|
||||||
"playwright-report"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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개
|
|
||||||
2330
adminfront/package-lock.json
generated
2330
adminfront/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,52 +13,52 @@
|
|||||||
"lint:fix": "biome check . --write",
|
"lint:fix": "biome check . --write",
|
||||||
"format": "biome format . --write",
|
"format": "biome format . --write",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "node ./node_modules/playwright/cli.js test",
|
"test": "playwright test",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:ui": "node ./node_modules/playwright/cli.js test --ui",
|
"test:ui": "playwright test --ui",
|
||||||
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
|
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@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-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@tanstack/react-query": "^5.66.8",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"@tanstack/react-query-devtools": "^5.66.8",
|
"@tanstack/react-query-devtools": "^5.100.10",
|
||||||
"axios": "^1.7.9",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
"axios": "^1.16.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^1.14.0",
|
||||||
"oidc-client-ts": "^3.4.1",
|
"oidc-client-ts": "^3.5.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.75.0",
|
||||||
"react-oidc-context": "^3.3.0",
|
"react-oidc-context": "^3.3.1",
|
||||||
"react-router-dom": "^6.28.2",
|
"react-router-dom": "^7.15.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@playwright/test": "^1.60.0",
|
||||||
"@playwright/test": "^1.58.0",
|
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^25.7.0",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.5.0",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.14",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.3",
|
"vite": "^8.0.12",
|
||||||
"vitest": "^4.0.18"
|
"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 defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||||
const reuseExistingServer = !process.env.CI && !process.env.PORT;
|
const reuseExistingServer = !process.env.CI && !process.env.PORT;
|
||||||
|
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read environment variables from file.
|
* Read environment variables from file.
|
||||||
@@ -56,7 +57,12 @@ export default defineConfig({
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: "chromium",
|
name: "chromium",
|
||||||
use: { ...devices["Desktop Chrome"] },
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
launchOptions: chromiumExecutablePath
|
||||||
|
? { executablePath: chromiumExecutablePath }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
3469
adminfront/pnpm-lock.yaml
generated
Normal file
3469
adminfront/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,91 @@ if [ "${1:-}" = "--print-mode" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
ensure_frontend_dependencies() {
|
ensure_frontend_dependencies() {
|
||||||
if [ ! -f package.json ] || [ ! -f package-lock.json ]; then
|
APP_WORKSPACE_FILTER="../adminfront"
|
||||||
|
|
||||||
|
# If common workspace exists, manage dependencies from the real workspace tree.
|
||||||
|
if [ -d /workspace/common ] && [ -f /workspace/common/package.json ]; then
|
||||||
|
WORKSPACE_DIR="/workspace/common"
|
||||||
|
LOCK_FILE="/workspace/common/pnpm-lock.yaml"
|
||||||
|
else
|
||||||
|
WORKSPACE_DIR="."
|
||||||
|
LOCK_FILE="package-lock.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$WORKSPACE_DIR/package.json" ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
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
|
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" 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||||
else
|
else
|
||||||
deps_hash="$(cksum package.json package-lock.json | cksum | awk '{print $1}')"
|
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | cksum | awk '{print $1}')"
|
||||||
fi
|
fi
|
||||||
deps_stamp="node_modules/.baron-deps-hash"
|
deps_stamp="node_modules/.baron-deps-hash"
|
||||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||||
|
|
||||||
if [ "$installed_hash" != "$deps_hash" ]; then
|
if [ "$installed_hash" != "$deps_hash" ]; then
|
||||||
echo "Installing frontend dependencies from package-lock.json..."
|
echo "Installing frontend dependencies..."
|
||||||
npm ci
|
acquire_install_lock
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||||
|
else
|
||||||
|
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | cksum | awk '{print $1}')"
|
||||||
|
fi
|
||||||
|
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||||
|
if [ "$installed_hash" = "$deps_hash" ]; then
|
||||||
|
release_install_lock
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
||||||
|
(cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
|
||||||
|
else
|
||||||
|
npm ci
|
||||||
|
fi
|
||||||
mkdir -p node_modules
|
mkdir -p node_modules
|
||||||
printf '%s\n' "$deps_hash" > "$deps_stamp"
|
printf '%s\n' "$deps_hash" > "$deps_stamp"
|
||||||
|
release_install_lock
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_frontend_dependencies
|
ensure_frontend_dependencies
|
||||||
|
|
||||||
if [ "$mode" = "production" ]; then
|
if [ "$mode" = "production" ]; then
|
||||||
echo "Running in production mode with Vite preview..."
|
echo "Running in production mode with custom static server..."
|
||||||
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
|
exec sh -c "npm run build && node ./scripts/serve-prod.mjs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Running in development mode..."
|
echo "Running in development mode..."
|
||||||
|
|||||||
153
adminfront/scripts/serve-prod.mjs
Normal file
153
adminfront/scripts/serve-prod.mjs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFile, stat } from "node:fs/promises";
|
||||||
|
import { extname, join, normalize, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const rootDir = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
const distDir = resolve(
|
||||||
|
process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist",
|
||||||
|
);
|
||||||
|
const host = process.env.HOST ?? "0.0.0.0";
|
||||||
|
const port = Number(process.env.PORT ?? process.env.ADMINFRONT_PORT ?? 5173);
|
||||||
|
const backendTarget = new URL(
|
||||||
|
process.env.API_PROXY_TARGET || "http://localhost:3000",
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentTypes = {
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".map": "application/json; charset=utf-8",
|
||||||
|
".mjs": "application/javascript; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getContentType(filePath) {
|
||||||
|
return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, statusCode, body) {
|
||||||
|
res.writeHead(statusCode, {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSafePath(pathname) {
|
||||||
|
const decoded = decodeURIComponent(pathname);
|
||||||
|
const relative = decoded.replace(/^\/+/, "");
|
||||||
|
const safe = normalize(relative).replace(/^(\.\.(?:[\\/]|$))+/, "");
|
||||||
|
return join(distDir, safe);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryReadFile(filePath) {
|
||||||
|
try {
|
||||||
|
return await readFile(filePath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyToBackend(req, res, pathname, search) {
|
||||||
|
const target = new URL(pathname + search, backendTarget);
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (!value) continue;
|
||||||
|
if (key === "host" || key === "content-length" || key === "connection") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
headers.set(key, value.join(", "));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBody = !["GET", "HEAD"].includes(req.method ?? "GET");
|
||||||
|
const response = await fetch(target, {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
body: hasBody ? req : undefined,
|
||||||
|
duplex: hasBody ? "half" : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseHeaders = new Headers(response.headers);
|
||||||
|
responseHeaders.delete("content-length");
|
||||||
|
responseHeaders.delete("transfer-encoding");
|
||||||
|
responseHeaders.delete("connection");
|
||||||
|
|
||||||
|
res.writeHead(response.status, Object.fromEntries(responseHeaders.entries()));
|
||||||
|
|
||||||
|
if (req.method === "HEAD") {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
res.end(Buffer.from(arrayBuffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveStatic(req, res, pathname) {
|
||||||
|
const indexPath = join(distDir, "index.html");
|
||||||
|
const filePath = toSafePath(pathname);
|
||||||
|
|
||||||
|
let resolvedPath = filePath;
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(resolvedPath);
|
||||||
|
if (fileStat.isDirectory()) {
|
||||||
|
resolvedPath = join(resolvedPath, "index.html");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
resolvedPath = indexPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = await tryReadFile(resolvedPath);
|
||||||
|
if (!body) {
|
||||||
|
body = await tryReadFile(indexPath);
|
||||||
|
resolvedPath = indexPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
sendJson(res, 500, { error: "dist_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": getContentType(resolvedPath),
|
||||||
|
"Cache-Control": resolvedPath.endsWith("index.html")
|
||||||
|
? "no-cache"
|
||||||
|
: "public, max-age=31536000, immutable",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.method === "HEAD") {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||||
|
const { pathname, search } = url;
|
||||||
|
|
||||||
|
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
||||||
|
await proxyToBackend(req, res, pathname, search);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = pathname === "/" ? "/index.html" : pathname;
|
||||||
|
await serveStatic(req, res, normalizedPath);
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, {
|
||||||
|
error: "internal_server_error",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).listen(port, host, () => {
|
||||||
|
console.log(`Adminfront production server listening on http://${host}:${port}`);
|
||||||
|
});
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: queryClientDefaultOptions,
|
||||||
queries: {
|
|
||||||
staleTime: 30_000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
retry: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,4 +21,32 @@ describe("admin routes", () => {
|
|||||||
|
|
||||||
expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
|
|||||||
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
||||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||||
|
import AuthGuard from "../features/auth/AuthGuard";
|
||||||
import AuthPage from "../features/auth/AuthPage";
|
import AuthPage from "../features/auth/AuthPage";
|
||||||
import LoginPage from "../features/auth/LoginPage";
|
import LoginPage from "../features/auth/LoginPage";
|
||||||
|
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
|
||||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||||
import UserProjectionPage from "../features/projections/UserProjectionPage";
|
import UserProjectionPage from "../features/projections/UserProjectionPage";
|
||||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||||
@@ -34,34 +36,40 @@ export const adminRoutes: RouteObject[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <AppLayout />,
|
element: <AuthGuard />,
|
||||||
children: [
|
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: <AppLayout />,
|
||||||
element: <TenantDetailPage />,
|
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <TenantProfilePage /> },
|
{ index: true, element: <GlobalOverviewPage /> },
|
||||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
{ path: "auth", element: <AuthPage /> },
|
||||||
{ path: "schema", element: <TenantSchemaPage /> },
|
{ path: "users", element: <UserListPage /> },
|
||||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
{ path: "users/new", element: <UserCreatePage /> },
|
||||||
|
{ path: "users/:id", element: <UserDetailPage /> },
|
||||||
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
|
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||||
|
{
|
||||||
|
path: "tenants/:tenantId",
|
||||||
|
element: <TenantDetailPage />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <TenantProfilePage /> },
|
||||||
|
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||||
|
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||||
|
{ path: "schema", element: <TenantSchemaPage /> },
|
||||||
|
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { fetchMe } from "../../lib/adminApi";
|
import { fetchMe } from "../../lib/adminApi";
|
||||||
|
import { normalizeAdminRole } from "../../lib/roles";
|
||||||
|
|
||||||
interface RoleGuardProps {
|
interface RoleGuardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -29,8 +30,10 @@ export function RoleGuard({
|
|||||||
|
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
const userRole = profile?.role || "user";
|
const userRole = normalizeAdminRole(profile?.role);
|
||||||
const hasAccess = roles.includes(userRole);
|
const hasAccess = roles
|
||||||
|
.map((role) => normalizeAdminRole(role))
|
||||||
|
.includes(userRole);
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return <>{fallback}</>;
|
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 { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import LanguageSelector from "./LanguageSelector";
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => ({
|
||||||
|
t: (_key: string, fallback?: string) => fallback ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("LanguageSelector", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates locale without reloading the page", () => {
|
||||||
|
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||||
|
window.localStorage.setItem("locale", "ko");
|
||||||
|
|
||||||
|
render(<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,6 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
const LOCALE_STORAGE_KEY = "locale";
|
|
||||||
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||||
|
|
||||||
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
@@ -28,13 +27,27 @@ function resolveLocale(): Locale {
|
|||||||
function LanguageSelector() {
|
function LanguageSelector() {
|
||||||
const [locale, setLocale] = useState<Locale>(resolveLocale());
|
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) => {
|
const handleChange = (next: Locale) => {
|
||||||
if (next === locale) {
|
if (next === locale) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||||
setLocale(next);
|
setLocale(next);
|
||||||
window.location.reload();
|
window.dispatchEvent(new Event("localechange"));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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;
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Moon,
|
Moon,
|
||||||
Network,
|
Network,
|
||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
|
ShieldCheck,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sun,
|
Sun,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
@@ -19,9 +20,23 @@ import * as React from "react";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
AppSidebar,
|
||||||
|
type ShellSidebarNavItem,
|
||||||
|
type ShellTranslator,
|
||||||
|
applyShellTheme,
|
||||||
|
buildShellProfileSummary,
|
||||||
|
buildShellSessionStatus,
|
||||||
|
readShellSessionExpiryEnabled,
|
||||||
|
readShellTheme,
|
||||||
|
shellLayoutClasses,
|
||||||
|
writeShellSessionExpiryEnabled,
|
||||||
|
} from "../../../../common/shell";
|
||||||
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
||||||
import { fetchMe } from "../../lib/adminApi";
|
import { fetchMe } from "../../lib/adminApi";
|
||||||
|
import { debugLog } from "../../lib/debugLog";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
import { isSuperAdminRole } from "../../lib/roles";
|
||||||
import {
|
import {
|
||||||
shouldAttemptSlidingSessionRenew,
|
shouldAttemptSlidingSessionRenew,
|
||||||
shouldAttemptUnlimitedSessionRenew,
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
@@ -29,20 +44,84 @@ import {
|
|||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
interface NavItem {
|
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
|
||||||
label: string;
|
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
|
||||||
to: string;
|
|
||||||
icon: React.ComponentType<{ size?: number | string }>;
|
const staticNavItems: ShellSidebarNavItem[] = [
|
||||||
isExternal?: boolean;
|
{
|
||||||
|
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.api_keys",
|
||||||
|
labelFallback: "API Keys",
|
||||||
|
to: "/api-keys",
|
||||||
|
icon: Key,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.admin.nav.audit_logs",
|
||||||
|
labelFallback: "Audit Logs",
|
||||||
|
to: "/audit-logs",
|
||||||
|
icon: NotebookTabs,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.admin.nav.auth_guard",
|
||||||
|
labelFallback: "Auth Guard",
|
||||||
|
to: "/auth",
|
||||||
|
icon: KeyRound,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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[] = [
|
function SessionStatusBadge(props: SessionStatusProps) {
|
||||||
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
const sessionStatus = useSessionStatus(props);
|
||||||
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
|
||||||
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
return (
|
||||||
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
<span
|
||||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
className={[
|
||||||
];
|
shellLayoutClasses.sessionBadge,
|
||||||
|
sessionStatus.toneClass,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{sessionStatus.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionStatusText(props: SessionStatusProps) {
|
||||||
|
const sessionStatus = useSessionStatus(props);
|
||||||
|
|
||||||
|
return <>{sessionStatus.text}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -52,6 +131,7 @@ function AppLayout() {
|
|||||||
const isRenewInFlightRef = useRef(false);
|
const isRenewInFlightRef = useRef(false);
|
||||||
const lastRenewAttemptAtRef = useRef(0);
|
const lastRenewAttemptAtRef = useRef(0);
|
||||||
const lastVisitedRouteRef = useRef<string | null>(null);
|
const lastVisitedRouteRef = useRef<string | null>(null);
|
||||||
|
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||||
const isDevRoleOverrideEnabled =
|
const isDevRoleOverrideEnabled =
|
||||||
import.meta.env.MODE === "development" ||
|
import.meta.env.MODE === "development" ||
|
||||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||||
@@ -62,26 +142,12 @@ function AppLayout() {
|
|||||||
const mockRoleOverride = isMockRoleEnabled
|
const mockRoleOverride = isMockRoleEnabled
|
||||||
? window.localStorage.getItem("X-Mock-Role")
|
? window.localStorage.getItem("X-Mock-Role")
|
||||||
: null;
|
: null;
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||||
const stored = window.localStorage.getItem("admin_theme");
|
|
||||||
return stored === "dark" ? "dark" : "light";
|
|
||||||
});
|
|
||||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
|
const [, setDevelopmentRenderRevision] = useState(0);
|
||||||
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
|
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||||
return stored !== "false";
|
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||||
});
|
);
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
setNowMs(Date.now());
|
|
||||||
}, 1000);
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: profile,
|
data: profile,
|
||||||
isLoading: isProfileLoading,
|
isLoading: isProfileLoading,
|
||||||
@@ -89,10 +155,10 @@ function AppLayout() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.debug("[AppLayout] Fetching profile...");
|
debugLog("[AppLayout] Fetching profile...");
|
||||||
try {
|
try {
|
||||||
const data = await fetchMe();
|
const data = await fetchMe();
|
||||||
console.debug("[AppLayout] Profile fetched successfully:", data.email);
|
debugLog("[AppLayout] Profile fetched successfully:", data.email);
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[AppLayout] Failed to fetch profile:", err);
|
console.error("[AppLayout] Failed to fetch profile:", err);
|
||||||
@@ -106,18 +172,19 @@ function AppLayout() {
|
|||||||
._IS_TEST_MODE === true,
|
._IS_TEST_MODE === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const navItems = React.useMemo(() => {
|
const navItems = React.useMemo<ShellSidebarNavItem[]>(() => {
|
||||||
const items = [...staticNavItems];
|
const items = [...staticNavItems];
|
||||||
const isTest =
|
const isTest =
|
||||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||||
._IS_TEST_MODE === true;
|
._IS_TEST_MODE === true;
|
||||||
const effectiveRole = mockRoleOverride || profile?.role;
|
const effectiveRole = mockRoleOverride || profile?.role;
|
||||||
|
|
||||||
const isSuperAdmin = isTest || effectiveRole === "super_admin";
|
const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
|
||||||
const isTenantAdmin = effectiveRole === "tenant_admin";
|
const isTenantAdmin = effectiveRole === "tenant_admin";
|
||||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||||
|
{ includeInternal: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredItems = items.filter((item) => {
|
const filteredItems = items.filter((item) => {
|
||||||
@@ -128,31 +195,42 @@ function AppLayout() {
|
|||||||
|
|
||||||
if (isSuperAdmin) {
|
if (isSuperAdmin) {
|
||||||
filteredItems.splice(1, 0, {
|
filteredItems.splice(1, 0, {
|
||||||
label: "ui.admin.nav.tenants",
|
labelKey: "ui.admin.nav.tenants",
|
||||||
|
labelFallback: "Tenants",
|
||||||
to: "/tenants",
|
to: "/tenants",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
});
|
});
|
||||||
filteredItems.splice(2, 0, {
|
filteredItems.splice(2, 0, {
|
||||||
label: "ui.admin.nav.org_chart",
|
labelKey: "ui.admin.nav.org_chart",
|
||||||
|
labelFallback: "Org Chart",
|
||||||
to: orgfrontUrl,
|
to: orgfrontUrl,
|
||||||
icon: Network,
|
icon: Network,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
});
|
});
|
||||||
filteredItems.splice(4, 0, {
|
filteredItems.splice(4, 0, {
|
||||||
label: "ui.admin.nav.user_projection",
|
labelKey: "ui.admin.nav.user_projection",
|
||||||
|
labelFallback: "User Projection",
|
||||||
to: "/system/projections/users",
|
to: "/system/projections/users",
|
||||||
icon: Database,
|
icon: Database,
|
||||||
});
|
});
|
||||||
|
filteredItems.splice(5, 0, {
|
||||||
|
labelKey: "ui.admin.nav.data_integrity",
|
||||||
|
labelFallback: "Data Integrity",
|
||||||
|
to: "/system/data-integrity",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
});
|
||||||
} else if (isTenantAdmin || manageableCount > 0) {
|
} else if (isTenantAdmin || manageableCount > 0) {
|
||||||
if (manageableCount <= 1 && profile?.tenantId) {
|
if (manageableCount <= 1 && profile?.tenantId) {
|
||||||
filteredItems.splice(1, 0, {
|
filteredItems.splice(1, 0, {
|
||||||
label: "ui.admin.nav.my_tenant",
|
labelKey: "ui.admin.nav.my_tenant",
|
||||||
|
labelFallback: "My Tenant",
|
||||||
to: `/tenants/${profile.tenantId}`,
|
to: `/tenants/${profile.tenantId}`,
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
});
|
});
|
||||||
} else if (manageableCount > 1) {
|
} else if (manageableCount > 1) {
|
||||||
filteredItems.splice(1, 0, {
|
filteredItems.splice(1, 0, {
|
||||||
label: "ui.admin.nav.tenants",
|
labelKey: "ui.admin.nav.tenants",
|
||||||
|
labelFallback: "Tenants",
|
||||||
to: "/tenants",
|
to: "/tenants",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
});
|
});
|
||||||
@@ -161,7 +239,8 @@ function AppLayout() {
|
|||||||
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
|
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
|
||||||
0,
|
0,
|
||||||
{
|
{
|
||||||
label: "ui.admin.nav.org_chart",
|
labelKey: "ui.admin.nav.org_chart",
|
||||||
|
labelFallback: "Org Chart",
|
||||||
to: orgfrontUrl,
|
to: orgfrontUrl,
|
||||||
icon: Network,
|
icon: Network,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
@@ -170,7 +249,8 @@ function AppLayout() {
|
|||||||
} else {
|
} else {
|
||||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
||||||
filteredItems.splice(1, 0, {
|
filteredItems.splice(1, 0, {
|
||||||
label: "ui.admin.nav.org_chart",
|
labelKey: "ui.admin.nav.org_chart",
|
||||||
|
labelFallback: "Org Chart",
|
||||||
to: orgfrontUrl,
|
to: orgfrontUrl,
|
||||||
icon: Network,
|
icon: Network,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
@@ -195,7 +275,7 @@ function AppLayout() {
|
|||||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||||
._IS_TEST_MODE === true;
|
._IS_TEST_MODE === true;
|
||||||
|
|
||||||
console.debug("[AppLayout] Auth state check:", {
|
debugLog("[AppLayout] Auth state check:", {
|
||||||
isLoading: auth.isLoading,
|
isLoading: auth.isLoading,
|
||||||
isAuthenticated: auth.isAuthenticated,
|
isAuthenticated: auth.isAuthenticated,
|
||||||
isTest,
|
isTest,
|
||||||
@@ -214,16 +294,30 @@ function AppLayout() {
|
|||||||
}, [auth.user]);
|
}, [auth.user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
applyShellTheme(theme);
|
||||||
root.classList.remove("light", "dark");
|
|
||||||
if (theme === "light") {
|
|
||||||
root.classList.add("light");
|
|
||||||
} else {
|
|
||||||
root.classList.add("dark");
|
|
||||||
}
|
|
||||||
window.localStorage.setItem("admin_theme", theme);
|
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDevelopmentRuntime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rerenderDevelopmentShell = () => {
|
||||||
|
setDevelopmentRenderRevision((value) => value + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||||
|
window.addEventListener(DEV_ROLE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||||
|
window.removeEventListener(
|
||||||
|
DEV_ROLE_CHANGED_EVENT,
|
||||||
|
rerenderDevelopmentShell,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [isDevelopmentRuntime]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
@@ -289,6 +383,10 @@ function AppLayout() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isDevelopmentRuntime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const maybeKeepSessionAlive = async () => {
|
const maybeKeepSessionAlive = async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (
|
if (
|
||||||
@@ -331,6 +429,7 @@ function AppLayout() {
|
|||||||
auth.isAuthenticated,
|
auth.isAuthenticated,
|
||||||
auth.isLoading,
|
auth.isLoading,
|
||||||
auth.user?.expires_at,
|
auth.user?.expires_at,
|
||||||
|
isDevelopmentRuntime,
|
||||||
isSessionExpiryEnabled,
|
isSessionExpiryEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -388,71 +487,83 @@ function AppLayout() {
|
|||||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileName =
|
const profileSummary = buildShellProfileSummary({
|
||||||
profile?.name?.trim() ||
|
profileName:
|
||||||
auth.user?.profile.name?.toString().trim() ||
|
profile?.name ||
|
||||||
auth.user?.profile.preferred_username?.toString().trim() ||
|
auth.user?.profile.name?.toString() ||
|
||||||
t("ui.dev.profile.unknown_name", "Unknown User");
|
auth.user?.profile.preferred_username?.toString(),
|
||||||
const profileEmail =
|
profileEmail: profile?.email || auth.user?.profile.email?.toString(),
|
||||||
profile?.email?.trim() ||
|
fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
|
||||||
auth.user?.profile.email?.toString().trim() ||
|
fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
|
||||||
t("ui.dev.profile.unknown_email", "unknown@example.com");
|
});
|
||||||
const profileInitial = profileName.charAt(0).toUpperCase();
|
|
||||||
const profileRoleKey = mockRoleOverride || profile?.role || "user";
|
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 handleSessionExpiryToggle = () => {
|
const handleSessionExpiryToggle = () => {
|
||||||
setIsSessionExpiryEnabled((prev) => {
|
setIsSessionExpiryEnabled((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
|
writeShellSessionExpiryEnabled(next);
|
||||||
return 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) {
|
if (auth.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -463,87 +574,19 @@ function AppLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
<div className={shellLayoutClasses.root}>
|
||||||
<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">
|
<AppSidebar
|
||||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
brandLabel={t("ui.admin.brand", "Baron 로그인")}
|
||||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
brandTitle={t("ui.admin.title", "Admin Control")}
|
||||||
<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)]">
|
brandIcon={<ShieldHalf size={20} />}
|
||||||
<ShieldHalf size={20} />
|
navContent={sidebarNavContent}
|
||||||
</div>
|
footerContent={sidebarFooterContent}
|
||||||
<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);
|
|
||||||
|
|
||||||
if (isExternal) {
|
<div className={shellLayoutClasses.contentWide}>
|
||||||
return (
|
<header className={shellLayoutClasses.headerElevated}>
|
||||||
<a
|
<div className={shellLayoutClasses.headerInner}>
|
||||||
key={to}
|
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||||
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">
|
|
||||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
{t("ui.admin.header.plane", "ADMIN PLANE")}
|
{t("ui.admin.header.plane", "ADMIN PLANE")}
|
||||||
</p>
|
</p>
|
||||||
@@ -552,12 +595,12 @@ function AppLayout() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className={shellLayoutClasses.headerActions}>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleTheme}
|
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", "테마 전환")}
|
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||||
>
|
>
|
||||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
@@ -566,14 +609,10 @@ function AppLayout() {
|
|||||||
: t("ui.common.theme_dark", "Dark")}
|
: t("ui.common.theme_dark", "Dark")}
|
||||||
</button>
|
</button>
|
||||||
{isSessionExpiryEnabled ? (
|
{isSessionExpiryEnabled ? (
|
||||||
<span
|
<SessionStatusBadge
|
||||||
className={[
|
expiresAtSec={auth.user?.expires_at}
|
||||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
t={t}
|
||||||
sessionToneClass,
|
/>
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{sessionText}
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative" ref={profileMenuRef}>
|
<div className="relative" ref={profileMenuRef}>
|
||||||
<button
|
<button
|
||||||
@@ -582,17 +621,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"
|
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-haspopup="menu"
|
||||||
aria-expanded={isProfileOpen}
|
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">
|
<div className={shellLayoutClasses.profileInitial}>
|
||||||
{profileInitial}
|
{profileSummary.initial}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden min-w-0 text-left md:block">
|
<div className="hidden min-w-0 text-left md:block">
|
||||||
<p className="truncate text-xs font-medium text-foreground">
|
<p className="truncate text-xs font-medium text-foreground">
|
||||||
{profileName}
|
{profileSummary.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[11px] text-muted-foreground">
|
<p className="truncate text-[11px] text-muted-foreground">
|
||||||
{profileEmail}
|
{profileSummary.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@@ -602,45 +641,44 @@ function AppLayout() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isProfileOpen ? (
|
{isProfileOpen ? (
|
||||||
<div
|
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||||
role="menu"
|
|
||||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
|
||||||
>
|
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
<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>
|
</p>
|
||||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
<div className={shellLayoutClasses.profileCard}>
|
||||||
<div>
|
<div>
|
||||||
<p className="truncate text-sm font-semibold text-foreground">
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
{profileName}
|
{profileSummary.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
{profileEmail}
|
{profileSummary.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center pt-1">
|
<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">
|
<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(
|
{t(
|
||||||
`ui.admin.role.${profileRoleKey}`,
|
`ui.shell.role.${profileRoleKey}`,
|
||||||
profileRoleKey.toUpperCase(),
|
profileRoleKey.toUpperCase(),
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
{t("ui.shell.session.auto_extend", "세션 만료 관리")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isSessionExpiryEnabled
|
{isSessionExpiryEnabled ? (
|
||||||
? sessionText
|
<SessionStatusText
|
||||||
: t(
|
expiresAtSec={auth.user?.expires_at}
|
||||||
"ui.dev.session.disabled",
|
t={t}
|
||||||
"세션 만료 비활성화",
|
/>
|
||||||
)}
|
) : (
|
||||||
|
t("ui.shell.session.disabled", "세션 만료 비활성화")
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -717,7 +755,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"
|
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" />
|
<UserIcon size={16} className="text-muted-foreground" />
|
||||||
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
|
<span>{t("ui.shell.nav.profile", "내 정보")}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -728,7 +766,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"
|
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} />
|
<LogOut size={16} />
|
||||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -736,7 +774,7 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10">
|
<main className={shellLayoutClasses.mainMinWidth}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<RoleSwitcher />
|
<RoleSwitcher />
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { FC } from "react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
|
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
|
||||||
|
|
||||||
const RoleSwitcher: FC = () => {
|
const RoleSwitcher: FC = () => {
|
||||||
const [currentRole, setCurrentRole] = useState<string>("");
|
const [currentRole, setCurrentRole] = useState<string>("");
|
||||||
const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false);
|
const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false);
|
||||||
@@ -31,13 +33,13 @@ const RoleSwitcher: FC = () => {
|
|||||||
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
|
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
|
||||||
setCurrentRole(role);
|
setCurrentRole(role);
|
||||||
setIsOverrideEnabled(true);
|
setIsOverrideEnabled(true);
|
||||||
window.location.reload();
|
window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearRoleOverride = () => {
|
const clearRoleOverride = () => {
|
||||||
window.localStorage.removeItem("X-Mock-Role-Enabled");
|
window.localStorage.removeItem("X-Mock-Role-Enabled");
|
||||||
setIsOverrideEnabled(false);
|
setIsOverrideEnabled(false);
|
||||||
window.location.reload();
|
window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (import.meta.env.MODE === "production") return null;
|
if (import.meta.env.MODE === "production") return null;
|
||||||
|
|||||||
@@ -1,38 +1,21 @@
|
|||||||
import { type VariantProps, cva } from "class-variance-authority";
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
import {
|
||||||
|
type CommonBadgeVariant,
|
||||||
|
getCommonBadgeClasses,
|
||||||
|
} from "../../../../common/ui/badge";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
"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",
|
variant?: CommonBadgeVariant;
|
||||||
{
|
}
|
||||||
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> {}
|
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
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 { Slot } from "@radix-ui/react-slot";
|
||||||
import { type VariantProps, cva } from "class-variance-authority";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
type CommonButtonSize,
|
||||||
|
type CommonButtonVariant,
|
||||||
|
getCommonButtonClasses,
|
||||||
|
} from "../../../../common/ui/button";
|
||||||
import { cn } from "../../lib/utils";
|
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
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
VariantProps<typeof buttonVariants> {
|
variant?: CommonButtonVariant;
|
||||||
|
size?: CommonButtonSize;
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button };
|
||||||
|
|||||||
@@ -1,65 +1,51 @@
|
|||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
import {
|
||||||
|
commonCardClass,
|
||||||
|
commonCardContentClass,
|
||||||
|
commonCardDescriptionClass,
|
||||||
|
commonCardFooterClass,
|
||||||
|
commonCardHeaderClass,
|
||||||
|
commonCardTitleClass,
|
||||||
|
} from "../../../../common/ui/card";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({
|
function CardHeader({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||||
<div
|
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({
|
function CardTitle({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
return (
|
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||||
<h3
|
|
||||||
className={cn("text-lg font-semibold leading-none", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({
|
function CardDescription({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
return (
|
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({
|
function CardContent({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({
|
function CardFooter({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
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,220 @@
|
|||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { cn } from "../../lib/utils";
|
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<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
HTMLDivElement,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, onMouseDown, ...props }, ref) => {
|
||||||
<DialogPrimitive.Overlay
|
const { setOpen } = useDialogContext("DialogOverlay");
|
||||||
ref={ref}
|
return (
|
||||||
className={cn(
|
<div
|
||||||
"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
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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 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,
|
className,
|
||||||
)}
|
)}
|
||||||
|
data-state="open"
|
||||||
|
onMouseDown={composeEventHandlers(onMouseDown, (event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
})}
|
||||||
{...props}
|
{...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" />
|
DialogOverlay.displayName = "DialogOverlay";
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
const DialogContent = React.forwardRef<
|
||||||
</DialogPrimitive.Content>
|
HTMLDialogElement,
|
||||||
</DialogPortal>
|
React.HTMLAttributes<HTMLDialogElement>
|
||||||
));
|
>(({ className, children, onKeyDown, ...props }, ref) => {
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
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 = ({
|
const DialogHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -80,10 +245,10 @@ const DialogFooter = ({
|
|||||||
DialogFooter.displayName = "DialogFooter";
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
HTMLHeadingElement,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<h2
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
@@ -92,19 +257,19 @@ const DialogTitle = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
DialogTitle.displayName = "DialogTitle";
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
const DialogDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
HTMLParagraphElement,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Description
|
<p
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
DialogDescription.displayName = "DialogDescription";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { commonInputClass } from "../../../../common/ui/input";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
@@ -9,10 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(commonInputClass, className)}
|
||||||
"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,
|
|
||||||
)}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,26 +1,68 @@
|
|||||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
interface SwitchProps
|
||||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
checked?: boolean;
|
||||||
>(({ className, ...props }, ref) => (
|
defaultChecked?: boolean;
|
||||||
<SwitchPrimitives.Root
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
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",
|
|
||||||
|
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
className,
|
className,
|
||||||
)}
|
checked,
|
||||||
{...props}
|
defaultChecked = false,
|
||||||
ref={ref}
|
disabled,
|
||||||
>
|
onCheckedChange,
|
||||||
<SwitchPrimitives.Thumb
|
onClick,
|
||||||
className={cn(
|
...props
|
||||||
"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",
|
},
|
||||||
)}
|
ref,
|
||||||
/>
|
) => {
|
||||||
</SwitchPrimitives.Root>
|
const isControlled = checked !== undefined;
|
||||||
));
|
const [internalChecked, setInternalChecked] =
|
||||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
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 };
|
export { Switch };
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
commonTableBodyClass,
|
||||||
|
commonTableCaptionClass,
|
||||||
|
commonTableCellClass,
|
||||||
|
commonTableClass,
|
||||||
|
commonTableFooterClass,
|
||||||
|
commonTableHeadClass,
|
||||||
|
commonTableHeaderClass,
|
||||||
|
commonTableRowClass,
|
||||||
|
commonTableWrapperClass,
|
||||||
|
} from "../../../../common/ui/table";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
const Table = React.forwardRef<
|
||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="relative w-full">
|
<div className={commonTableWrapperClass}>
|
||||||
<table
|
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
Table.displayName = "Table";
|
Table.displayName = "Table";
|
||||||
@@ -19,7 +26,11 @@ const TableHeader = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
<thead
|
||||||
|
ref={ref}
|
||||||
|
className={cn(commonTableHeaderClass, className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
TableHeader.displayName = "TableHeader";
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
@@ -27,11 +38,7 @@ const TableBody = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tbody
|
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableBody.displayName = "TableBody";
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tfoot
|
<tfoot
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("bg-muted/50 font-medium text-foreground", className)}
|
className={cn(commonTableFooterClass, className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -51,14 +58,7 @@ const TableRow = React.forwardRef<
|
|||||||
HTMLTableRowElement,
|
HTMLTableRowElement,
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableRow.displayName = "TableRow";
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
@@ -66,14 +66,7 @@ const TableHead = React.forwardRef<
|
|||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<th
|
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableHead.displayName = "TableHead";
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
@@ -81,11 +74,7 @@ const TableCell = React.forwardRef<
|
|||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td
|
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("p-6 align-middle text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableCell.displayName = "TableCell";
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<caption
|
<caption
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
className={cn(commonTableCaptionClass, className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
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";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||||
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: "테넌트 정보를 직접 제어합니다.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function ApiKeyCreatePage() {
|
function ApiKeyCreatePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -298,7 +254,7 @@ function ApiKeyCreatePage() {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<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);
|
const isSelected = selectedScopes.includes(scope.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<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 { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
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 { 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 { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +23,15 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../../components/ui/dialog";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -19,10 +40,27 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} 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 { t } from "../../lib/i18n";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||||
|
|
||||||
function ApiKeyListPage() {
|
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({
|
const query = useQuery({
|
||||||
queryKey: ["api-keys", { limit: 50, offset: 0 }],
|
queryKey: ["api-keys", { limit: 50, offset: 0 }],
|
||||||
queryFn: () => fetchApiKeys(50, 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
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
?.data?.error;
|
?.data?.error;
|
||||||
const fallbackError =
|
const fallbackError =
|
||||||
@@ -62,42 +121,80 @@ function ApiKeyListPage() {
|
|||||||
deleteMutation.mutate(id);
|
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 (
|
return (
|
||||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
<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">
|
<PageHeader
|
||||||
<div className="space-y-2">
|
sticky
|
||||||
<h2 className="text-3xl font-semibold">
|
titleAs="h2"
|
||||||
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
|
icon={<Key size={20} />}
|
||||||
</h2>
|
title={t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
description={t(
|
||||||
{t(
|
"msg.admin.api_keys.list.subtitle",
|
||||||
"msg.admin.api_keys.list.subtitle",
|
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
||||||
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
)}
|
||||||
)}
|
actions={
|
||||||
</p>
|
<>
|
||||||
</div>
|
<Button
|
||||||
<div className="flex items-center gap-2">
|
type="button"
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
onClick={() => query.refetch()}
|
||||||
onClick={() => query.refetch()}
|
disabled={query.isFetching}
|
||||||
disabled={query.isFetching}
|
>
|
||||||
>
|
<RefreshCw size={16} />
|
||||||
<RefreshCw size={16} />
|
{t("ui.common.refresh", "새로고침")}
|
||||||
{t("ui.common.refresh", "새로고침")}
|
</Button>
|
||||||
</Button>
|
<Button asChild>
|
||||||
<Button asChild>
|
<Link to="/api-keys/new">
|
||||||
<Link to="/api-keys/new">
|
<Plus size={16} />
|
||||||
<Plus size={16} />
|
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
||||||
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
</>
|
||||||
</div>
|
}
|
||||||
</header>
|
/>
|
||||||
|
|
||||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
<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">
|
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>
|
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||||
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
|
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -119,7 +216,7 @@ function ApiKeyListPage() {
|
|||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
||||||
@@ -189,15 +286,40 @@ function ApiKeyListPage() {
|
|||||||
: t("ui.common.never", "Never")}
|
: t("ui.common.never", "Never")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => handleDelete(key.id, key.name)}
|
size="sm"
|
||||||
disabled={deleteMutation.isPending}
|
onClick={() => openScopeEditor(key)}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Edit3 size={14} />
|
||||||
{t("ui.common.delete", "삭제")}
|
{t(
|
||||||
</Button>
|
"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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -207,6 +329,137 @@ function ApiKeyListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</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,16 @@
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Copy,
|
|
||||||
ListChecks,
|
|
||||||
RefreshCw,
|
|
||||||
Search,
|
|
||||||
Terminal,
|
|
||||||
} from "lucide-react";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
formatAuditValue,
|
||||||
|
parseAuditDetails,
|
||||||
|
resolveAuditAction,
|
||||||
|
resolveAuditActor,
|
||||||
|
} from "../../../../common/core/audit";
|
||||||
|
import { AuditLogTable } from "../../../../common/core/components/audit";
|
||||||
|
import { PageHeader } from "../../../../common/core/components/page";
|
||||||
|
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -19,92 +20,17 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import {
|
import { Input } from "../../components/ui/input";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "../../components/ui/table";
|
|
||||||
import type { AuditLog } from "../../lib/adminApi";
|
import type { AuditLog } from "../../lib/adminApi";
|
||||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuditLogsPage() {
|
function AuditLogsPage() {
|
||||||
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
const [searchActorId, setSearchActorId] = React.useState("");
|
||||||
const [filterDraft, setFilterDraft] = React.useState("");
|
const [searchAction, setSearchAction] = React.useState("");
|
||||||
const [expandedRows, setExpandedRows] = React.useState<
|
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||||
Record<string, boolean>
|
const deferredSearchActorId = React.useDeferredValue(searchActorId.trim());
|
||||||
>({});
|
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
|
||||||
|
|
||||||
const handleCopy = (value: string) => {
|
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigator.clipboard.writeText(value);
|
|
||||||
};
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -126,20 +52,29 @@ function AuditLogsPage() {
|
|||||||
(page) =>
|
(page) =>
|
||||||
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
const filteredLogs = React.useMemo(
|
||||||
const handleAddFilter = () => {
|
() =>
|
||||||
const trimmed = filterDraft.trim();
|
logs.filter((row) => {
|
||||||
if (!trimmed) {
|
const details = parseAuditDetails(row.details);
|
||||||
return;
|
const actorLabel = resolveAuditActor(row, details).toLowerCase();
|
||||||
}
|
const actionLabel = resolveAuditAction(row, details).toLowerCase();
|
||||||
setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed]));
|
const matchesActor =
|
||||||
setFilterDraft("");
|
deferredSearchActorId === "" ||
|
||||||
};
|
actorLabel.includes(deferredSearchActorId.toLowerCase());
|
||||||
|
const matchesAction =
|
||||||
|
deferredSearchAction === "" ||
|
||||||
|
actionLabel.includes(deferredSearchAction.toLowerCase());
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === "all" || row.status === statusFilter;
|
||||||
|
return matchesActor && matchesAction && matchesStatus;
|
||||||
|
}),
|
||||||
|
[logs, deferredSearchActorId, deferredSearchAction, statusFilter],
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
{t("msg.admin.audit.loading", "Loading audit logs...")}
|
{t("msg.common.audit.loading", "Loading audit logs...")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -150,7 +85,7 @@ function AuditLogsPage() {
|
|||||||
(error as Error).message;
|
(error as Error).message;
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-red-500">
|
<div className="p-8 text-center text-red-500">
|
||||||
{t("msg.admin.audit.load_error", "Error loading logs: {{error}}", {
|
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
|
||||||
error: errMsg,
|
error: errMsg,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,445 +93,109 @@ function AuditLogsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
<div className="space-y-6">
|
||||||
<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">
|
<PageHeader
|
||||||
<div>
|
title={t("ui.common.audit.title", "감사 로그")}
|
||||||
<h2 className="text-3xl font-semibold">
|
description={t(
|
||||||
{t("ui.admin.audit.title", "감사 로그")}
|
"msg.admin.audit.subtitle",
|
||||||
</h2>
|
"관리자 작업 이력을 조회합니다.",
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
)}
|
||||||
{t(
|
icon={<NotebookTabs size={20} />}
|
||||||
"msg.admin.audit.subtitle",
|
actions={
|
||||||
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
|
<>
|
||||||
)}
|
<Badge variant="muted">
|
||||||
</p>
|
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
||||||
</div>
|
count: filteredLogs.length,
|
||||||
<div className="flex items-center gap-2">
|
})}
|
||||||
<Button
|
</Badge>
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => refetch()}
|
variant="outline"
|
||||||
disabled={isFetching}
|
onClick={() => refetch()}
|
||||||
>
|
disabled={isFetching}
|
||||||
<RefreshCw size={16} />
|
>
|
||||||
{t("ui.common.refresh", "새로고침")}
|
<RefreshCw size={16} />
|
||||||
</Button>
|
{t("ui.common.refresh", "새로고침")}
|
||||||
<Button>
|
</Button>
|
||||||
<ListChecks size={16} />
|
<Button>
|
||||||
{t("ui.admin.audit.export_csv", "Export CSV")}
|
<Download size={16} />
|
||||||
</Button>
|
{t("ui.common.export_csv", "CSV 내보내기")}
|
||||||
</div>
|
</Button>
|
||||||
</header>
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>
|
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||||
{t("ui.admin.audit.registry.title", "Log Registry")}
|
{t("ui.common.audit.registry.title", "Audit registry")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", {
|
{t(
|
||||||
count: logs.length,
|
"msg.admin.audit.registry.description",
|
||||||
})}
|
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
<CardContent className="space-y-4 pt-0">
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
|
<SearchFilterBar
|
||||||
<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)]">
|
primary={
|
||||||
<Search size={14} />
|
<form
|
||||||
<input
|
onSubmit={(e) => {
|
||||||
value={filterDraft}
|
e.preventDefault();
|
||||||
onChange={(event) => setFilterDraft(event.target.value)}
|
refetch();
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
handleAddFilter();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
placeholder={t(
|
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||||
"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)]"
|
|
||||||
>
|
|
||||||
<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",
|
|
||||||
)}
|
|
||||||
</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
|
<div className="relative">
|
||||||
? t("msg.common.loading", "Loading...")
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
: t("ui.admin.audit.load_more", "Load more")}
|
<Input
|
||||||
</Button>
|
className="pl-10"
|
||||||
) : (
|
value={searchActorId}
|
||||||
<span className="text-xs text-[var(--color-muted)]">
|
onChange={(event) => setSearchActorId(event.target.value)}
|
||||||
{t("msg.admin.audit.end", "End of audit feed")}
|
placeholder={t(
|
||||||
</span>
|
"ui.common.audit.filters.user_id",
|
||||||
)}
|
"Filter by User ID",
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={searchAction}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSearchAction(event.target.value.toUpperCase())
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.common.audit.filters.action",
|
||||||
|
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">
|
||||||
|
{t("ui.common.audit.filters.status_all", "All Status")}
|
||||||
|
</option>
|
||||||
|
<option value="success">
|
||||||
|
{t("ui.common.status.success", "Success")}
|
||||||
|
</option>
|
||||||
|
<option value="failure">
|
||||||
|
{t("ui.common.status.failure", "Failure")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AuditLogTable
|
||||||
|
logs={filteredLogs}
|
||||||
|
t={t}
|
||||||
|
loading={isLoading}
|
||||||
|
hasNextPage={Boolean(hasNextPage)}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
onLoadMore={() => fetchNextPage()}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { ShieldHalf } from "lucide-react";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { debugLog } from "../../lib/debugLog";
|
||||||
|
|
||||||
function AuthCallbackPage() {
|
function AuthCallbackPage() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.debug("[AuthCallbackPage] State:", {
|
debugLog("[AuthCallbackPage] State:", {
|
||||||
isAuthenticated: auth.isAuthenticated,
|
isAuthenticated: auth.isAuthenticated,
|
||||||
isLoading: auth.isLoading,
|
isLoading: auth.isLoading,
|
||||||
error: auth.error,
|
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 />;
|
||||||
|
}
|
||||||
36
adminfront/src/features/auth/AuthPage.test.tsx
Normal file
36
adminfront/src/features/auth/AuthPage.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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";
|
import PermissionChecker from "./components/PermissionChecker";
|
||||||
|
|
||||||
function AuthPage() {
|
function AuthPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<PageHeader
|
||||||
<div className="space-y-1">
|
titleAs="h2"
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
icon={<ShieldHalf size={20} />}
|
||||||
Admin auth
|
title={t("ui.admin.auth_guard.title", "Auth Guard")}
|
||||||
</p>
|
description={t(
|
||||||
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
"ui.admin.auth_guard.subtitle",
|
||||||
<KeyRound size={22} className="text-primary" />
|
"Verify admin privileges and ReBAC relationships against the policy engine.",
|
||||||
인증가드
|
)}
|
||||||
</h2>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PermissionChecker />
|
<PermissionChecker />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
|
import { debugLog } from "../../lib/debugLog";
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -20,7 +21,7 @@ function LoginPage() {
|
|||||||
const shouldAutoLogin = searchParams.get("auto") === "1";
|
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.debug("[LoginPage] Auth state check:", {
|
debugLog("[LoginPage] Auth state check:", {
|
||||||
isAuthenticated: auth.isAuthenticated,
|
isAuthenticated: auth.isAuthenticated,
|
||||||
isLoading: auth.isLoading,
|
isLoading: auth.isLoading,
|
||||||
returnTo,
|
returnTo,
|
||||||
@@ -84,8 +85,11 @@ function LoginPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
|
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.href =
|
void auth.signinRedirect({
|
||||||
window.location.origin + window.location.pathname;
|
state: {
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
다시 시도하기
|
다시 시도하기
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { CheckCircle2, ShieldAlert, XCircle } from "lucide-react";
|
import { CheckCircle2, XCircle } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
import apiClient from "../../../lib/apiClient";
|
import apiClient from "../../../lib/apiClient";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
type CheckPermissionResponse = {
|
type CheckPermissionResponse = {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
@@ -46,50 +47,84 @@ function PermissionChecker() {
|
|||||||
return (
|
return (
|
||||||
<Card className="border-primary/20 bg-[var(--color-panel)]">
|
<Card className="border-primary/20 bg-[var(--color-panel)]">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="text-lg font-bold">
|
||||||
<ShieldAlert size={20} className="text-primary" />
|
{t(
|
||||||
ReBAC 권한 검증 도구
|
"ui.admin.auth_guard.checker.title",
|
||||||
|
"ReBAC permission checker",
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory
|
{t(
|
||||||
Keto를 통해 실시간으로 확인합니다.
|
"ui.admin.auth_guard.checker.description",
|
||||||
|
"Check in real time whether a subject has access to a resource through Ory Keto.",
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Namespace</Label>
|
<Label>
|
||||||
|
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
|
||||||
|
</Label>
|
||||||
<select
|
<select
|
||||||
value={namespace}
|
value={namespace}
|
||||||
onChange={(e) => setNamespace(e.target.value)}
|
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"
|
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="Tenant">
|
||||||
<option value="TenantGroup">TenantGroup</option>
|
{t("ui.admin.auth_guard.checker.namespace.tenant", "Tenant")}
|
||||||
<option value="RelyingParty">RelyingParty</option>
|
</option>
|
||||||
<option value="System">System</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Relation</Label>
|
<Label>{t("ui.admin.auth_guard.checker.relation", "Relation")}</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="view, manage, admins..."
|
placeholder={t(
|
||||||
|
"ui.admin.auth_guard.checker.relation_placeholder",
|
||||||
|
"view, manage, admins...",
|
||||||
|
)}
|
||||||
value={relation}
|
value={relation}
|
||||||
onChange={(e) => setRelation(e.target.value)}
|
onChange={(e) => setRelation(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Object ID</Label>
|
<Label>{t("ui.admin.auth_guard.checker.object_id", "Object ID")}</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Tenant UUID 등"
|
placeholder={t(
|
||||||
|
"ui.admin.auth_guard.checker.object_id_placeholder",
|
||||||
|
"Tenant UUID, etc.",
|
||||||
|
)}
|
||||||
value={object}
|
value={object}
|
||||||
onChange={(e) => setObject(e.target.value)}
|
onChange={(e) => setObject(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Subject (User:ID)</Label>
|
<Label>
|
||||||
|
{t(
|
||||||
|
"ui.admin.auth_guard.checker.subject",
|
||||||
|
"Subject (User:ID)",
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="User:uuid 또는 Namespace:ID#Relation"
|
placeholder={t(
|
||||||
|
"ui.admin.auth_guard.checker.subject_placeholder",
|
||||||
|
"User:uuid or Namespace:ID#Relation",
|
||||||
|
)}
|
||||||
value={subject}
|
value={subject}
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -102,7 +137,9 @@ function PermissionChecker() {
|
|||||||
disabled={!object || !subject || checkMutation.isPending}
|
disabled={!object || !subject || checkMutation.isPending}
|
||||||
className="w-full px-12 md:w-auto"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,18 +154,33 @@ function PermissionChecker() {
|
|||||||
{result.allowed ? (
|
{result.allowed ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 size={48} />
|
<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">
|
<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>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<XCircle size={48} />
|
<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">
|
<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>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
194
adminfront/src/features/integrity/DataIntegrityPage.test.tsx
Normal file
194
adminfront/src/features/integrity/DataIntegrityPage.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
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,
|
||||||
|
} 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,
|
||||||
|
})),
|
||||||
|
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();
|
||||||
|
window.localStorage.setItem("locale", "ko");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders integrity report for super_admin", async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("테넌트 정합성")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("중복 테넌트 slug")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("1").length).toBeGreaterThan(0);
|
||||||
|
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
594
adminfront/src/features/integrity/DataIntegrityPage.tsx
Normal file
594
adminfront/src/features/integrity/DataIntegrityPage.tsx
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
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,
|
||||||
|
type OrphanUserLoginID,
|
||||||
|
deleteOrphanUserLoginIDs,
|
||||||
|
fetchDataIntegrityReport,
|
||||||
|
fetchOrphanUserLoginIDs,
|
||||||
|
} from "../../lib/adminApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
import { getAdminDateLocale } from "../../lib/locale";
|
||||||
|
|
||||||
|
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 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 [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>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRecheck}
|
||||||
|
disabled={isLoading || isFetching || isManualRechecking}
|
||||||
|
>
|
||||||
|
<Database size={16} />
|
||||||
|
{isManualRechecking
|
||||||
|
? t("ui.admin.integrity.recheck.running", "검사 중")
|
||||||
|
: t("ui.admin.integrity.recheck.run", "다시 검사")}
|
||||||
|
</Button>
|
||||||
|
{recheckMessage ? (
|
||||||
|
<output
|
||||||
|
aria-live="polite"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{recheckMessage}
|
||||||
|
</output>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-4 pb-6">
|
||||||
|
{isError ? (
|
||||||
|
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
{(error as Error)?.message ||
|
||||||
|
t(
|
||||||
|
"msg.admin.integrity.report.load_error",
|
||||||
|
"정합성 리포트를 불러오지 못했습니다.",
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-border bg-card p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
{t(
|
||||||
|
"ui.admin.integrity.read_model.title",
|
||||||
|
"Read model integrity",
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.integrity.read_model.description",
|
||||||
|
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{data ? (
|
||||||
|
<Badge variant={statusBadgeVariant(data.status)}>
|
||||||
|
{statusLabel(data.status)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-8 text-sm text-muted-foreground">
|
||||||
|
{t("ui.admin.integrity.loading", "불러오는 중")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">
|
||||||
|
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||||
|
{data?.summary.totalChecks ?? 0}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">
|
||||||
|
{t("ui.admin.integrity.summary.passed", "정상")}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||||
|
{data?.summary.passed ?? 0}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">
|
||||||
|
{t("ui.admin.integrity.summary.failures", "실패 건수")}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||||
|
{data?.summary.failures ?? 0}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">
|
||||||
|
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm">
|
||||||
|
{formatDateTime(data?.checkedAt)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(data?.sections ?? []).map((section) => (
|
||||||
|
<section
|
||||||
|
key={section.key}
|
||||||
|
className="rounded-lg border border-border bg-card p-5"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
{integritySectionLabel(section.key, section.label)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{integritySectionDescription(section.key)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusBadgeVariant(section.status)}>
|
||||||
|
{statusLabel(section.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{section.checks.map((check) => (
|
||||||
|
<div
|
||||||
|
key={check.key}
|
||||||
|
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<CheckIcon check={check} />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{integrityCheckLabel(check.key, check.label)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{integrityCheckDescription(
|
||||||
|
check.key,
|
||||||
|
check.description,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 md:justify-end">
|
||||||
|
<Badge variant={statusBadgeVariant(check.status)}>
|
||||||
|
{statusLabel(check.status)}
|
||||||
|
</Badge>
|
||||||
|
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
|
||||||
|
{check.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-border bg-card p-5">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
{t(
|
||||||
|
"ui.admin.integrity.orphan_login_ids.title",
|
||||||
|
"유령 로그인 ID 정리",
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.integrity.orphan_login_ids.description",
|
||||||
|
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</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 type React from "react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 AuthPage from "../auth/AuthPage";
|
||||||
import GlobalOverviewPage from "./GlobalOverviewPage";
|
import GlobalOverviewPage from "./GlobalOverviewPage";
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
|
let currentRole = "super_admin";
|
||||||
|
|
||||||
vi.mock("../../lib/adminApi", () => ({
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
|
fetchMe: vi.fn(async () => ({ role: currentRole })),
|
||||||
fetchAdminOverviewStats: vi.fn(async () => ({
|
fetchAdminOverviewStats: vi.fn(async () => ({
|
||||||
totalTenants: 10,
|
totalTenants: 10,
|
||||||
|
totalUsers: 152,
|
||||||
oidcClients: 3,
|
oidcClients: 3,
|
||||||
auditEvents24h: 18,
|
auditEvents24h: 18,
|
||||||
})),
|
})),
|
||||||
fetchTenants: vi.fn(async () => ({
|
fetchAllTenants: vi.fn(async () => ({
|
||||||
items: [
|
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",
|
id: "company-1",
|
||||||
type: "COMPANY",
|
type: "COMPANY",
|
||||||
@@ -52,7 +72,7 @@ vi.mock("../../lib/adminApi", () => ({
|
|||||||
],
|
],
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
total: 3,
|
total: 4,
|
||||||
})),
|
})),
|
||||||
fetchAdminRPUsageDaily: vi.fn(async () => ({
|
fetchAdminRPUsageDaily: vi.fn(async () => ({
|
||||||
days: 14,
|
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) {
|
function renderWithProviders(ui: React.ReactElement) {
|
||||||
@@ -112,6 +156,7 @@ function renderWithProviders(ui: React.ReactElement) {
|
|||||||
|
|
||||||
describe("admin overview and auth guard pages", () => {
|
describe("admin overview and auth guard pages", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
currentRole = "super_admin";
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,7 +164,7 @@ describe("admin overview and auth guard pages", () => {
|
|||||||
renderWithProviders(<GlobalOverviewPage />);
|
renderWithProviders(<GlobalOverviewPage />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"),
|
await screen.findByText("회사별 앱별 로그인 요청 현황"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
await screen.findByLabelText("일 단위 RP 요청 현황"),
|
await screen.findByLabelText("일 단위 RP 요청 현황"),
|
||||||
@@ -132,50 +177,85 @@ describe("admin overview and auth guard pages", () => {
|
|||||||
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
|
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 />);
|
renderWithProviders(<GlobalOverviewPage />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(await screen.findByText("전체 테넌트 수")).parentElement,
|
(await screen.findByText("전체 테넌트 수")).parentElement,
|
||||||
).toHaveTextContent("10");
|
).toHaveTextContent("4");
|
||||||
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
|
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
|
||||||
"3",
|
"3",
|
||||||
);
|
);
|
||||||
|
expect(screen.getByText("전체 사용자 수").parentElement).toHaveTextContent(
|
||||||
|
"152",
|
||||||
|
);
|
||||||
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
|
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
|
||||||
"18",
|
"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 />);
|
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: "주" }));
|
fireEvent.click(screen.getByRole("button", { name: "주" }));
|
||||||
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
|
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
|
||||||
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
|
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "월" }));
|
fireEvent.click(screen.getByRole("button", { name: "월" }));
|
||||||
fireEvent.change(screen.getByLabelText("조직 검색"), {
|
fireEvent.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" }));
|
||||||
target: { value: "개발" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText("대상 조직"), {
|
|
||||||
target: { value: "org-1" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
||||||
days: 90,
|
days: 90,
|
||||||
period: "month",
|
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(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", () => {
|
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
|
||||||
renderWithProviders(<AuthPage />);
|
renderWithProviders(<AuthPage />);
|
||||||
|
|
||||||
expect(screen.getByText("인증가드")).toBeInTheDocument();
|
expect(screen.getByText("인증 가드")).toBeInTheDocument();
|
||||||
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
|
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
|
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
Database,
|
Database,
|
||||||
|
LayoutDashboard,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ReactNode, useMemo, useState } from "react";
|
import { type ReactNode, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
OverviewAxisNotes,
|
||||||
|
OverviewMetric,
|
||||||
|
OverviewSelectionChips,
|
||||||
|
} from "../../../../common/core/components/overview";
|
||||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||||
import {
|
import {
|
||||||
|
type DataIntegrityStatus,
|
||||||
type RPUsageDailyMetric,
|
type RPUsageDailyMetric,
|
||||||
type RPUsagePeriod,
|
type RPUsagePeriod,
|
||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
fetchAdminOverviewStats,
|
fetchAdminOverviewStats,
|
||||||
fetchAdminRPUsageDaily,
|
fetchAdminRPUsageDaily,
|
||||||
fetchTenants,
|
fetchAllTenants,
|
||||||
|
fetchDataIntegrityReport,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
@@ -26,10 +35,8 @@ type DailyPoint = {
|
|||||||
|
|
||||||
type SeriesSummary = {
|
type SeriesSummary = {
|
||||||
key: string;
|
key: string;
|
||||||
tenantLabel: string;
|
|
||||||
clientLabel: string;
|
clientLabel: string;
|
||||||
loginRequests: number;
|
loginRequests: number;
|
||||||
otherRequests: number;
|
|
||||||
uniqueSubjects: number;
|
uniqueSubjects: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,19 +62,16 @@ function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
|
|||||||
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||||
const bySeries = new Map<string, SeriesSummary>();
|
const bySeries = new Map<string, SeriesSummary>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const key = `${row.tenantId}:${row.clientId}`;
|
const key = row.clientId;
|
||||||
const current =
|
const current =
|
||||||
bySeries.get(key) ??
|
bySeries.get(key) ??
|
||||||
({
|
({
|
||||||
key,
|
key,
|
||||||
tenantLabel: row.tenantName || row.tenantId || "-",
|
|
||||||
clientLabel: row.clientName || row.clientId,
|
clientLabel: row.clientName || row.clientId,
|
||||||
loginRequests: 0,
|
loginRequests: 0,
|
||||||
otherRequests: 0,
|
|
||||||
uniqueSubjects: 0,
|
uniqueSubjects: 0,
|
||||||
} satisfies SeriesSummary);
|
} satisfies SeriesSummary);
|
||||||
current.loginRequests += row.loginRequests;
|
current.loginRequests += row.loginRequests;
|
||||||
current.otherRequests += row.otherRequests;
|
|
||||||
current.uniqueSubjects = Math.max(
|
current.uniqueSubjects = Math.max(
|
||||||
current.uniqueSubjects,
|
current.uniqueSubjects,
|
||||||
row.uniqueSubjects,
|
row.uniqueSubjects,
|
||||||
@@ -75,10 +79,7 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
|||||||
bySeries.set(key, current);
|
bySeries.set(key, current);
|
||||||
}
|
}
|
||||||
return Array.from(bySeries.values())
|
return Array.from(bySeries.values())
|
||||||
.sort(
|
.sort((a, b) => b.loginRequests - a.loginRequests)
|
||||||
(a, b) =>
|
|
||||||
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
|
|
||||||
)
|
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,30 +134,135 @@ function formatPeriodLabel(date: string, period: RPUsagePeriod) {
|
|||||||
return `${parts.monthText}.${parts.dayText}`;
|
return `${parts.monthText}.${parts.dayText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverviewMetric({
|
function formatOverviewDateTime(value?: string) {
|
||||||
icon,
|
if (!value) return "-";
|
||||||
label,
|
const date = new Date(value);
|
||||||
value,
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
}: {
|
return new Intl.DateTimeFormat("ko-KR", {
|
||||||
icon: ReactNode;
|
dateStyle: "medium",
|
||||||
label: string;
|
timeStyle: "short",
|
||||||
value: string;
|
}).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 (
|
return (
|
||||||
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
|
<section className="border-t border-border/60 pt-4">
|
||||||
<span className="text-muted-foreground">{icon}</span>
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<span className="text-muted-foreground">{label}</span>
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold tabular-nums">{value}</span>
|
{data.status === "pass" ? (
|
||||||
</span>
|
<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({
|
function RPUsageMixedChart({
|
||||||
rows,
|
rows,
|
||||||
|
periodControls,
|
||||||
filters,
|
filters,
|
||||||
period,
|
period,
|
||||||
}: {
|
}: {
|
||||||
rows: RPUsageDailyMetric[];
|
rows: RPUsageDailyMetric[];
|
||||||
|
periodControls: ReactNode;
|
||||||
filters: ReactNode;
|
filters: ReactNode;
|
||||||
period: RPUsagePeriod;
|
period: RPUsagePeriod;
|
||||||
}) {
|
}) {
|
||||||
@@ -185,142 +291,131 @@ function RPUsageMixedChart({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-1">
|
||||||
<BarChart3 size={18} className="text-primary" />
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
<h3 className="text-base font-semibold">
|
{t("ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황")}
|
||||||
회사별 앱별 로그인요청/기타 요청 현황
|
|
||||||
</h3>
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.admin.overview.chart.description",
|
||||||
|
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{filters}
|
{periodControls}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filters}
|
||||||
|
|
||||||
{daily.length === 0 ? (
|
{daily.length === 0 ? (
|
||||||
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
||||||
표시할 RP 이용 집계가 없습니다.
|
표시할 RP 이용 집계가 없습니다.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="space-y-3">
|
||||||
<svg
|
<div className="overflow-x-auto">
|
||||||
role="img"
|
<svg
|
||||||
aria-label="일 단위 RP 요청 현황"
|
role="img"
|
||||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
aria-label="일 단위 RP 요청 현황"
|
||||||
className="h-[235px] min-w-[720px] w-full"
|
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||||
>
|
className="h-[235px] min-w-[720px] w-full"
|
||||||
<title>일 단위 RP 요청 현황</title>
|
>
|
||||||
<g transform="translate(510 10)">
|
<title>일 단위 RP 요청 현황</title>
|
||||||
<rect
|
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||||
x="0"
|
const gridY = padTop + innerHeight * ratio;
|
||||||
y="3"
|
const label = Math.round(maxValue * (1 - ratio));
|
||||||
width="10"
|
return (
|
||||||
height="10"
|
<g key={ratio}>
|
||||||
rx="2"
|
<line
|
||||||
className="fill-sky-500/70"
|
x1={padX}
|
||||||
/>
|
x2={chartWidth - padX}
|
||||||
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
|
y1={gridY}
|
||||||
기타 요청
|
y2={gridY}
|
||||||
</text>
|
stroke="currentColor"
|
||||||
<line
|
className="text-border"
|
||||||
x1="78"
|
strokeWidth="1"
|
||||||
x2="98"
|
/>
|
||||||
y1="8"
|
<text
|
||||||
y2="8"
|
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"
|
className="stroke-emerald-500"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<text
|
{daily.map((point, index) => (
|
||||||
x="104"
|
<circle
|
||||||
y="12"
|
key={`${point.date}-login`}
|
||||||
className="fill-muted-foreground text-[11px]"
|
cx={x(index)}
|
||||||
>
|
cy={y(point.loginRequests)}
|
||||||
로그인 요청
|
r="4"
|
||||||
</text>
|
className="fill-emerald-500 stroke-background"
|
||||||
</g>
|
strokeWidth="2"
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
/>
|
||||||
const gridY = padTop + innerHeight * ratio;
|
))}
|
||||||
const label = Math.round(maxValue * (1 - ratio));
|
</svg>
|
||||||
return (
|
</div>
|
||||||
<g key={ratio}>
|
<OverviewAxisNotes
|
||||||
<line
|
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
|
||||||
x1={padX}
|
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{series.length > 0 && (
|
{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">
|
<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) => (
|
{series.map((item) => (
|
||||||
<div key={item.key} className="flex min-w-0 items-center gap-2">
|
<div
|
||||||
<span className="truncate font-medium">{item.clientLabel}</span>
|
key={item.key}
|
||||||
<span className="truncate text-muted-foreground">
|
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
|
||||||
{item.tenantLabel}
|
>
|
||||||
</span>
|
<span className="font-medium">{item.clientLabel}</span>
|
||||||
<span className="ml-auto whitespace-nowrap tabular-nums">
|
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
|
||||||
로그인 {item.loginRequests.toLocaleString()} / 기타{" "}
|
{t(
|
||||||
{item.otherRequests.toLocaleString()} / 사용자{" "}
|
"ui.common.chart.series_summary.login_users",
|
||||||
{item.uniqueSubjects.toLocaleString()}
|
"로그인 {{login}} / 사용자 {{subjects}}",
|
||||||
|
{
|
||||||
|
login: item.loginRequests.toLocaleString(),
|
||||||
|
subjects: item.uniqueSubjects.toLocaleString(),
|
||||||
|
},
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -332,8 +427,7 @@ function RPUsageMixedChart({
|
|||||||
|
|
||||||
function GlobalOverviewPage() {
|
function GlobalOverviewPage() {
|
||||||
const [period, setPeriod] = useState<RPUsagePeriod>("day");
|
const [period, setPeriod] = useState<RPUsagePeriod>("day");
|
||||||
const [tenantSearch, setTenantSearch] = useState("");
|
const [selectedTenantIds, setSelectedTenantIds] = useState<string[]>([]);
|
||||||
const [selectedTenantId, setSelectedTenantId] = useState("");
|
|
||||||
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
||||||
const statsQuery = useQuery({
|
const statsQuery = useQuery({
|
||||||
queryKey: ["admin-overview-stats"],
|
queryKey: ["admin-overview-stats"],
|
||||||
@@ -342,97 +436,97 @@ function GlobalOverviewPage() {
|
|||||||
});
|
});
|
||||||
const tenantsQuery = useQuery({
|
const tenantsQuery = useQuery({
|
||||||
queryKey: ["admin-overview-tenant-options"],
|
queryKey: ["admin-overview-tenant-options"],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const tenantOptions = useMemo(() => {
|
const tenantOptions = useMemo(() => {
|
||||||
const term = tenantSearch.trim().toLowerCase();
|
return (tenantsQuery.data?.items ?? []).filter(
|
||||||
return (tenantsQuery.data?.items ?? [])
|
(tenant) => tenant.type === "COMPANY",
|
||||||
.filter(
|
);
|
||||||
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
|
}, [tenantsQuery.data?.items]);
|
||||||
)
|
|
||||||
.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]);
|
|
||||||
const usageQuery = useQuery({
|
const usageQuery = useQuery({
|
||||||
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
|
queryKey: ["admin-rp-usage-daily", usageDays, period],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchAdminRPUsageDaily({
|
fetchAdminRPUsageDaily({
|
||||||
days: usageDays,
|
days: usageDays,
|
||||||
period,
|
period,
|
||||||
tenantId: selectedTenantId || undefined,
|
|
||||||
}),
|
}),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const stats = statsQuery.data;
|
const stats = statsQuery.data;
|
||||||
|
const visibleTenantCount = tenantsQuery.data?.items.length;
|
||||||
const usageRows = usageQuery.data?.items ?? [];
|
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) =>
|
const metric = (value: number | undefined) =>
|
||||||
value === undefined ? "-" : value.toLocaleString();
|
value === undefined ? "-" : value.toLocaleString();
|
||||||
|
const periodControls = (
|
||||||
|
<div 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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
const chartFilters = (
|
const chartFilters = (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div>
|
||||||
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
<OverviewSelectionChips
|
||||||
{[
|
allLabel="전체"
|
||||||
["day", "일"],
|
options={tenantOptions.map((tenant) => ({
|
||||||
["week", "주"],
|
id: tenant.id,
|
||||||
["month", "월"],
|
label: `${tenant.name} (${tenant.slug})`,
|
||||||
].map(([value, label]) => (
|
}))}
|
||||||
<button
|
selectedIds={selectedTenantIds}
|
||||||
key={value}
|
onSelectAll={() => setSelectedTenantIds([])}
|
||||||
type="button"
|
onToggle={(tenantId) => {
|
||||||
aria-pressed={period === value}
|
setSelectedTenantIds((current) =>
|
||||||
onClick={() => setPeriod(value as RPUsagePeriod)}
|
current.includes(tenantId)
|
||||||
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
|
? current.filter((item) => item !== tenantId)
|
||||||
period === value
|
: [...current, tenantId],
|
||||||
? "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"
|
|
||||||
/>
|
/>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-in fade-in duration-500">
|
<div className="space-y-4 animate-in fade-in duration-500">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
<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">
|
||||||
{t("ui.admin.overview.title", "Dashboard")}
|
<LayoutDashboard size={20} />
|
||||||
</h2>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="space-y-1">
|
||||||
{t(
|
<h2 className="text-3xl font-semibold">
|
||||||
"msg.admin.overview.description",
|
{t("ui.common.overview.title", "운영 현황")}
|
||||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
</h2>
|
||||||
)}
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
{t(
|
||||||
|
"msg.admin.overview.description",
|
||||||
|
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -444,7 +538,7 @@ function GlobalOverviewPage() {
|
|||||||
"ui.admin.overview.summary.total_tenants",
|
"ui.admin.overview.summary.total_tenants",
|
||||||
"전체 테넌트 수",
|
"전체 테넌트 수",
|
||||||
)}
|
)}
|
||||||
value={metric(stats?.totalTenants)}
|
value={metric(visibleTenantCount ?? stats?.totalTenants)}
|
||||||
/>
|
/>
|
||||||
<OverviewMetric
|
<OverviewMetric
|
||||||
icon={<ShieldCheck size={14} />}
|
icon={<ShieldCheck size={14} />}
|
||||||
@@ -454,6 +548,11 @@ function GlobalOverviewPage() {
|
|||||||
)}
|
)}
|
||||||
value={metric(stats?.oidcClients)}
|
value={metric(stats?.oidcClients)}
|
||||||
/>
|
/>
|
||||||
|
<OverviewMetric
|
||||||
|
icon={<Users size={14} />}
|
||||||
|
label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")}
|
||||||
|
value={metric(stats?.totalUsers)}
|
||||||
|
/>
|
||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
<OverviewMetric
|
<OverviewMetric
|
||||||
icon={<Activity size={14} />}
|
icon={<Activity size={14} />}
|
||||||
@@ -472,12 +571,24 @@ function GlobalOverviewPage() {
|
|||||||
|
|
||||||
{usageQuery.isError ? (
|
{usageQuery.isError ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<h3 className="text-base font-semibold">
|
<div className="space-y-1">
|
||||||
회사별 앱별 로그인요청/기타 요청 현황
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
</h3>
|
{t(
|
||||||
{chartFilters}
|
"ui.admin.overview.chart.title",
|
||||||
|
"회사별 앱별 로그인 요청 현황",
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.admin.overview.chart.description",
|
||||||
|
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{periodControls}
|
||||||
</div>
|
</div>
|
||||||
|
{chartFilters}
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
||||||
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
||||||
@@ -486,11 +597,16 @@ function GlobalOverviewPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<RPUsageMixedChart
|
<RPUsageMixedChart
|
||||||
rows={usageRows}
|
rows={filteredUsageRows}
|
||||||
|
periodControls={periodControls}
|
||||||
filters={chartFilters}
|
filters={chartFilters}
|
||||||
period={period}
|
period={period}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RoleGuard roles={["super_admin"]}>
|
||||||
|
<IntegrityOverviewSummary />
|
||||||
|
</RoleGuard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import {
|
|||||||
reconcileUserProjection,
|
reconcileUserProjection,
|
||||||
resetUserProjection,
|
resetUserProjection,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
import UserProjectionPage from "./UserProjectionPage";
|
import UserProjectionPage from "./UserProjectionPage";
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
let currentRole = "super_admin";
|
let currentRole = "super_admin";
|
||||||
|
|
||||||
vi.mock("../../lib/adminApi", () => ({
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
@@ -52,18 +55,24 @@ describe("UserProjectionPage", () => {
|
|||||||
currentRole = "super_admin";
|
currentRole = "super_admin";
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
window.localStorage.setItem("locale", "ko");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders projection status for super_admin", async () => {
|
it("renders projection status for super_admin", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("사용자 Projection 관리"),
|
await screen.findByText("사용자 동기화 관리"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("Kratos users projection"),
|
await screen.findByText(
|
||||||
|
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
|
||||||
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("ready")).toBeInTheDocument();
|
expect(
|
||||||
|
await screen.findByText("Kratos 사용자 동기화"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("준비됨")).toBeInTheDocument();
|
||||||
expect(screen.getByText("152")).toBeInTheDocument();
|
expect(screen.getByText("152")).toBeInTheDocument();
|
||||||
expect(fetchUserProjectionStatus).toHaveBeenCalled();
|
expect(fetchUserProjectionStatus).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -71,7 +80,7 @@ describe("UserProjectionPage", () => {
|
|||||||
it("runs reconcile and reset actions for super_admin", async () => {
|
it("runs reconcile and reset actions for super_admin", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
await screen.findByText("사용자 Projection 관리");
|
await screen.findByText("사용자 동기화 관리");
|
||||||
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
|
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -92,8 +101,22 @@ describe("UserProjectionPage", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("사용자 Projection 관리"),
|
screen.queryByText("사용자 동기화 관리"),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
|
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 { 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 { RoleGuard } from "../../components/auth/RoleGuard";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
reconcileUserProjection,
|
reconcileUserProjection,
|
||||||
resetUserProjection,
|
resetUserProjection,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
import { getAdminDateLocale } from "../../lib/locale";
|
||||||
|
|
||||||
function formatDateTime(value?: string) {
|
function formatDateTime(value?: string) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -17,7 +19,7 @@ function formatDateTime(value?: string) {
|
|||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
return new Intl.DateTimeFormat("ko-KR", {
|
return new Intl.DateTimeFormat(getAdminDateLocale(), {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
timeStyle: "medium",
|
timeStyle: "medium",
|
||||||
}).format(date);
|
}).format(date);
|
||||||
@@ -31,12 +33,26 @@ function ProjectionStatusBadge({
|
|||||||
status: string;
|
status: string;
|
||||||
}) {
|
}) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
return <Badge variant="success">ready</Badge>;
|
return (
|
||||||
|
<Badge variant="success">
|
||||||
|
{t("ui.admin.user_projection.status.ready", "ready")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (status === "failed") {
|
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() {
|
function UserProjectionContent() {
|
||||||
@@ -64,7 +80,10 @@ function UserProjectionContent() {
|
|||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
"사용자 projection을 Kratos 기준으로 다시 구축하시겠습니까?",
|
t(
|
||||||
|
"msg.admin.user_projection.reset_confirm",
|
||||||
|
"Rebuild user projection from the Kratos source of truth?",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
resetMutation.mutate();
|
resetMutation.mutate();
|
||||||
@@ -76,13 +95,26 @@ function UserProjectionContent() {
|
|||||||
const actionError = reconcileMutation.error ?? resetMutation.error;
|
const actionError = reconcileMutation.error ?? resetMutation.error;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="space-y-6 p-6 md:p-8">
|
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<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>
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<p className="text-sm text-muted-foreground">System</p>
|
<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">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
<Users size={20} />
|
||||||
사용자 Projection 관리
|
</div>
|
||||||
</h2>
|
<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>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -92,7 +124,7 @@ function UserProjectionContent() {
|
|||||||
disabled={isWorking}
|
disabled={isWorking}
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
재동기화
|
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -101,49 +133,72 @@ function UserProjectionContent() {
|
|||||||
disabled={isWorking}
|
disabled={isWorking}
|
||||||
>
|
>
|
||||||
<RotateCcw size={16} />
|
<RotateCcw size={16} />
|
||||||
초기화 후 재구축
|
{t(
|
||||||
|
"ui.admin.user_projection.actions.reset",
|
||||||
|
"Reset and rebuild",
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
{(error as Error)?.message ||
|
{(error as Error)?.message ||
|
||||||
"projection 상태를 불러오지 못했습니다."}
|
t(
|
||||||
|
"msg.admin.user_projection.load_error",
|
||||||
|
"Failed to load projection status.",
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{actionResult ? (
|
{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">
|
<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>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{actionError ? (
|
{actionError ? (
|
||||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
<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>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="rounded-lg border border-border bg-card p-5">
|
<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="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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{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">
|
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div>
|
<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">
|
<dd className="mt-1">
|
||||||
<ProjectionStatusBadge
|
<ProjectionStatusBadge
|
||||||
ready={data?.ready ?? false}
|
ready={data?.ready ?? false}
|
||||||
@@ -153,20 +208,33 @@ function UserProjectionContent() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">
|
<dt className="text-sm text-muted-foreground">
|
||||||
Projection 사용자
|
{t(
|
||||||
|
"ui.admin.user_projection.summary.projected_users",
|
||||||
|
"Projected users",
|
||||||
|
)}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||||
{data?.projectedUsers ?? 0}
|
{data?.projectedUsers ?? 0}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<dd className="mt-1 text-sm">
|
||||||
{formatDateTime(data?.lastSyncedAt)}
|
{formatDateTime(data?.lastSyncedAt)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<dd className="mt-1 text-sm">
|
||||||
{formatDateTime(data?.updatedAt)}
|
{formatDateTime(data?.updatedAt)}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -190,14 +258,22 @@ export default function UserProjectionPage() {
|
|||||||
<RoleGuard
|
<RoleGuard
|
||||||
roles={["super_admin"]}
|
roles={["super_admin"]}
|
||||||
fallback={
|
fallback={
|
||||||
<main className="p-6 md:p-8">
|
<main className="p-6 md:p-8">
|
||||||
<section className="rounded-lg border border-border bg-card p-5">
|
<section className="rounded-lg border border-border bg-card p-5">
|
||||||
<h2 className="text-lg font-semibold">접근 권한이 없습니다</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
{t(
|
||||||
이 화면은 super_admin 권한으로만 접근할 수 있습니다.
|
"ui.admin.user_projection.forbidden.title",
|
||||||
</p>
|
"Access denied",
|
||||||
</section>
|
)}
|
||||||
</main>
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.user_projection.forbidden.description",
|
||||||
|
"This screen is only available to super_admin users.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<UserProjectionContent />
|
<UserProjectionContent />
|
||||||
|
|||||||
@@ -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 { describe, expect, it } from "vitest";
|
||||||
import type { TenantSummary } from "../../../lib/adminApi";
|
import type { TenantSummary } from "../../../lib/adminApi";
|
||||||
import { filterParentTenants } from "./ParentTenantSelector";
|
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||||
|
|
||||||
const tenants: TenantSummary[] = [
|
const tenants: TenantSummary[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
DialogTrigger,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "../../users/orgChartPicker";
|
} from "../../users/orgChartPicker";
|
||||||
|
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||||
|
|
||||||
type ParentTenantSelectorProps = {
|
type ParentTenantSelectorProps = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,26 +35,6 @@ type ParentTenantSelectorProps = {
|
|||||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
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({
|
export function ParentTenantSelector({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
@@ -106,27 +88,100 @@ export function ParentTenantSelector({
|
|||||||
</div>
|
</div>
|
||||||
<input id={id} name={id} type="hidden" value={value} readOnly />
|
<input id={id} name={id} type="hidden" value={value} readOnly />
|
||||||
<div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
|
<div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
|
||||||
<Button
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
type="button"
|
<DialogTrigger asChild>
|
||||||
variant="outline"
|
<Button type="button" variant="outline" size="sm">
|
||||||
size="sm"
|
<Building2 className="h-4 w-4" />
|
||||||
onClick={() => setPickerOpen(true)}
|
{orgChartPickerLabel ??
|
||||||
>
|
selectedTenant?.name ??
|
||||||
<Building2 className="h-4 w-4" />
|
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||||
{orgChartPickerLabel ??
|
</Button>
|
||||||
selectedTenant?.name ??
|
</DialogTrigger>
|
||||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
<DialogContent className="max-w-[460px] p-4">
|
||||||
</Button>
|
<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 && (
|
{localPickerLabel && (
|
||||||
<Button
|
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||||
type="button"
|
<DialogTrigger asChild>
|
||||||
variant="outline"
|
<Button type="button" variant="outline" size="sm">
|
||||||
size="sm"
|
<Building2 className="h-4 w-4" />
|
||||||
onClick={() => setLocalPickerOpen(true)}
|
{localPickerLabel}
|
||||||
>
|
</Button>
|
||||||
<Building2 className="h-4 w-4" />
|
</DialogTrigger>
|
||||||
{localPickerLabel}
|
<DialogContent className="max-w-[460px] p-4">
|
||||||
</Button>
|
<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 ? (
|
{selectedTenant ? (
|
||||||
<>
|
<>
|
||||||
@@ -156,85 +211,6 @@ export function ParentTenantSelector({
|
|||||||
{helpText && (
|
{helpText && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
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 { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -53,13 +54,27 @@ import { t } from "../../../lib/i18n";
|
|||||||
|
|
||||||
type DialogMode = "owner" | "admin";
|
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() {
|
export function TenantAdminsAndOwnersTab() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const currentUserId = auth.user?.profile.sub;
|
const currentUserId = auth.user?.profile.sub;
|
||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||||
|
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
|
||||||
|
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
|
||||||
|
|
||||||
if (!tenantId) return null;
|
if (!tenantId) return null;
|
||||||
|
|
||||||
@@ -95,18 +110,22 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
// Optimistically add to the list to prevent immediate double clicks
|
// Optimistically add to the list to prevent immediate double clicks
|
||||||
const addedUser = searchResults.find((u) => u.id === userId);
|
const addedUser = searchResults.find((u) => u.id === userId);
|
||||||
if (addedUser) {
|
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[]>(
|
queryClient.setQueryData<TenantAdmin[]>(
|
||||||
["tenant-owners", tenantId],
|
["tenant-owners", tenantId],
|
||||||
(old) => {
|
(old) => {
|
||||||
if (!old)
|
if (!old) return [optimisticOwner];
|
||||||
return [
|
|
||||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
|
||||||
];
|
|
||||||
if (old.some((o) => o.id === userId)) return old;
|
if (old.some((o) => o.id === userId)) return old;
|
||||||
return [
|
return [...old, optimisticOwner];
|
||||||
...old,
|
|
||||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,6 +144,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||||
|
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
|
||||||
if (context?.previousOwners) {
|
if (context?.previousOwners) {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["tenant-owners", tenantId],
|
["tenant-owners", tenantId],
|
||||||
@@ -148,6 +168,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
"tenant-owners",
|
"tenant-owners",
|
||||||
tenantId,
|
tenantId,
|
||||||
]);
|
]);
|
||||||
|
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
|
||||||
queryClient.setQueryData<TenantAdmin[]>(
|
queryClient.setQueryData<TenantAdmin[]>(
|
||||||
["tenant-owners", tenantId],
|
["tenant-owners", tenantId],
|
||||||
(old) => (old ? old.filter((o) => o.id !== userId) : []),
|
(old) => (old ? old.filter((o) => o.id !== userId) : []),
|
||||||
@@ -194,18 +215,22 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
|
|
||||||
const addedUser = searchResults.find((u) => u.id === userId);
|
const addedUser = searchResults.find((u) => u.id === userId);
|
||||||
if (addedUser) {
|
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[]>(
|
queryClient.setQueryData<TenantAdmin[]>(
|
||||||
["tenant-admins", tenantId],
|
["tenant-admins", tenantId],
|
||||||
(old) => {
|
(old) => {
|
||||||
if (!old)
|
if (!old) return [optimisticAdmin];
|
||||||
return [
|
|
||||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
|
||||||
];
|
|
||||||
if (old.some((a) => a.id === userId)) return old;
|
if (old.some((a) => a.id === userId)) return old;
|
||||||
return [
|
return [...old, optimisticAdmin];
|
||||||
...old,
|
|
||||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -223,6 +248,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||||
|
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
|
||||||
if (context?.previousAdmins) {
|
if (context?.previousAdmins) {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["tenant-admins", tenantId],
|
["tenant-admins", tenantId],
|
||||||
@@ -246,6 +272,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
"tenant-admins",
|
"tenant-admins",
|
||||||
tenantId,
|
tenantId,
|
||||||
]);
|
]);
|
||||||
|
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
|
||||||
queryClient.setQueryData<TenantAdmin[]>(
|
queryClient.setQueryData<TenantAdmin[]>(
|
||||||
["tenant-admins", tenantId],
|
["tenant-admins", tenantId],
|
||||||
(old) => (old ? old.filter((a) => a.id !== userId) : []),
|
(old) => (old ? old.filter((a) => a.id !== userId) : []),
|
||||||
@@ -312,8 +339,10 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentOwners = ownersQuery.data || [];
|
const serverOwners = ownersQuery.data || [];
|
||||||
const currentAdmins = adminsQuery.data || [];
|
const serverAdmins = adminsQuery.data || [];
|
||||||
|
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
||||||
|
const currentAdmins = mergePendingMembers(serverAdmins, pendingAdmins);
|
||||||
const searchResults = usersQuery.data?.items || [];
|
const searchResults = usersQuery.data?.items || [];
|
||||||
const isDialogOpen = dialogMode !== null;
|
const isDialogOpen = dialogMode !== null;
|
||||||
|
|
||||||
@@ -363,7 +392,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[250px] font-bold">
|
<TableHead className="w-[250px] font-bold">
|
||||||
{t("ui.admin.tenants.owners.table_name", "이름")}
|
{t("ui.admin.tenants.owners.table_name", "이름")}
|
||||||
@@ -371,22 +400,19 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
<TableHead className="font-bold">
|
<TableHead className="font-bold">
|
||||||
{t("ui.admin.tenants.owners.table_email", "이메일")}
|
{t("ui.admin.tenants.owners.table_email", "이메일")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right font-bold w-[100px]">
|
|
||||||
{t("ui.admin.tenants.owners.table_actions", "액션")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{ownersQuery.isLoading ? (
|
{ownersQuery.isLoading ? (
|
||||||
<TableRow>
|
<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" />
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : currentOwners.length === 0 ? (
|
) : currentOwners.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={3}
|
colSpan={2}
|
||||||
className="h-32 text-center text-muted-foreground"
|
className="h-32 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
@@ -404,7 +430,8 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
currentOwners.map((owner) => (
|
currentOwners.map((owner) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={owner.id}
|
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">
|
<TableCell className="font-medium">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -417,46 +444,6 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
<TableCell className="text-muted-foreground italic">
|
<TableCell className="text-muted-foreground italic">
|
||||||
{owner.email}
|
{owner.email}
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -494,7 +481,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[250px] font-bold">
|
<TableHead className="w-[250px] font-bold">
|
||||||
{t("ui.admin.tenants.admins.table_name", "이름")}
|
{t("ui.admin.tenants.admins.table_name", "이름")}
|
||||||
@@ -502,22 +489,19 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
<TableHead className="font-bold">
|
<TableHead className="font-bold">
|
||||||
{t("ui.admin.tenants.admins.table_email", "이메일")}
|
{t("ui.admin.tenants.admins.table_email", "이메일")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right font-bold w-[100px]">
|
|
||||||
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{adminsQuery.isLoading ? (
|
{adminsQuery.isLoading ? (
|
||||||
<TableRow>
|
<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" />
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : currentAdmins.length === 0 ? (
|
) : currentAdmins.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={3}
|
colSpan={2}
|
||||||
className="h-32 text-center text-muted-foreground"
|
className="h-32 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
@@ -535,7 +519,8 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
currentAdmins.map((admin) => (
|
currentAdmins.map((admin) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={admin.id}
|
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">
|
<TableCell className="font-medium">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -548,46 +533,6 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
<TableCell className="text-muted-foreground italic">
|
<TableCell className="text-muted-foreground italic">
|
||||||
{admin.email}
|
{admin.email}
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { Building2, Sparkles } from "lucide-react";
|
import { Building2, Sparkles } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
import { Textarea } from "../../../components/ui/textarea";
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
import { createTenant, fetchAllTenants } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { DomainTagInput } from "../components/DomainTagInput";
|
import { DomainTagInput } from "../components/DomainTagInput";
|
||||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||||
@@ -30,12 +30,19 @@ import {
|
|||||||
shouldAllowHanmacOrgConfig,
|
shouldAllowHanmacOrgConfig,
|
||||||
} from "../utils/orgConfig";
|
} from "../utils/orgConfig";
|
||||||
|
|
||||||
|
type AdminFrontTestHooks = {
|
||||||
|
selectTenantParent?: (tenantId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
function TenantCreatePage() {
|
function TenantCreatePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [type, setType] = useState("COMPANY");
|
const [type, setType] = useState("COMPANY");
|
||||||
const [slug, setSlug] = useState("");
|
const [slug, setSlug] = useState("");
|
||||||
const [parentId, setParentId] = useState("");
|
const [parentId, setParentId] = useState(
|
||||||
|
() => searchParams.get("parentId") ?? "",
|
||||||
|
);
|
||||||
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
|
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
|
||||||
const [orgUnitType, setOrgUnitType] = useState("");
|
const [orgUnitType, setOrgUnitType] = useState("");
|
||||||
const [visibility, setVisibility] = useState<TenantVisibility>("public");
|
const [visibility, setVisibility] = useState<TenantVisibility>("public");
|
||||||
@@ -47,8 +54,8 @@ function TenantCreatePage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const parentQuery = useQuery({
|
const parentQuery = useQuery({
|
||||||
queryKey: ["tenants", { limit: 1000 }],
|
queryKey: ["tenants", "all"],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
const tenants = parentQuery.data?.items ?? [];
|
const tenants = parentQuery.data?.items ?? [];
|
||||||
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
|
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
|
||||||
@@ -74,10 +81,22 @@ function TenantCreatePage() {
|
|||||||
"ui.admin.tenants.create.parent_context.pick_required",
|
"ui.admin.tenants.create.parent_context.pick_required",
|
||||||
"상위 테넌트 선택 필요",
|
"상위 테넌트 선택 필요",
|
||||||
);
|
);
|
||||||
const handleParentChange = (nextParentId: string) => {
|
const handleParentChange = useCallback((nextParentId: string) => {
|
||||||
setParentId(nextParentId);
|
setParentId(nextParentId);
|
||||||
setParentStepConfirmed(false);
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: (overrideForceDomains?: string[]) =>
|
mutationFn: (overrideForceDomains?: string[]) =>
|
||||||
@@ -205,6 +224,14 @@ function TenantCreatePage() {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="tenant-test-select-hanmac-parent"
|
||||||
|
hidden
|
||||||
|
onClick={() => handleParentChange("family-1")}
|
||||||
|
>
|
||||||
|
test-select-hanmac-parent
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{canConfigureHanmacOrg && (
|
{canConfigureHanmacOrg && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function canShowWorksmobileEntry(tenant?: {
|
||||||
|
id?: string;
|
||||||
|
slug?: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
}) {
|
||||||
|
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { canShowWorksmobileEntry } from "./TenantDetailPage";
|
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||||
|
|
||||||
describe("TenantDetailPage Worksmobile entry visibility", () => {
|
describe("TenantDetailPage Worksmobile entry visibility", () => {
|
||||||
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
|
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
|
||||||
|
|||||||
@@ -5,14 +5,8 @@ import { Badge } from "../../../components/ui/badge";
|
|||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
import { normalizeAdminRole } from "../../../lib/roles";
|
||||||
export function canShowWorksmobileEntry(tenant?: {
|
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||||
id?: string;
|
|
||||||
slug?: string;
|
|
||||||
parentId?: string | null;
|
|
||||||
}) {
|
|
||||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TenantDetailPage() {
|
function TenantDetailPage() {
|
||||||
const params = useParams<{ tenantId: string }>();
|
const params = useParams<{ tenantId: string }>();
|
||||||
@@ -30,8 +24,9 @@ function TenantDetailPage() {
|
|||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const profileRole = normalizeAdminRole(profile?.role);
|
||||||
const canAccessSchema =
|
const canAccessSchema =
|
||||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||||
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
|
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
|
||||||
|
|
||||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -513,7 +514,7 @@ function TenantGroupsPage() {
|
|||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.groups.table.name", "NAME")}
|
{t("ui.admin.groups.table.name", "NAME")}
|
||||||
@@ -610,7 +611,7 @@ function TenantGroupsPage() {
|
|||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.groups.members.table.name", "이름")}
|
{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,8 +18,8 @@ import { toast } from "../../../components/ui/use-toast";
|
|||||||
import {
|
import {
|
||||||
approveTenant,
|
approveTenant,
|
||||||
deleteTenant,
|
deleteTenant,
|
||||||
|
fetchAllTenants,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
|
||||||
updateTenant,
|
updateTenant,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
@@ -58,7 +58,7 @@ export function TenantProfilePage() {
|
|||||||
|
|
||||||
const parentQuery = useQuery({
|
const parentQuery = useQuery({
|
||||||
queryKey: ["tenants", "list-all"],
|
queryKey: ["tenants", "list-all"],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage";
|
import { createSchemaField, normalizeSchemaField } from "./tenantSchemaFields";
|
||||||
|
|
||||||
describe("TenantSchemaPage schema field helpers", () => {
|
describe("TenantSchemaPage schema field helpers", () => {
|
||||||
it("creates text fields without varchar maxLength policy", () => {
|
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 { toast } from "../../../components/ui/use-toast";
|
||||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
import { normalizeAdminRole } from "../../../lib/roles";
|
||||||
export type SchemaFieldType =
|
import {
|
||||||
| "text"
|
type SchemaField,
|
||||||
| "number"
|
createSchemaField,
|
||||||
| "boolean"
|
isSchemaFieldType,
|
||||||
| "date"
|
normalizeSchemaField,
|
||||||
| "float"
|
} from "./tenantSchemaFields";
|
||||||
| "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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TenantSchemaPage() {
|
export function TenantSchemaPage() {
|
||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
@@ -101,8 +33,9 @@ export function TenantSchemaPage() {
|
|||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const profileRole = normalizeAdminRole(profile?.role);
|
||||||
const canAccess =
|
const canAccess =
|
||||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||||
|
|
||||||
const tenantQuery = useQuery({
|
const tenantQuery = useQuery({
|
||||||
queryKey: ["tenant", tenantId],
|
queryKey: ["tenant", tenantId],
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ArrowRight, Building2, Plus } from "lucide-react";
|
import { ArrowRight, Building2, Plus } from "lucide-react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
commonStickyTableHeaderClass,
|
||||||
|
commonTableShellClass,
|
||||||
|
commonTableViewportClass,
|
||||||
|
} from "../../../../../common/ui/table";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +23,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import { fetchTenants } from "../../../lib/adminApi";
|
import { fetchAllTenants } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
function TenantSubTenantsPage() {
|
function TenantSubTenantsPage() {
|
||||||
@@ -27,7 +32,7 @@ function TenantSubTenantsPage() {
|
|||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["sub-tenants", tenantId],
|
queryKey: ["sub-tenants", tenantId],
|
||||||
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
|
queryFn: () => fetchAllTenants({ parentId: tenantId ?? undefined }),
|
||||||
enabled: !!tenantId,
|
enabled: !!tenantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,10 +63,10 @@ function TenantSubTenantsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
<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={commonTableShellClass}>
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className={commonTableViewportClass}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.tenants.sub.table.name", "NAME")}
|
{t("ui.admin.tenants.sub.table.name", "NAME")}
|
||||||
@@ -72,16 +77,13 @@ function TenantSubTenantsPage() {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.tenants.sub.table.status", "STATUS")}
|
{t("ui.admin.tenants.sub.table.status", "STATUS")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
|
||||||
{t("ui.admin.tenants.sub.table.action", "ACTION")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{subTenants.length === 0 && (
|
{subTenants.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={4}
|
colSpan={3}
|
||||||
className="text-center py-8 text-muted-foreground"
|
className="text-center py-8 text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
@@ -92,7 +94,11 @@ function TenantSubTenantsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{subTenants.map((tenant) => (
|
{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">
|
<TableCell className="font-semibold">
|
||||||
{tenant.name}
|
{tenant.name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -108,16 +114,6 @@ function TenantSubTenantsPage() {
|
|||||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
UserMinus,
|
UserMinus,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +39,7 @@ import { t } from "../../../lib/i18n";
|
|||||||
|
|
||||||
function TenantUsersPage() {
|
function TenantUsersPage() {
|
||||||
const params = useParams<{ tenantId: string }>();
|
const params = useParams<{ tenantId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const tenantId = params.tenantId ?? "";
|
const tenantId = params.tenantId ?? "";
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -123,7 +125,7 @@ function TenantUsersPage() {
|
|||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.tenants.members.table.name", "NAME")}
|
{t("ui.admin.tenants.members.table.name", "NAME")}
|
||||||
@@ -137,15 +139,12 @@ function TenantUsersPage() {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[80px] text-right">
|
|
||||||
{t("ui.admin.tenants.members.table.actions", "ACTIONS")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{usersQuery.isLoading ? (
|
{usersQuery.isLoading ? (
|
||||||
<TableRow>
|
<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">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Loader2
|
<Loader2
|
||||||
className="animate-spin text-muted-foreground"
|
className="animate-spin text-muted-foreground"
|
||||||
@@ -160,7 +159,7 @@ function TenantUsersPage() {
|
|||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={5}
|
colSpan={4}
|
||||||
className="text-center py-8 text-muted-foreground"
|
className="text-center py-8 text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
@@ -171,7 +170,11 @@ function TenantUsersPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
users.map((user) => (
|
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">
|
<TableCell className="font-semibold">
|
||||||
{user.name}
|
{user.name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -198,43 +201,6 @@ function TenantUsersPage() {
|
|||||||
{t(`ui.common.status.${user.status}`, user.status)}
|
{t(`ui.common.status.${user.status}`, user.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,22 +4,30 @@ import {
|
|||||||
canCreateWorksmobileRow,
|
canCreateWorksmobileRow,
|
||||||
canOpenWorksmobilePasswordManage,
|
canOpenWorksmobilePasswordManage,
|
||||||
canSelectWorksmobileRow,
|
canSelectWorksmobileRow,
|
||||||
|
comparisonFilterOptions,
|
||||||
|
filterVisibleWorksmobileComparisonRows,
|
||||||
filterWorksmobileComparisonRows,
|
filterWorksmobileComparisonRows,
|
||||||
|
filterWorksmobileComparisonRowsBySearch,
|
||||||
formatWorksmobileOrgDetails,
|
formatWorksmobileOrgDetails,
|
||||||
formatWorksmobilePersonName,
|
formatWorksmobilePersonName,
|
||||||
|
formatWorksmobileUpdateDetails,
|
||||||
|
getDefaultGroupComparisonFilters,
|
||||||
getDefaultWorksmobileComparisonColumns,
|
getDefaultWorksmobileComparisonColumns,
|
||||||
getWorksmobileComparisonStatusLabel,
|
getWorksmobileComparisonStatusLabel,
|
||||||
getWorksmobileRowSelectionKey,
|
getWorksmobileRowSelectionKey,
|
||||||
getWorksmobileSelectedActionIds,
|
getWorksmobileSelectedActionIds,
|
||||||
|
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
|
||||||
|
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||||
isImmutableWorksmobileAccount,
|
isImmutableWorksmobileAccount,
|
||||||
summarizeWorksmobileComparison,
|
summarizeWorksmobileComparison,
|
||||||
userFilterOptions,
|
userFilterOptions,
|
||||||
} from "./TenantWorksmobilePage";
|
} from "./worksmobileComparison";
|
||||||
|
|
||||||
describe("TenantWorksmobilePage comparison helpers", () => {
|
describe("TenantWorksmobilePage comparison helpers", () => {
|
||||||
it("summarizes comparison rows by status", () => {
|
it("summarizes comparison rows by status", () => {
|
||||||
const summary = summarizeWorksmobileComparison([
|
const summary = summarizeWorksmobileComparison([
|
||||||
{ resourceType: "USER", status: "matched" },
|
{ resourceType: "USER", status: "matched" },
|
||||||
|
{ resourceType: "GROUP", status: "needs_update" },
|
||||||
{ resourceType: "USER", status: "missing_in_worksmobile" },
|
{ resourceType: "USER", status: "missing_in_worksmobile" },
|
||||||
{ resourceType: "USER", status: "missing_in_baron" },
|
{ resourceType: "USER", status: "missing_in_baron" },
|
||||||
{ resourceType: "USER", status: "missing_external_key" },
|
{ resourceType: "USER", status: "missing_external_key" },
|
||||||
@@ -27,8 +35,9 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(summary).toEqual({
|
expect(summary).toEqual({
|
||||||
total: 5,
|
total: 6,
|
||||||
matched: 1,
|
matched: 1,
|
||||||
|
needsUpdate: 1,
|
||||||
missingInWorksmobile: 1,
|
missingInWorksmobile: 1,
|
||||||
missingInBaron: 2,
|
missingInBaron: 2,
|
||||||
missingExternalKey: 1,
|
missingExternalKey: 1,
|
||||||
@@ -46,6 +55,9 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
|
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
|
||||||
"ex_key 없음",
|
"ex_key 없음",
|
||||||
);
|
);
|
||||||
|
expect(getWorksmobileComparisonStatusLabel("needs_update")).toBe(
|
||||||
|
"업데이트 필요",
|
||||||
|
);
|
||||||
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
|
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
|
||||||
"unknown_status",
|
"unknown_status",
|
||||||
);
|
);
|
||||||
@@ -143,6 +155,42 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
).toBe(false);
|
).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", () => {
|
it("keeps row selection keys separate from Baron action ids", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
@@ -231,7 +279,8 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
expect(
|
expect(
|
||||||
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
|
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
|
||||||
).toEqual([rows[0], rows[1], rows[3]]);
|
).toEqual([rows[0], rows[1], rows[3]]);
|
||||||
expect(filterWorksmobileComparisonRows(rows, [])).toEqual(rows);
|
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
|
||||||
|
expect(filterWorksmobileComparisonRows(rows, [])).toEqual([]);
|
||||||
expect(
|
expect(
|
||||||
filterWorksmobileComparisonRows(rows, [
|
filterWorksmobileComparisonRows(rows, [
|
||||||
"baron_only",
|
"baron_only",
|
||||||
@@ -239,16 +288,198 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
"matched",
|
"matched",
|
||||||
]),
|
]),
|
||||||
).toEqual(rows);
|
).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", () => {
|
it("orders user comparison filter options from Baron-only first", () => {
|
||||||
expect(userFilterOptions.map((option) => option.value)).toEqual([
|
expect(userFilterOptions.map((option) => option.value)).toEqual([
|
||||||
"baron_only",
|
"baron_only",
|
||||||
|
"needs_update",
|
||||||
"works_only",
|
"works_only",
|
||||||
"matched",
|
"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("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", () => {
|
it("formats WORKS account name with level on one line", () => {
|
||||||
expect(
|
expect(
|
||||||
formatWorksmobilePersonName({
|
formatWorksmobilePersonName({
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
126
adminfront/src/features/tenants/routes/tenantListView.ts
Normal file
126
adminfront/src/features/tenants/routes/tenantListView.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { TenantSummary } from "../../../lib/adminApi";
|
||||||
|
import { type TenantNode, buildTenantFullTree } 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 = "",
|
||||||
|
): TenantViewRow[] {
|
||||||
|
const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
408
adminfront/src/features/tenants/routes/worksmobileComparison.ts
Normal file
408
adminfront/src/features/tenants/routes/worksmobileComparison.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
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 !== "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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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"],
|
||||||
|
};
|
||||||
@@ -66,7 +66,7 @@ describe("tenantCsvImport", () => {
|
|||||||
|
|
||||||
it("parses tenant CSV rows with the supported import columns", () => {
|
it("parses tenant CSV rows with the supported import columns", () => {
|
||||||
const rows = parseTenantCSV(
|
const rows = parseTenantCSV(
|
||||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
|
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(rows).toEqual([
|
expect(rows).toEqual([
|
||||||
@@ -80,6 +80,8 @@ describe("tenantCsvImport", () => {
|
|||||||
slug: "hanmac-tech",
|
slug: "hanmac-tech",
|
||||||
memo: "Memo",
|
memo: "Memo",
|
||||||
emailDomain: "hanmac-tech.example.com",
|
emailDomain: "hanmac-tech.example.com",
|
||||||
|
visibility: "internal",
|
||||||
|
orgUnitType: "센터",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -109,15 +111,18 @@ describe("tenantCsvImport", () => {
|
|||||||
|
|
||||||
it("serializes selected matches by filling tenant_id before upload", () => {
|
it("serializes selected matches by filling tenant_id before upload", () => {
|
||||||
const rows = parseTenantCSV(
|
const rows = parseTenantCSV(
|
||||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
|
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀\n",
|
||||||
);
|
);
|
||||||
const preview = buildTenantImportPreview(rows, tenants);
|
const preview = buildTenantImportPreview(rows, tenants);
|
||||||
const csv = serializeTenantImportCSV(preview, {
|
const csv = serializeTenantImportCSV(preview, {
|
||||||
2: "tenant-1",
|
2: "tenant-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(csv.split("\n")[0]).toBe(
|
||||||
|
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
|
||||||
|
);
|
||||||
expect(csv).toContain(
|
expect(csv).toContain(
|
||||||
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com",
|
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,7 +233,7 @@ describe("tenantCsvImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(csv.split("\n")[0]).toBe(
|
expect(csv.split("\n")[0]).toBe(
|
||||||
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain",
|
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
|
||||||
);
|
);
|
||||||
expect(csv).toContain(
|
expect(csv).toContain(
|
||||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
|
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type TenantCSVRow = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
memo: string;
|
memo: string;
|
||||||
emailDomain: string;
|
emailDomain: string;
|
||||||
|
visibility: string;
|
||||||
|
orgUnitType: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantCSVParseOptions = {
|
export type TenantCSVParseOptions = {
|
||||||
@@ -76,6 +78,8 @@ const importHeaders = [
|
|||||||
"slug",
|
"slug",
|
||||||
"memo",
|
"memo",
|
||||||
"email_domain",
|
"email_domain",
|
||||||
|
"visibility",
|
||||||
|
"org_unit_type",
|
||||||
];
|
];
|
||||||
|
|
||||||
const headerAliases: Record<string, TenantCSVSourceKey> = {
|
const headerAliases: Record<string, TenantCSVSourceKey> = {
|
||||||
@@ -102,6 +106,16 @@ const headerAliases: Record<string, TenantCSVSourceKey> = {
|
|||||||
email_domain: "emailDomain",
|
email_domain: "emailDomain",
|
||||||
domain: "emailDomain",
|
domain: "emailDomain",
|
||||||
domains: "emailDomain",
|
domains: "emailDomain",
|
||||||
|
visibility: "visibility",
|
||||||
|
public_setting: "visibility",
|
||||||
|
publicsetting: "visibility",
|
||||||
|
orgunittype: "orgUnitType",
|
||||||
|
org_unit_type: "orgUnitType",
|
||||||
|
"org-unit-type": "orgUnitType",
|
||||||
|
organizationtype: "orgUnitType",
|
||||||
|
organization_type: "orgUnitType",
|
||||||
|
orgtype: "orgUnitType",
|
||||||
|
org_type: "orgUnitType",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseTenantCSV(
|
export function parseTenantCSV(
|
||||||
@@ -159,6 +173,8 @@ export function parseTenantCSV(
|
|||||||
slug,
|
slug,
|
||||||
memo: value("memo"),
|
memo: value("memo"),
|
||||||
emailDomain: value("emailDomain"),
|
emailDomain: value("emailDomain"),
|
||||||
|
visibility: value("visibility"),
|
||||||
|
orgUnitType: value("orgUnitType"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -287,6 +303,8 @@ export function serializeTenantImportCSV(
|
|||||||
slug,
|
slug,
|
||||||
preview.row.memo,
|
preview.row.memo,
|
||||||
preview.row.emailDomain,
|
preview.row.emailDomain,
|
||||||
|
preview.row.visibility,
|
||||||
|
preview.row.orgUnitType,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return `${lines.map(formatCSVRecord).join("\n")}\n`;
|
return `${lines.map(formatCSVRecord).join("\n")}\n`;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { Building2, Plus, Users } from "lucide-react";
|
import { Building2, Plus, Users } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -21,14 +22,14 @@ import {
|
|||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import {
|
import {
|
||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
|
fetchAllTenants,
|
||||||
fetchGroups,
|
fetchGroups,
|
||||||
fetchTenants,
|
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
export default function GlobalUserGroupListPage() {
|
export default function GlobalUserGroupListPage() {
|
||||||
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
|
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
|
||||||
queryKey: ["admin-tenants"],
|
queryKey: ["admin-tenants"],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isTenantsLoading)
|
if (isTenantsLoading)
|
||||||
@@ -87,7 +88,7 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
|
|||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[250px]">그룹명</TableHead>
|
<TableHead className="w-[250px]">그룹명</TableHead>
|
||||||
<TableHead>설명</TableHead>
|
<TableHead>설명</TableHead>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CornerDownRight,
|
CornerDownRight,
|
||||||
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -72,7 +73,8 @@ import {
|
|||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
createUser,
|
createUser,
|
||||||
fetchTenants,
|
exportTenantsCSV,
|
||||||
|
fetchAllTenants,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
updateTenant,
|
updateTenant,
|
||||||
updateUser,
|
updateUser,
|
||||||
@@ -422,6 +424,24 @@ function TenantUserGroupsTab() {
|
|||||||
const [isAddExistingOpen, setIsAddExistingOpen] = useState(false);
|
const [isAddExistingOpen, setIsAddExistingOpen] = useState(false);
|
||||||
const [existingSearch, setExistingSearch] = useState("");
|
const [existingSearch, setExistingSearch] = useState("");
|
||||||
|
|
||||||
|
const exportChildrenMutation = useMutation({
|
||||||
|
mutationFn: (parentId: string) => exportTenantsCSV(true, parentId),
|
||||||
|
onSuccess: ({ blob, filename }) => {
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
onError: () =>
|
||||||
|
toast.error(
|
||||||
|
t("msg.admin.tenants.export_error", "테넌트 내보내기에 실패했습니다."),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
// Data Fetching
|
// Data Fetching
|
||||||
const {
|
const {
|
||||||
data: allTenantsData,
|
data: allTenantsData,
|
||||||
@@ -429,7 +449,7 @@ function TenantUserGroupsTab() {
|
|||||||
refetch: refetchTree,
|
refetch: refetchTree,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["tenants-full-tree-v2"],
|
queryKey: ["tenants-full-tree-v2"],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { currentBase, subTree } = useMemo(() => {
|
const { currentBase, subTree } = useMemo(() => {
|
||||||
@@ -611,6 +631,16 @@ function TenantUserGroupsTab() {
|
|||||||
<UserPlus size={16} className="mr-2" />
|
<UserPlus size={16} className="mr-2" />
|
||||||
{t("ui.admin.users.list.add", "멤버 추가")}
|
{t("ui.admin.users.list.add", "멤버 추가")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => exportChildrenMutation.mutate(selectedNode.id)}
|
||||||
|
disabled={exportChildrenMutation.isPending}
|
||||||
|
data-testid="tenant-subtree-export-btn"
|
||||||
|
>
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
{t("ui.admin.tenants.sub.export", "하위 조직 CSV")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AxiosError } from "axios";
|
|||||||
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
|
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -42,9 +43,9 @@ import { toast } from "../../../components/ui/use-toast";
|
|||||||
import {
|
import {
|
||||||
addGroupMember,
|
addGroupMember,
|
||||||
assignGroupRole,
|
assignGroupRole,
|
||||||
|
fetchAllTenants,
|
||||||
fetchGroup,
|
fetchGroup,
|
||||||
fetchGroupRoles,
|
fetchGroupRoles,
|
||||||
fetchTenants,
|
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
removeGroupMember,
|
removeGroupMember,
|
||||||
removeGroupRole,
|
removeGroupRole,
|
||||||
@@ -91,7 +92,7 @@ export function UserGroupDetailPage() {
|
|||||||
// Fetch all tenants for role assignment
|
// Fetch all tenants for role assignment
|
||||||
const { data: tenantList } = useQuery({
|
const { data: tenantList } = useQuery({
|
||||||
queryKey: ["admin-tenants"],
|
queryKey: ["admin-tenants"],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
enabled: isAddRoleOpen,
|
enabled: isAddRoleOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,7 +194,14 @@ export function UserGroupDetailPage() {
|
|||||||
"Not found"}
|
"Not found"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["user-group-detail", id],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t("ui.common.retry", "다시 시도")}
|
{t("ui.common.retry", "다시 시도")}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="pt-4 border-t">
|
<div className="pt-4 border-t">
|
||||||
@@ -348,7 +356,7 @@ export function UserGroupDetailPage() {
|
|||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="font-bold">
|
<TableHead className="font-bold">
|
||||||
{t("ui.admin.users.list.table.name_email", "사용자")}
|
{t("ui.admin.users.list.table.name_email", "사용자")}
|
||||||
@@ -533,7 +541,7 @@ export function UserGroupDetailPage() {
|
|||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="font-bold">
|
<TableHead className="font-bold">
|
||||||
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
|
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ import {
|
|||||||
type UserAppointment,
|
type UserAppointment,
|
||||||
type UserCreateRequest,
|
type UserCreateRequest,
|
||||||
type UserCreateResponse,
|
type UserCreateResponse,
|
||||||
createTenant,
|
|
||||||
createUser,
|
createUser,
|
||||||
|
fetchAllTenants,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
import { isSuperAdminRole } from "../../lib/roles";
|
||||||
import {
|
import {
|
||||||
type OrgChartTenantSelection,
|
type OrgChartTenantSelection,
|
||||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||||
@@ -56,9 +56,10 @@ import {
|
|||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
import type { UserSchemaField } from "./userSchemaFields";
|
import type { UserSchemaField } from "./userSchemaFields";
|
||||||
|
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||||
|
|
||||||
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
||||||
type UserType = "hanmac" | "external" | "personal";
|
type UserCategory = "hanmac" | "external" | "personal";
|
||||||
|
|
||||||
type PickerTarget = { kind: "appointment"; index: number };
|
type PickerTarget = { kind: "appointment"; index: number };
|
||||||
|
|
||||||
@@ -66,6 +67,13 @@ type AppointmentDraft = UserAppointment & {
|
|||||||
draftId: string;
|
draftId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AdminFrontTestHooks = {
|
||||||
|
selectUserAppointmentTenant?: (
|
||||||
|
selection: OrgChartTenantSelection,
|
||||||
|
index?: number,
|
||||||
|
) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
function createDraftId() {
|
function createDraftId() {
|
||||||
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
|
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
|
||||||
}
|
}
|
||||||
@@ -114,8 +122,8 @@ function UserCreatePage() {
|
|||||||
>(null);
|
>(null);
|
||||||
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
||||||
const [autoPassword, setAutoPassword] = React.useState(true);
|
const [autoPassword, setAutoPassword] = React.useState(true);
|
||||||
const [isHanmacFamily, setIsHanmacFamily] = React.useState(true);
|
const [userCategory, setUserCategory] =
|
||||||
const [userType, setUserType] = React.useState<UserType>("hanmac");
|
React.useState<UserCategory>("hanmac");
|
||||||
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
||||||
AppointmentDraft[]
|
AppointmentDraft[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -125,8 +133,8 @@ function UserCreatePage() {
|
|||||||
const [isResolvingTenant, setIsResolvingTenant] = React.useState(false);
|
const [isResolvingTenant, setIsResolvingTenant] = React.useState(false);
|
||||||
|
|
||||||
const { data: tenantsData } = useQuery({
|
const { data: tenantsData } = useQuery({
|
||||||
queryKey: ["tenants", { limit: 100 }],
|
queryKey: ["tenants", "all"],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
const tenants = tenantsData?.items ?? [];
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|
||||||
@@ -152,6 +160,7 @@ function UserCreatePage() {
|
|||||||
grade: "",
|
grade: "",
|
||||||
position: "",
|
position: "",
|
||||||
jobTitle: "",
|
jobTitle: "",
|
||||||
|
role: "user",
|
||||||
metadata: {},
|
metadata: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -177,17 +186,11 @@ function UserCreatePage() {
|
|||||||
|
|
||||||
const selectedTenantSlug = watch("tenantSlug");
|
const selectedTenantSlug = watch("tenantSlug");
|
||||||
const personalTenant = React.useMemo(
|
const personalTenant = React.useMemo(
|
||||||
() =>
|
() => resolvePersonalTenant(tenants),
|
||||||
tenants.find(
|
|
||||||
(tenant) =>
|
|
||||||
tenant.slug === "personal" ||
|
|
||||||
(tenant.type === "PERSONAL" &&
|
|
||||||
tenant.name.toLowerCase() === "personal"),
|
|
||||||
),
|
|
||||||
[tenants],
|
[tenants],
|
||||||
);
|
);
|
||||||
const selectedTenant =
|
const selectedTenant =
|
||||||
userType !== "external"
|
userCategory !== "external"
|
||||||
? undefined
|
? undefined
|
||||||
: nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug);
|
: nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug);
|
||||||
|
|
||||||
@@ -231,7 +234,7 @@ function UserCreatePage() {
|
|||||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||||
import.meta.env.ORGFRONT_URL,
|
import.meta.env.ORGFRONT_URL,
|
||||||
{
|
{
|
||||||
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
|
tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -280,6 +283,21 @@ function UserCreatePage() {
|
|||||||
return () => window.removeEventListener("message", onMessage);
|
return () => window.removeEventListener("message", onMessage);
|
||||||
}, [applyTenantSelection, pickerTarget]);
|
}, [applyTenantSelection, pickerTarget]);
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const testWindow = window as Window &
|
||||||
|
typeof globalThis & {
|
||||||
|
__adminfrontTestHooks?: AdminFrontTestHooks;
|
||||||
|
};
|
||||||
|
const hooks = testWindow.__adminfrontTestHooks ?? {};
|
||||||
|
hooks.selectUserAppointmentTenant = async (selection, index = 0) => {
|
||||||
|
await applyTenantSelection(selection, {
|
||||||
|
kind: "appointment",
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
testWindow.__adminfrontTestHooks = hooks;
|
||||||
|
}
|
||||||
|
|
||||||
const addAppointment = () => {
|
const addAppointment = () => {
|
||||||
setAdditionalAppointments((current) => [
|
setAdditionalAppointments((current) => [
|
||||||
...current,
|
...current,
|
||||||
@@ -310,25 +328,16 @@ function UserCreatePage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUserTypeChange = (value: string) => {
|
const handleUserCategoryChange = (value: string) => {
|
||||||
const nextType = value as UserType;
|
const nextCategory = value as UserCategory;
|
||||||
setUserType(nextType);
|
setUserCategory(nextCategory);
|
||||||
setIsHanmacFamily(nextType === "hanmac");
|
if (nextCategory !== "hanmac") {
|
||||||
if (nextType !== "hanmac") {
|
|
||||||
setAdditionalAppointments([]);
|
setAdditionalAppointments([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensurePersonalTenant = async () => {
|
const ensurePersonalTenant = async () => {
|
||||||
if (personalTenant) return personalTenant;
|
return personalTenant;
|
||||||
const tenant = await createTenant({
|
|
||||||
name: "Personal",
|
|
||||||
slug: "personal",
|
|
||||||
type: "PERSONAL",
|
|
||||||
status: "active",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
|
||||||
return tenant;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
@@ -355,10 +364,13 @@ function UserCreatePage() {
|
|||||||
setGeneratedPassword(null);
|
setGeneratedPassword(null);
|
||||||
setCreatedEmail(null);
|
setCreatedEmail(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
hanmacFamily: _hanmacFamily,
|
||||||
|
userType: _userType,
|
||||||
|
...formMetadata
|
||||||
|
} = data.metadata ?? {};
|
||||||
const metadata: Record<string, unknown> = {
|
const metadata: Record<string, unknown> = {
|
||||||
...(data.metadata ?? {}),
|
...formMetadata,
|
||||||
hanmacFamily: userType === "hanmac" && isHanmacFamily,
|
|
||||||
userType,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload: UserCreateRequest = {
|
const payload: UserCreateRequest = {
|
||||||
@@ -366,10 +378,11 @@ function UserCreatePage() {
|
|||||||
password: data.password,
|
password: data.password,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
|
role: data.role,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (userType === "external") {
|
if (userCategory === "external") {
|
||||||
if (!data.tenantSlug) {
|
if (!data.tenantSlug) {
|
||||||
setError(
|
setError(
|
||||||
t(
|
t(
|
||||||
@@ -386,7 +399,7 @@ function UserCreatePage() {
|
|||||||
payload.jobTitle = data.jobTitle;
|
payload.jobTitle = data.jobTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userType === "personal") {
|
if (userCategory === "personal") {
|
||||||
try {
|
try {
|
||||||
const tenant = await ensurePersonalTenant();
|
const tenant = await ensurePersonalTenant();
|
||||||
payload.tenantSlug = tenant.slug;
|
payload.tenantSlug = tenant.slug;
|
||||||
@@ -405,7 +418,7 @@ function UserCreatePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userType === "hanmac") {
|
if (userCategory === "hanmac") {
|
||||||
const appointments = additionalAppointments
|
const appointments = additionalAppointments
|
||||||
.filter((appointment) => appointment.tenantId)
|
.filter((appointment) => appointment.tenantId)
|
||||||
.map((appointment) => ({
|
.map((appointment) => ({
|
||||||
@@ -644,7 +657,32 @@ function UserCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={userType} onValueChange={handleUserTypeChange}>
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">
|
||||||
|
{t("ui.admin.users.create.form.role", "역할")}
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
{...register("role")}
|
||||||
|
disabled={!isSuperAdminRole(profile?.role)}
|
||||||
|
>
|
||||||
|
<option value="super_admin">
|
||||||
|
{t("ui.admin.role.super_admin", "시스템 관리자")}
|
||||||
|
</option>
|
||||||
|
<option value="user">
|
||||||
|
{t("ui.admin.role.user", "일반 사용자")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.users.create.form.role_help",
|
||||||
|
"시스템 접근 권한을 결정합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={userCategory} onValueChange={handleUserCategoryChange}>
|
||||||
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="hanmac"
|
value="hanmac"
|
||||||
@@ -761,6 +799,7 @@ function UserCreatePage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={isResolvingTenant}
|
disabled={isResolvingTenant}
|
||||||
|
data-testid={`appointment-tenant-picker-${index}`}
|
||||||
>
|
>
|
||||||
<Building2 className="mr-2 h-4 w-4" />
|
<Building2 className="mr-2 h-4 w-4" />
|
||||||
{appointment.tenantName || "테넌트 선택"}
|
{appointment.tenantName || "테넌트 선택"}
|
||||||
@@ -972,6 +1011,7 @@ function UserCreatePage() {
|
|||||||
title={t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
|
title={t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
|
||||||
src={pickerUrl}
|
src={pickerUrl}
|
||||||
className="h-[600px] w-full rounded-md border"
|
className="h-[600px] w-full rounded-md border"
|
||||||
|
data-testid="appointment-tenant-picker-frame"
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ import {
|
|||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../../components/ui/select";
|
||||||
import { Switch } from "../../components/ui/switch";
|
import { Switch } from "../../components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -56,18 +63,18 @@ import {
|
|||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
type UserAppointment,
|
type UserAppointment,
|
||||||
type UserUpdateRequest,
|
type UserUpdateRequest,
|
||||||
createTenant,
|
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
fetchAllTenants,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchPasswordPolicy,
|
fetchPasswordPolicy,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
|
||||||
fetchUser,
|
fetchUser,
|
||||||
fetchUserRpHistory,
|
fetchUserRpHistory,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
import { normalizeAdminRole } from "../../lib/roles";
|
||||||
import { generateSecurePassword } from "../../lib/utils";
|
import { generateSecurePassword } from "../../lib/utils";
|
||||||
import {
|
import {
|
||||||
type OrgChartTenantSelection,
|
type OrgChartTenantSelection,
|
||||||
@@ -78,11 +85,17 @@ import {
|
|||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
import type { UserSchemaField } from "./userSchemaFields";
|
import type { UserSchemaField } from "./userSchemaFields";
|
||||||
|
import {
|
||||||
|
normalizeUserStatusValue,
|
||||||
|
userStatusLabel,
|
||||||
|
userStatusValues,
|
||||||
|
} from "./userStatus";
|
||||||
|
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||||
|
|
||||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||||
};
|
};
|
||||||
type UserType = "hanmac" | "external" | "personal";
|
type UserCategory = "hanmac" | "external" | "personal";
|
||||||
|
|
||||||
type PasswordResetMode = "generated" | "manual";
|
type PasswordResetMode = "generated" | "manual";
|
||||||
type PickerTarget = { kind: "appointment"; index: number };
|
type PickerTarget = { kind: "appointment"; index: number };
|
||||||
@@ -318,8 +331,8 @@ function UserDetailPage() {
|
|||||||
const [passwordResetError, setPasswordResetError] = React.useState<
|
const [passwordResetError, setPasswordResetError] = React.useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
const [isHanmacFamily, setIsHanmacFamily] = React.useState(false);
|
const [userCategory, setUserCategory] =
|
||||||
const [userType, setUserType] = React.useState<UserType>("external");
|
React.useState<UserCategory>("external");
|
||||||
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
||||||
AppointmentDraft[]
|
AppointmentDraft[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -346,8 +359,8 @@ function UserDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: tenantsData } = useQuery({
|
const { data: tenantsData } = useQuery({
|
||||||
queryKey: ["tenants", { limit: 100 }],
|
queryKey: ["tenants", "all"],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
const tenants = React.useMemo(
|
const tenants = React.useMemo(
|
||||||
() => tenantsData?.items ?? [],
|
() => tenantsData?.items ?? [],
|
||||||
@@ -387,8 +400,9 @@ function UserDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const profileRole = normalizeAdminRole(profile?.role);
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||||
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
||||||
const watchedStatus = watch("status");
|
const watchedStatus = watch("status");
|
||||||
|
|
||||||
@@ -465,20 +479,14 @@ function UserDetailPage() {
|
|||||||
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
||||||
}, [tenants]);
|
}, [tenants]);
|
||||||
const personalTenant = React.useMemo(
|
const personalTenant = React.useMemo(
|
||||||
() =>
|
() => resolvePersonalTenant(tenants),
|
||||||
tenants.find(
|
|
||||||
(tenant) =>
|
|
||||||
tenant.slug === "personal" ||
|
|
||||||
(tenant.type === "PERSONAL" &&
|
|
||||||
tenant.name.toLowerCase() === "personal"),
|
|
||||||
),
|
|
||||||
[tenants],
|
[tenants],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||||
import.meta.env.ORGFRONT_URL,
|
import.meta.env.ORGFRONT_URL,
|
||||||
{
|
{
|
||||||
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
|
tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -566,25 +574,16 @@ function UserDetailPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUserTypeChange = (value: string) => {
|
const handleUserCategoryChange = (value: string) => {
|
||||||
const nextType = value as UserType;
|
const nextCategory = value as UserCategory;
|
||||||
setUserType(nextType);
|
setUserCategory(nextCategory);
|
||||||
setIsHanmacFamily(nextType === "hanmac");
|
if (nextCategory !== "hanmac") {
|
||||||
if (nextType !== "hanmac") {
|
|
||||||
setAdditionalAppointments([]);
|
setAdditionalAppointments([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensurePersonalTenant = async () => {
|
const ensurePersonalTenant = async () => {
|
||||||
if (personalTenant) return personalTenant;
|
return personalTenant;
|
||||||
const tenant = await createTenant({
|
|
||||||
name: "Personal",
|
|
||||||
slug: "personal",
|
|
||||||
type: "PERSONAL",
|
|
||||||
status: "active",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
|
||||||
return tenant;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -598,8 +597,8 @@ function UserDetailPage() {
|
|||||||
name:
|
name:
|
||||||
typeof metadata.primaryTenantName === "string"
|
typeof metadata.primaryTenantName === "string"
|
||||||
? metadata.primaryTenantName
|
? metadata.primaryTenantName
|
||||||
: user.tenant?.name || user.companyCode || "",
|
: user.tenant?.name || user.tenantSlug || "",
|
||||||
slug: user.companyCode,
|
slug: user.tenantSlug,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const fallbackAppointment =
|
const fallbackAppointment =
|
||||||
@@ -616,9 +615,9 @@ function UserDetailPage() {
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
phone: user.phone || "",
|
phone: user.phone || "",
|
||||||
role: user.role,
|
role: user.role,
|
||||||
status: user.status,
|
status: normalizeUserStatusValue(user.status),
|
||||||
tenantSlug:
|
tenantSlug:
|
||||||
user.companyCode ||
|
user.tenantSlug ||
|
||||||
user.joinedTenants?.find(
|
user.joinedTenants?.find(
|
||||||
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||||
)?.slug ||
|
)?.slug ||
|
||||||
@@ -638,14 +637,17 @@ function UserDetailPage() {
|
|||||||
tenants,
|
tenants,
|
||||||
hanmacFamilyTenantId,
|
hanmacFamilyTenantId,
|
||||||
);
|
);
|
||||||
const resolvedUserType =
|
const isPersonalUser =
|
||||||
metadata.userType === "personal" || user.companyCode === "personal"
|
user.tenantSlug === personalTenant.slug ||
|
||||||
? "personal"
|
user.tenant?.id === personalTenant.id ||
|
||||||
: isUserHanmacFamily
|
user.tenant?.slug === personalTenant.slug ||
|
||||||
? "hanmac"
|
metadata.personalTenantId === personalTenant.id;
|
||||||
: "external";
|
const resolvedUserCategory = isPersonalUser
|
||||||
setUserType(resolvedUserType);
|
? "personal"
|
||||||
setIsHanmacFamily(resolvedUserType === "hanmac");
|
: isUserHanmacFamily
|
||||||
|
? "hanmac"
|
||||||
|
: "external";
|
||||||
|
setUserCategory(resolvedUserCategory);
|
||||||
const familyFallbackTenants = [
|
const familyFallbackTenants = [
|
||||||
...(user.joinedTenants ?? []),
|
...(user.joinedTenants ?? []),
|
||||||
...(user.tenant ? [user.tenant] : []),
|
...(user.tenant ? [user.tenant] : []),
|
||||||
@@ -696,7 +698,7 @@ function UserDetailPage() {
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [hanmacFamilyTenantId, tenants, user, reset]);
|
}, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
||||||
@@ -737,20 +739,22 @@ function UserDetailPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
hanmacFamily: _hanmacFamily,
|
||||||
|
userType: _userType,
|
||||||
|
...safeMetadata
|
||||||
|
} = cleanMetadata;
|
||||||
const metadata: Record<string, unknown> = {
|
const metadata: Record<string, unknown> = {
|
||||||
...cleanMetadata,
|
...safeMetadata,
|
||||||
hanmacFamily: userType === "hanmac" && isHanmacFamily,
|
|
||||||
userType,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileData = { ...data };
|
|
||||||
profileData.role = undefined;
|
|
||||||
const payload: UserUpdateRequest = {
|
const payload: UserUpdateRequest = {
|
||||||
...profileData,
|
...data,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
payload.role = undefined;
|
||||||
|
|
||||||
if (userType === "personal") {
|
if (userCategory === "personal") {
|
||||||
try {
|
try {
|
||||||
const tenant = await ensurePersonalTenant();
|
const tenant = await ensurePersonalTenant();
|
||||||
payload.tenantSlug = tenant.slug;
|
payload.tenantSlug = tenant.slug;
|
||||||
@@ -768,7 +772,7 @@ function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userType === "hanmac") {
|
if (userCategory === "hanmac") {
|
||||||
const appointments = additionalAppointments
|
const appointments = additionalAppointments
|
||||||
.filter((appointment) => appointment.tenantId)
|
.filter((appointment) => appointment.tenantId)
|
||||||
.map((appointment) => ({
|
.map((appointment) => ({
|
||||||
@@ -906,7 +910,7 @@ function UserDetailPage() {
|
|||||||
>
|
>
|
||||||
<Building2 size={12} className="mr-1.5" />
|
<Building2 size={12} className="mr-1.5" />
|
||||||
{user.tenant?.name ||
|
{user.tenant?.name ||
|
||||||
user.companyCode ||
|
user.tenantSlug ||
|
||||||
user.joinedTenants?.find(
|
user.joinedTenants?.find(
|
||||||
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||||
)?.name ||
|
)?.name ||
|
||||||
@@ -1052,27 +1056,31 @@ function UserDetailPage() {
|
|||||||
>
|
>
|
||||||
{t("ui.admin.users.detail.form.status", "상태")}
|
{t("ui.admin.users.detail.form.status", "상태")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
|
<Select
|
||||||
<Switch
|
value={normalizeUserStatusValue(watchedStatus || "")}
|
||||||
id="status"
|
onValueChange={(value) =>
|
||||||
checked={watchedStatus === "active"}
|
setValue("status", normalizeUserStatusValue(value), {
|
||||||
onCheckedChange={(checked) =>
|
shouldDirty: true,
|
||||||
setValue("status", checked ? "active" : "inactive")
|
})
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
<span className="text-sm text-muted-foreground">
|
<SelectTrigger id="status" className="h-11 shadow-sm">
|
||||||
{t(
|
<SelectValue />
|
||||||
`ui.common.status.${watchedStatus}`,
|
</SelectTrigger>
|
||||||
watchedStatus || "inactive",
|
<SelectContent>
|
||||||
)}
|
{userStatusValues.map((status) => (
|
||||||
</span>
|
<SelectItem key={status} value={status}>
|
||||||
</div>
|
{userStatusLabel(status)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={userType}
|
value={userCategory}
|
||||||
onValueChange={handleUserTypeChange}
|
onValueChange={handleUserCategoryChange}
|
||||||
className="space-y-4 pt-6 border-t border-dashed"
|
className="space-y-4 pt-6 border-t border-dashed"
|
||||||
>
|
>
|
||||||
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
||||||
@@ -1097,7 +1105,7 @@ function UserDetailPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{userType === "external" && (
|
{userCategory === "external" && (
|
||||||
<div className="grid gap-8 md:grid-cols-2">
|
<div className="grid gap-8 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
@@ -1141,7 +1149,7 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userType === "hanmac" && (
|
{userCategory === "hanmac" && (
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
<div className="space-y-4 rounded-md border p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -1314,7 +1322,7 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userType === "personal" && (
|
{userCategory === "personal" && (
|
||||||
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
||||||
{personalTenant
|
{personalTenant
|
||||||
? `Personal (${personalTenant.slug})`
|
? `Personal (${personalTenant.slug})`
|
||||||
@@ -1322,7 +1330,7 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userType === "external" && (
|
{userCategory === "external" && (
|
||||||
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
|
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,8 +20,8 @@ import {
|
|||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
bulkUpdateUsers,
|
bulkUpdateUsers,
|
||||||
|
fetchAllTenants,
|
||||||
fetchGroups,
|
fetchGroups,
|
||||||
fetchTenants,
|
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@ export function UserBulkMoveGroupModal({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: tenantsData } = useQuery({
|
const { data: tenantsData } = useQuery({
|
||||||
queryKey: ["tenants", { limit: 100 }],
|
queryKey: ["tenants", "all"],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
const tenants = tenantsData?.items ?? [];
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "../../../components/ui/dropdown-menu";
|
||||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
type BulkUserItem,
|
type BulkUserItem,
|
||||||
type BulkUserResult,
|
type BulkUserResult,
|
||||||
bulkCreateUsers,
|
bulkCreateUsers,
|
||||||
createTenant,
|
createTenant,
|
||||||
fetchTenants,
|
fetchAllTenants,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
@@ -42,6 +43,9 @@ import {
|
|||||||
|
|
||||||
interface UserBulkUploadModalProps {
|
interface UserBulkUploadModalProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
|
variant?: "button" | "dropdown" | "custom";
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUserTenantPreviewRows(
|
function buildUserTenantPreviewRows(
|
||||||
@@ -66,6 +70,8 @@ function buildUserTenantPreviewRows(
|
|||||||
slug: user.tenantImport?.slug || user.tenantSlug || key,
|
slug: user.tenantImport?.slug || user.tenantSlug || key,
|
||||||
memo: user.tenantImport?.memo ?? "",
|
memo: user.tenantImport?.memo ?? "",
|
||||||
emailDomain: user.tenantImport?.emailDomain ?? "",
|
emailDomain: user.tenantImport?.emailDomain ?? "",
|
||||||
|
visibility: "public",
|
||||||
|
orgUnitType: "node",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,8 +124,34 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
|||||||
return "text-muted-foreground";
|
return "text-muted-foreground";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
export const downloadUserTemplate = () => {
|
||||||
const [open, setOpen] = React.useState(false);
|
const headers =
|
||||||
|
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
||||||
|
const example =
|
||||||
|
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
||||||
|
const blob = new Blob([`${headers}\n${example}`], {
|
||||||
|
type: "text/csv;charset=utf-8;",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "user_bulk_template.csv";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserBulkUploadModal({
|
||||||
|
onSuccess,
|
||||||
|
variant = "button",
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange: controlledOnOpenChange,
|
||||||
|
}: UserBulkUploadModalProps) {
|
||||||
|
const [localOpen, setLocalOpen] = React.useState(false);
|
||||||
|
const open = controlledOpen !== undefined ? controlledOpen : localOpen;
|
||||||
|
const setOpen = (val: boolean) => {
|
||||||
|
setLocalOpen(val);
|
||||||
|
controlledOnOpenChange?.(val);
|
||||||
|
};
|
||||||
const [file, setFile] = React.useState<File | null>(null);
|
const [file, setFile] = React.useState<File | null>(null);
|
||||||
const [parsing, setParsing] = React.useState(false);
|
const [parsing, setParsing] = React.useState(false);
|
||||||
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
|
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
|
||||||
@@ -137,7 +169,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
|
|
||||||
const tenantQuery = useQuery({
|
const tenantQuery = useQuery({
|
||||||
queryKey: ["tenants", "user-bulk-import"],
|
queryKey: ["tenants", "user-bulk-import"],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
@@ -321,309 +353,334 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
(preview) => preview?.status === "blockingError",
|
(preview) => preview?.status === "blockingError",
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const triggerProps = {
|
||||||
<Dialog
|
disabled: mutation.isPending,
|
||||||
open={open}
|
"data-testid": "bulk-import-btn",
|
||||||
onOpenChange={(val) => {
|
};
|
||||||
setOpen(val);
|
|
||||||
if (!val) reset();
|
const triggerNode =
|
||||||
}}
|
variant === "dropdown" ? (
|
||||||
>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
{...triggerProps}
|
||||||
|
>
|
||||||
|
<Upload size={16} className="mr-2 opacity-50" />
|
||||||
|
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : variant === "custom" ? null : (
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" className="gap-2" {...triggerProps}>
|
||||||
variant="outline"
|
|
||||||
className="gap-2"
|
|
||||||
data-testid="bulk-import-btn"
|
|
||||||
>
|
|
||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl">
|
);
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle data-testid="bulk-upload-title">
|
|
||||||
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t(
|
|
||||||
"msg.admin.users.bulk.description",
|
|
||||||
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{!results ? (
|
return (
|
||||||
<div className="space-y-4 py-4">
|
<>
|
||||||
<div className="flex justify-between items-center">
|
{variant === "dropdown" ? triggerNode : null}
|
||||||
<Button
|
<Dialog
|
||||||
variant="ghost"
|
open={open}
|
||||||
size="sm"
|
onOpenChange={(val) => {
|
||||||
onClick={downloadTemplate}
|
setOpen(val);
|
||||||
className="gap-2"
|
if (!val) reset();
|
||||||
>
|
}}
|
||||||
<Download size={14} />
|
>
|
||||||
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
|
{variant !== "dropdown" && variant !== "custom" && triggerNode}
|
||||||
</Button>
|
<DialogContent className="max-w-2xl">
|
||||||
<input
|
<DialogHeader>
|
||||||
type="file"
|
<DialogTitle data-testid="bulk-upload-title">
|
||||||
accept=".csv"
|
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
|
||||||
className="hidden"
|
</DialogTitle>
|
||||||
ref={fileInputRef}
|
<DialogDescription>
|
||||||
onChange={handleFileChange}
|
{t(
|
||||||
/>
|
"msg.admin.users.bulk.description",
|
||||||
<Button
|
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
|
||||||
onClick={() => fileInputRef.current?.click()}
|
)}
|
||||||
variant="secondary"
|
</DialogDescription>
|
||||||
>
|
</DialogHeader>
|
||||||
{file
|
|
||||||
? t("ui.common.change_file", "파일 변경")
|
|
||||||
: t("ui.common.select_file", "파일 선택")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{file && (
|
{!results ? (
|
||||||
<div className="rounded-lg border p-4 bg-muted/20">
|
<div className="space-y-4 py-4">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex justify-between items-center">
|
||||||
<FileText className="text-primary" />
|
<Button
|
||||||
<span className="font-medium">{file.name}</span>
|
variant="ghost"
|
||||||
<span className="text-xs text-muted-foreground">
|
size="sm"
|
||||||
({(file.size / 1024).toFixed(1)} KB)
|
onClick={downloadTemplate}
|
||||||
</span>
|
className="gap-2"
|
||||||
</div>
|
>
|
||||||
{parsing ? (
|
<Download size={14} />
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
{t(
|
||||||
<Loader2 size={14} className="animate-spin" />
|
"ui.admin.users.bulk.download_template",
|
||||||
{t("msg.common.parsing", "파싱 중...")}
|
"템플릿 다운로드",
|
||||||
</div>
|
)}
|
||||||
) : (
|
</Button>
|
||||||
<div className="text-sm text-muted-foreground">
|
<Button asChild variant="secondary" className="cursor-pointer">
|
||||||
{t(
|
<label>
|
||||||
"msg.admin.users.bulk.parsed_count",
|
{file
|
||||||
"{{count}}명의 사용자가 감지되었습니다.",
|
? t("ui.common.change_file", "파일 변경")
|
||||||
{ count: previewData.length },
|
: t("ui.common.select_file", "파일 선택")}
|
||||||
)}
|
<input
|
||||||
</div>
|
type="file"
|
||||||
)}
|
accept=".csv"
|
||||||
|
className="hidden"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Allow picking the same file again if it was cleared
|
||||||
|
(e.target as HTMLInputElement).value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{tenantPreviewRows.length > 0 && (
|
{file && (
|
||||||
<div
|
<div className="rounded-lg border p-4 bg-muted/20">
|
||||||
className="rounded-md border p-3 text-sm"
|
<div className="flex items-center gap-3 mb-2">
|
||||||
data-testid="user-import-tenant-resolution"
|
<FileText className="text-primary" />
|
||||||
>
|
<span className="font-medium">{file.name}</span>
|
||||||
<div className="mb-2 font-medium">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
|
({(file.size / 1024).toFixed(1)} KB)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{parsing ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
{t("msg.common.parsing", "파싱 중...")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.users.bulk.parsed_count",
|
||||||
|
"{{count}}명의 사용자가 감지되었습니다.",
|
||||||
|
{ count: previewData.length },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
)}
|
||||||
{tenantPreviewRows.map((preview) => (
|
|
||||||
<div
|
{tenantPreviewRows.length > 0 && (
|
||||||
key={preview.row.rowNumber}
|
<div
|
||||||
className="grid gap-2 sm:grid-cols-[1fr_1fr]"
|
className="rounded-md border p-3 text-sm"
|
||||||
>
|
data-testid="user-import-tenant-resolution"
|
||||||
<div>
|
>
|
||||||
<div className="font-medium">{preview.row.name}</div>
|
<div className="mb-2 font-medium">
|
||||||
<div className="font-mono text-xs text-muted-foreground">
|
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
|
||||||
{preview.row.slug}
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tenantPreviewRows.map((preview) => (
|
||||||
|
<div
|
||||||
|
key={preview.row.rowNumber}
|
||||||
|
className="grid gap-2 sm:grid-cols-[1fr_1fr]"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{preview.row.name}</div>
|
||||||
|
<div className="font-mono text-xs text-muted-foreground">
|
||||||
|
{preview.row.slug}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<select
|
||||||
<select
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
|
||||||
value={
|
|
||||||
selectedTenantMatches[preview.row.rowNumber] ??
|
|
||||||
"__create__"
|
|
||||||
}
|
|
||||||
onChange={(event) =>
|
|
||||||
setSelectedTenantMatches((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[preview.row.rowNumber]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="__create__">
|
|
||||||
{t(
|
|
||||||
"ui.admin.users.bulk.create_missing_tenant",
|
|
||||||
"신규 생성",
|
|
||||||
)}
|
|
||||||
</option>
|
|
||||||
{preview.candidates.map((candidate) => (
|
|
||||||
<option
|
|
||||||
key={candidate.tenantId}
|
|
||||||
value={candidate.tenantId}
|
|
||||||
>
|
|
||||||
{candidate.name} ({candidate.slug})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{(selectedTenantMatches[preview.row.rowNumber] ??
|
|
||||||
"__create__") === "__create__" && (
|
|
||||||
<input
|
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
|
|
||||||
value={
|
value={
|
||||||
selectedTenantCreateSlugs[
|
selectedTenantMatches[preview.row.rowNumber] ??
|
||||||
preview.row.rowNumber
|
"__create__"
|
||||||
] ?? ""
|
|
||||||
}
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSelectedTenantCreateSlugs((prev) => ({
|
setSelectedTenantMatches((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[preview.row.rowNumber]: event.target.value,
|
[preview.row.rowNumber]: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
<option value="__create__">
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.bulk.create_missing_tenant",
|
||||||
|
"신규 생성",
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
{preview.candidates.map((candidate) => (
|
||||||
|
<option
|
||||||
|
key={candidate.tenantId}
|
||||||
|
value={candidate.tenantId}
|
||||||
|
>
|
||||||
|
{candidate.name} ({candidate.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(selectedTenantMatches[preview.row.rowNumber] ??
|
||||||
|
"__create__") === "__create__" && (
|
||||||
|
<input
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
|
||||||
|
value={
|
||||||
|
selectedTenantCreateSlugs[
|
||||||
|
preview.row.rowNumber
|
||||||
|
] ?? ""
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedTenantCreateSlugs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[preview.row.rowNumber]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewData.length > 0 && (
|
||||||
|
<ScrollArea className="h-[200px] rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="p-2 text-left">Email</th>
|
||||||
|
<th className="p-2 text-left">Name</th>
|
||||||
|
<th className="p-2 text-left">Tenant</th>
|
||||||
|
<th className="p-2 text-left">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{previewData.slice(0, 10).map((u, index) => (
|
||||||
|
<tr key={`${u.email}-${index}`} className="border-t">
|
||||||
|
<td className="p-2">
|
||||||
|
<input
|
||||||
|
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
|
||||||
|
value={
|
||||||
|
hanmacEmailPreviews[index]?.finalEmail ??
|
||||||
|
u.email
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
setPreviewData((prev) =>
|
||||||
|
prev.map((item, itemIndex) =>
|
||||||
|
itemIndex === index
|
||||||
|
? { ...item, email: event.target.value }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-2">{u.name}</td>
|
||||||
|
<td className="p-2">{u.tenantSlug || "-"}</td>
|
||||||
|
<td
|
||||||
|
className={`p-2 text-xs ${hanmacEmailStatusClass(
|
||||||
|
hanmacEmailPreviews[index],
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
|
||||||
|
{hanmacEmailPreviews[index]?.reason && (
|
||||||
|
<div>{hanmacEmailPreviews[index]?.reason}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{previewData.length > 10 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={4}
|
||||||
|
className="p-2 text-center text-muted-foreground italic"
|
||||||
|
>
|
||||||
|
... and {previewData.length - 10} more users
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{successCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase">
|
||||||
|
{t("ui.common.success", "성공")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-10 bg-border" />
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
<div className="text-2xl font-bold text-destructive">
|
||||||
|
{failCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase">
|
||||||
|
{t("ui.common.fail", "실패")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[250px] rounded-md border">
|
||||||
|
<div className="p-2 space-y-2">
|
||||||
|
{results.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.email}
|
||||||
|
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
|
||||||
|
>
|
||||||
|
{r.success ? (
|
||||||
|
<CheckCircle2
|
||||||
|
size={16}
|
||||||
|
className="text-green-500 mt-0.5"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AlertCircle
|
||||||
|
size={16}
|
||||||
|
className="text-destructive mt-0.5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{r.email}</div>
|
||||||
|
{!r.success && (
|
||||||
|
<div className="text-xs text-destructive">
|
||||||
|
{r.message}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{previewData.length > 0 && (
|
|
||||||
<ScrollArea className="h-[200px] rounded-md border">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-muted sticky top-0">
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 text-left">Email</th>
|
|
||||||
<th className="p-2 text-left">Name</th>
|
|
||||||
<th className="p-2 text-left">Tenant</th>
|
|
||||||
<th className="p-2 text-left">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{previewData.slice(0, 10).map((u, index) => (
|
|
||||||
<tr key={`${u.email}-${index}`} className="border-t">
|
|
||||||
<td className="p-2">
|
|
||||||
<input
|
|
||||||
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
|
|
||||||
value={
|
|
||||||
hanmacEmailPreviews[index]?.finalEmail ?? u.email
|
|
||||||
}
|
|
||||||
onChange={(event) =>
|
|
||||||
setPreviewData((prev) =>
|
|
||||||
prev.map((item, itemIndex) =>
|
|
||||||
itemIndex === index
|
|
||||||
? { ...item, email: event.target.value }
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="p-2">{u.name}</td>
|
|
||||||
<td className="p-2">{u.tenantSlug || "-"}</td>
|
|
||||||
<td
|
|
||||||
className={`p-2 text-xs ${hanmacEmailStatusClass(
|
|
||||||
hanmacEmailPreviews[index],
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
|
|
||||||
{hanmacEmailPreviews[index]?.reason && (
|
|
||||||
<div>{hanmacEmailPreviews[index]?.reason}</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{previewData.length > 10 && (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={4}
|
|
||||||
className="p-2 text-center text-muted-foreground italic"
|
|
||||||
>
|
|
||||||
... and {previewData.length - 10} more users
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
|
||||||
<div className="flex-1 text-center">
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
{successCount}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground uppercase">
|
|
||||||
{t("ui.common.success", "성공")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-px h-10 bg-border" />
|
|
||||||
<div className="flex-1 text-center">
|
|
||||||
<div className="text-2xl font-bold text-destructive">
|
|
||||||
{failCount}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground uppercase">
|
|
||||||
{t("ui.common.fail", "실패")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="h-[250px] rounded-md border">
|
|
||||||
<div className="p-2 space-y-2">
|
|
||||||
{results.map((r) => (
|
|
||||||
<div
|
|
||||||
key={r.email}
|
|
||||||
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
|
|
||||||
>
|
|
||||||
{r.success ? (
|
|
||||||
<CheckCircle2
|
|
||||||
size={16}
|
|
||||||
className="text-green-500 mt-0.5"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AlertCircle
|
|
||||||
size={16}
|
|
||||||
className="text-destructive mt-0.5"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium truncate">{r.email}</div>
|
|
||||||
{!r.success && (
|
|
||||||
<div className="text-xs text-destructive">
|
|
||||||
{r.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
{!results ? (
|
|
||||||
<Button
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={
|
|
||||||
previewData.length === 0 ||
|
|
||||||
mutation.isPending ||
|
|
||||||
preparing ||
|
|
||||||
hasBlockingHanmacEmailRows
|
|
||||||
}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
data-testid="bulk-start-btn"
|
|
||||||
>
|
|
||||||
{(mutation.isPending || preparing) && (
|
|
||||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
|
||||||
)}
|
|
||||||
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
data-testid="bulk-close-dialog-btn"
|
|
||||||
>
|
|
||||||
{t("ui.common.close", "닫기")}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
<DialogFooter>
|
||||||
</Dialog>
|
{!results ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={
|
||||||
|
previewData.length === 0 ||
|
||||||
|
mutation.isPending ||
|
||||||
|
preparing ||
|
||||||
|
hasBlockingHanmacEmailRows
|
||||||
|
}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
data-testid="bulk-start-btn"
|
||||||
|
>
|
||||||
|
{(mutation.isPending || preparing) && (
|
||||||
|
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
data-testid="bulk-close-dialog-btn"
|
||||||
|
>
|
||||||
|
{t("ui.common.close", "닫기")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ describe("orgChartPicker", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds internal visibility to tenant picker URLs only when requested", () => {
|
||||||
|
expect(
|
||||||
|
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
|
||||||
|
includeInternal: true,
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600&includeInternal=true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("adds tenantId to the tenant picker URL when Hanmac family scope is known", () => {
|
it("adds tenantId to the tenant picker URL when Hanmac family scope is known", () => {
|
||||||
expect(
|
expect(
|
||||||
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
|
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
|
||||||
@@ -34,12 +44,22 @@ describe("orgChartPicker", () => {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
).toBe(
|
).toBe(
|
||||||
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id",
|
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id%26includeInternal%3Dtrue",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds the chart navigation URL through the org-chart auto login entry", () => {
|
it("builds the admin chart navigation URL with internal visibility enabled", () => {
|
||||||
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
|
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
|
||||||
|
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can build chart navigation URL without internal visibility", () => {
|
||||||
|
expect(
|
||||||
|
buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", {
|
||||||
|
includeInternal: false,
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
|
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -169,4 +189,66 @@ describe("orgChartPicker", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not treat legacy hanmacFamily metadata as Hanmac family without tenant evidence", () => {
|
||||||
|
const tenants = [
|
||||||
|
{
|
||||||
|
id: "hanmac-family-id",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
name: "한맥가족",
|
||||||
|
type: "COMPANY_GROUP",
|
||||||
|
parentId: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "external-id",
|
||||||
|
slug: "external",
|
||||||
|
name: "External",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentId: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isHanmacFamilyUser(
|
||||||
|
{
|
||||||
|
companyCode: "external",
|
||||||
|
tenant: tenants[1],
|
||||||
|
metadata: { hanmacFamily: true },
|
||||||
|
},
|
||||||
|
tenants,
|
||||||
|
"hanmac-family-id",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat userType metadata as Hanmac family without tenant evidence", () => {
|
||||||
|
const tenants = [
|
||||||
|
{
|
||||||
|
id: "hanmac-family-id",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
name: "한맥가족",
|
||||||
|
type: "COMPANY_GROUP",
|
||||||
|
parentId: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "external-id",
|
||||||
|
slug: "external",
|
||||||
|
name: "External",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentId: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isHanmacFamilyUser(
|
||||||
|
{
|
||||||
|
companyCode: "external",
|
||||||
|
tenant: tenants[1],
|
||||||
|
metadata: { userType: "hanmac" },
|
||||||
|
},
|
||||||
|
tenants,
|
||||||
|
"hanmac-family-id",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ export type OrgChartTenantSelection = {
|
|||||||
|
|
||||||
export type TenantFilterTarget = {
|
export type TenantFilterTarget = {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
tenantId?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
tenantName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HanmacFamilyUserTarget = {
|
export type HanmacFamilyUserTarget = {
|
||||||
@@ -31,10 +34,12 @@ type OrgChartPickerMessage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type OrgChartTenantPickerOptions = {
|
type OrgChartTenantPickerOptions = {
|
||||||
|
includeInternal?: boolean;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OrgChartLoginOptions = {
|
type OrgChartLoginOptions = {
|
||||||
|
includeInternal?: boolean;
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,20 +125,43 @@ export function isHanmacFamilyUser<T extends TenantFilterTarget>(
|
|||||||
tenants: T[],
|
tenants: T[],
|
||||||
hanmacFamilyTenantId?: string,
|
hanmacFamilyTenantId?: string,
|
||||||
) {
|
) {
|
||||||
const metadata = user.metadata ?? {};
|
const metadataAppointments = Array.isArray(
|
||||||
if (metadata.hanmacFamily === true || metadata.userType === "hanmac") {
|
user.metadata?.additionalAppointments,
|
||||||
return true;
|
)
|
||||||
}
|
? user.metadata.additionalAppointments
|
||||||
|
.map((appointment) => appointment as TenantFilterTarget)
|
||||||
|
.filter(
|
||||||
|
(appointment) =>
|
||||||
|
typeof appointment.tenantId === "string" ||
|
||||||
|
typeof appointment.id === "string" ||
|
||||||
|
typeof appointment.tenantSlug === "string" ||
|
||||||
|
typeof appointment.slug === "string",
|
||||||
|
)
|
||||||
|
.map((appointment) => ({
|
||||||
|
id: appointment.id ?? appointment.tenantId,
|
||||||
|
slug: appointment.slug ?? appointment.tenantSlug,
|
||||||
|
parentId: appointment.parentId,
|
||||||
|
type: appointment.type,
|
||||||
|
name: appointment.name ?? appointment.tenantName,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
const tenantBySlug = new Map(
|
const tenantBySlug = new Map(
|
||||||
tenants
|
tenants
|
||||||
.filter((tenant) => tenant.slug?.trim())
|
.filter((tenant) => tenant.slug?.trim())
|
||||||
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
|
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
|
||||||
);
|
);
|
||||||
|
const tenantById = new Map(
|
||||||
|
tenants
|
||||||
|
.filter((tenant) => tenant.id?.trim())
|
||||||
|
.map((tenant) => [tenant.id as string, tenant]),
|
||||||
|
);
|
||||||
const tenantCandidates = [
|
const tenantCandidates = [
|
||||||
user.tenant,
|
user.tenant,
|
||||||
...(user.joinedTenants ?? []),
|
...(user.joinedTenants ?? []),
|
||||||
tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""),
|
...metadataAppointments,
|
||||||
|
...metadataAppointments.map((appointment) =>
|
||||||
|
tenantById.get(appointment.id ?? ""),
|
||||||
|
),
|
||||||
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
|
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -178,6 +206,9 @@ export function buildOrgChartTenantPickerUrl(
|
|||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
params.set("tenantId", tenantId);
|
params.set("tenantId", tenantId);
|
||||||
}
|
}
|
||||||
|
if (options.includeInternal) {
|
||||||
|
params.set("includeInternal", "true");
|
||||||
|
}
|
||||||
|
|
||||||
return `${normalizedBase}/embed/picker?${params.toString()}`;
|
return `${normalizedBase}/embed/picker?${params.toString()}`;
|
||||||
}
|
}
|
||||||
@@ -186,16 +217,25 @@ export function buildAuthenticatedOrgChartTenantPickerUrl(
|
|||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
options: OrgChartTenantPickerOptions = {},
|
options: OrgChartTenantPickerOptions = {},
|
||||||
) {
|
) {
|
||||||
const pickerUrl = buildOrgChartTenantPickerUrl("", options);
|
const pickerUrl = buildOrgChartTenantPickerUrl("", {
|
||||||
|
includeInternal: true,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
|
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAuthenticatedOrgChartUrl(
|
export function buildAuthenticatedOrgChartUrl(
|
||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
options: OrgChartLoginOptions = {},
|
options: OrgChartLoginOptions = { includeInternal: true },
|
||||||
) {
|
) {
|
||||||
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
|
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
|
||||||
const returnTo = options.returnTo?.trim() || "/chart";
|
let returnTo = options.returnTo?.trim() || "/chart";
|
||||||
|
if (options.includeInternal && returnTo.startsWith("/chart")) {
|
||||||
|
const [path, query = ""] = returnTo.split("?", 2);
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
params.set("includeInternal", "true");
|
||||||
|
returnTo = `${path}?${params.toString()}`;
|
||||||
|
}
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
auto: "1",
|
auto: "1",
|
||||||
returnTo,
|
returnTo,
|
||||||
|
|||||||
43
adminfront/src/features/users/userStatus.test.ts
Normal file
43
adminfront/src/features/users/userStatus.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
normalizeUserStatusValue,
|
||||||
|
userStatusLabel,
|
||||||
|
userStatusValues,
|
||||||
|
} from "./userStatus";
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => ({
|
||||||
|
t: (key: string, fallback?: string) => fallback ?? key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("userStatus", () => {
|
||||||
|
it("exposes canonical user status values", () => {
|
||||||
|
expect(userStatusValues).toEqual([
|
||||||
|
"active",
|
||||||
|
"temporary_leave",
|
||||||
|
"suspended",
|
||||||
|
"preboarding",
|
||||||
|
"baron_guest",
|
||||||
|
"extended_leave",
|
||||||
|
"archived",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy status values", () => {
|
||||||
|
expect(normalizeUserStatusValue("inactive")).toBe("preboarding");
|
||||||
|
expect(normalizeUserStatusValue("leave_of_absence")).toBe(
|
||||||
|
"temporary_leave",
|
||||||
|
);
|
||||||
|
expect(normalizeUserStatusValue("baron_only")).toBe("baron_guest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to preboarding when status is missing", () => {
|
||||||
|
expect(normalizeUserStatusValue(undefined)).toBe("preboarding");
|
||||||
|
expect(normalizeUserStatusValue(null)).toBe("preboarding");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses canonical labels for legacy status values", () => {
|
||||||
|
expect(userStatusLabel("baron_only")).toBe("baron_guest");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,13 +2,42 @@ import { t } from "../../lib/i18n";
|
|||||||
|
|
||||||
export const userStatusValues = [
|
export const userStatusValues = [
|
||||||
"active",
|
"active",
|
||||||
"inactive",
|
"temporary_leave",
|
||||||
"suspended",
|
"suspended",
|
||||||
"leave_of_absence",
|
"preboarding",
|
||||||
|
"baron_guest",
|
||||||
|
"extended_leave",
|
||||||
|
"archived",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type UserStatusValue = (typeof userStatusValues)[number];
|
export type UserStatusValue = (typeof userStatusValues)[number];
|
||||||
|
|
||||||
export function userStatusLabel(status: string) {
|
export function normalizeUserStatusValue(status?: string | null): UserStatusValue {
|
||||||
return t(`ui.common.status.${status}`, status);
|
switch ((status ?? "").trim().toLowerCase()) {
|
||||||
|
case "active":
|
||||||
|
return "active";
|
||||||
|
case "temporary_leave":
|
||||||
|
case "leave_of_absence":
|
||||||
|
return "temporary_leave";
|
||||||
|
case "suspended":
|
||||||
|
case "blocked":
|
||||||
|
return "suspended";
|
||||||
|
case "preboarding":
|
||||||
|
case "inactive":
|
||||||
|
return "preboarding";
|
||||||
|
case "baron_guest":
|
||||||
|
case "baron_only":
|
||||||
|
return "baron_guest";
|
||||||
|
case "extended_leave":
|
||||||
|
return "extended_leave";
|
||||||
|
case "archived":
|
||||||
|
return "archived";
|
||||||
|
default:
|
||||||
|
return "preboarding";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userStatusLabel(status: string) {
|
||||||
|
const normalized = normalizeUserStatusValue(status);
|
||||||
|
return t(`ui.common.status.${normalized}`, normalized);
|
||||||
}
|
}
|
||||||
|
|||||||
37
adminfront/src/features/users/utils/personalTenant.test.ts
Normal file
37
adminfront/src/features/users/utils/personalTenant.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
GLOBAL_PERSONAL_TENANT_ID,
|
||||||
|
resolvePersonalTenant,
|
||||||
|
} from "./personalTenant";
|
||||||
|
|
||||||
|
describe("resolvePersonalTenant", () => {
|
||||||
|
it("uses the fixed global Personal tenant when it is not included in the paged tenant list", () => {
|
||||||
|
expect(resolvePersonalTenant([])).toMatchObject({
|
||||||
|
id: GLOBAL_PERSONAL_TENANT_ID,
|
||||||
|
slug: "personal",
|
||||||
|
name: "Personal",
|
||||||
|
type: "PERSONAL",
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the tenant returned by the API when available", () => {
|
||||||
|
expect(
|
||||||
|
resolvePersonalTenant([
|
||||||
|
{
|
||||||
|
id: "api-personal-id",
|
||||||
|
slug: "personal",
|
||||||
|
name: "Personal",
|
||||||
|
type: "PERSONAL",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toMatchObject({
|
||||||
|
id: "api-personal-id",
|
||||||
|
slug: "personal",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
34
adminfront/src/features/users/utils/personalTenant.ts
Normal file
34
adminfront/src/features/users/utils/personalTenant.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { TenantSummary } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
export const GLOBAL_PERSONAL_TENANT_ID =
|
||||||
|
import.meta.env.VITE_PERSONAL_TENANT_ID ||
|
||||||
|
"9607eb7b-04d2-42ab-80fe-780fe21c7e8f";
|
||||||
|
|
||||||
|
export const GLOBAL_PERSONAL_TENANT_SLUG =
|
||||||
|
import.meta.env.VITE_PERSONAL_TENANT_SLUG || "personal";
|
||||||
|
|
||||||
|
export function isPersonalTenant(
|
||||||
|
tenant: Pick<TenantSummary, "name" | "slug" | "type">,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
tenant.slug === GLOBAL_PERSONAL_TENANT_SLUG ||
|
||||||
|
(tenant.type === "PERSONAL" && tenant.name.toLowerCase() === "personal")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePersonalTenant(tenants: TenantSummary[]): TenantSummary {
|
||||||
|
const tenant = tenants.find(isPersonalTenant);
|
||||||
|
if (tenant) return tenant;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: GLOBAL_PERSONAL_TENANT_ID,
|
||||||
|
slug: GLOBAL_PERSONAL_TENANT_SLUG,
|
||||||
|
name: "Personal",
|
||||||
|
type: "PERSONAL",
|
||||||
|
description: "개인 사용자 기본 루트 테넌트",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "2026-05-04T06:52:59.187802Z",
|
||||||
|
updatedAt: "2026-05-04T06:52:59.191145Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import "../../common/theme/base.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@@ -24,37 +26,7 @@
|
|||||||
--input: 215 25% 24%;
|
--input: 215 25% 24%;
|
||||||
--ring: 209 79% 52%;
|
--ring: 209 79% 52%;
|
||||||
--radius: 0.75rem;
|
--radius: 0.75rem;
|
||||||
}
|
--app-background-image: radial-gradient(
|
||||||
|
|
||||||
.light {
|
|
||||||
--background: 0 0% 98%;
|
|
||||||
--foreground: 223 25% 12%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 223 25% 12%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 223 25% 12%;
|
|
||||||
--primary: 209 79% 52%;
|
|
||||||
--primary-foreground: 0 0% 100%;
|
|
||||||
--secondary: 220 17% 94%;
|
|
||||||
--secondary-foreground: 223 25% 20%;
|
|
||||||
--muted: 223 15% 45%;
|
|
||||||
--muted-foreground: 223 15% 45%;
|
|
||||||
--accent: 40 96% 62%;
|
|
||||||
--accent-foreground: 223 25% 12%;
|
|
||||||
--destructive: 0 84% 60%;
|
|
||||||
--destructive-foreground: 0 0% 100%;
|
|
||||||
--border: 220 17% 90%;
|
|
||||||
--input: 220 17% 90%;
|
|
||||||
--ring: 209 79% 52%;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
|
||||||
background-image: radial-gradient(
|
|
||||||
circle at 10% 18%,
|
circle at 10% 18%,
|
||||||
rgba(54, 211, 153, 0.16),
|
rgba(54, 211, 153, 0.16),
|
||||||
transparent 28%
|
transparent 28%
|
||||||
@@ -70,14 +42,4 @@
|
|||||||
transparent 30%
|
transparent 30%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
|
||||||
@apply text-inherit no-underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.glass-panel {
|
|
||||||
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
77
adminfront/src/lib/adminApi.test.ts
Normal file
77
adminfront/src/lib/adminApi.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const apiClient = {
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("./apiClient", () => ({
|
||||||
|
default: apiClient,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("adminApi user tenant payloads", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiClient.post.mockReset();
|
||||||
|
apiClient.put.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends tenantSlug without remapping it to companyCode when creating a user", async () => {
|
||||||
|
const { createUser } = await import("./adminApi");
|
||||||
|
apiClient.post.mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
|
await createUser({
|
||||||
|
email: "user@test.com",
|
||||||
|
name: "Test User",
|
||||||
|
tenantSlug: "test-tenant",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/users",
|
||||||
|
expect.objectContaining({ tenantSlug: "test-tenant" }),
|
||||||
|
);
|
||||||
|
expect(apiClient.post.mock.calls[0][1]).not.toHaveProperty("companyCode");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends tenantSlug without remapping it to companyCode when updating a user", async () => {
|
||||||
|
const { updateUser } = await import("./adminApi");
|
||||||
|
apiClient.put.mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
|
await updateUser("user-id", { tenantSlug: "new-tenant" });
|
||||||
|
|
||||||
|
expect(apiClient.put).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/users/user-id",
|
||||||
|
expect.objectContaining({ tenantSlug: "new-tenant" }),
|
||||||
|
);
|
||||||
|
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps tenantSlug payloads unchanged for bulk user APIs", async () => {
|
||||||
|
const { bulkCreateUsers, bulkUpdateUsers } = await import("./adminApi");
|
||||||
|
apiClient.post.mockResolvedValue({ data: {} });
|
||||||
|
apiClient.put.mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
|
await bulkCreateUsers([
|
||||||
|
{
|
||||||
|
email: "user@test.com",
|
||||||
|
name: "Test User",
|
||||||
|
tenantSlug: "test-tenant",
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await bulkUpdateUsers({
|
||||||
|
userIds: ["user-id"],
|
||||||
|
tenantSlug: "new-tenant",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({
|
||||||
|
tenantSlug: "test-tenant",
|
||||||
|
});
|
||||||
|
expect(apiClient.post.mock.calls[0][1].users[0]).not.toHaveProperty(
|
||||||
|
"companyCode",
|
||||||
|
);
|
||||||
|
expect(apiClient.put.mock.calls[0][1]).toMatchObject({
|
||||||
|
tenantSlug: "new-tenant",
|
||||||
|
});
|
||||||
|
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { fetchAllCursorPages } from "../../../common/core/pagination";
|
||||||
import apiClient from "./apiClient";
|
import apiClient from "./apiClient";
|
||||||
|
import { userManager } from "./auth";
|
||||||
|
|
||||||
export type AuditLog = {
|
export type AuditLog = {
|
||||||
event_id: string;
|
event_id: string;
|
||||||
@@ -51,6 +53,9 @@ export type TenantListResponse = {
|
|||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
cursor?: string;
|
||||||
|
nextCursor?: string;
|
||||||
|
next_cursor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantUpdateRequest = {
|
export type TenantUpdateRequest = {
|
||||||
@@ -124,6 +129,7 @@ export type RPUsageDailyResponse = {
|
|||||||
|
|
||||||
export type AdminOverviewStats = {
|
export type AdminOverviewStats = {
|
||||||
totalTenants: number;
|
totalTenants: number;
|
||||||
|
totalUsers: number;
|
||||||
oidcClients: number;
|
oidcClients: number;
|
||||||
auditEvents24h: number;
|
auditEvents24h: number;
|
||||||
};
|
};
|
||||||
@@ -144,6 +150,60 @@ export type UserProjectionActionResult = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DataIntegrityStatus = "pass" | "warning" | "fail";
|
||||||
|
|
||||||
|
export type DataIntegrityCheck = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
status: DataIntegrityStatus;
|
||||||
|
severity: "info" | "warning" | "error" | string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataIntegritySection = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
status: DataIntegrityStatus;
|
||||||
|
checks: DataIntegrityCheck[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataIntegrityReport = {
|
||||||
|
status: DataIntegrityStatus;
|
||||||
|
checkedAt: string;
|
||||||
|
summary: {
|
||||||
|
totalChecks: number;
|
||||||
|
passed: number;
|
||||||
|
warnings: number;
|
||||||
|
failures: number;
|
||||||
|
};
|
||||||
|
sections: DataIntegritySection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrphanUserLoginID = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail?: string;
|
||||||
|
userDeletedAt?: string;
|
||||||
|
tenantId: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
tenantDeletedAt?: string;
|
||||||
|
fieldKey: string;
|
||||||
|
loginId: string;
|
||||||
|
reasons: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrphanUserLoginIDListResponse = {
|
||||||
|
items: OrphanUserLoginID[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteOrphanUserLoginIDsResult = {
|
||||||
|
deletedCount: number;
|
||||||
|
deleted: OrphanUserLoginID[];
|
||||||
|
skippedIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||||
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||||
params: { limit, cursor },
|
params: { limit, cursor },
|
||||||
@@ -156,6 +216,28 @@ export async function fetchAdminOverviewStats() {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDataIntegrityReport() {
|
||||||
|
const { data } = await apiClient.get<DataIntegrityReport>(
|
||||||
|
"/v1/admin/integrity",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOrphanUserLoginIDs() {
|
||||||
|
const { data } = await apiClient.get<OrphanUserLoginIDListResponse>(
|
||||||
|
"/v1/admin/integrity/orphan-user-login-ids",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOrphanUserLoginIDs(ids: string[]) {
|
||||||
|
const { data } = await apiClient.delete<DeleteOrphanUserLoginIDsResult>(
|
||||||
|
"/v1/admin/integrity/orphan-user-login-ids",
|
||||||
|
{ data: { ids } },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchUserProjectionStatus() {
|
export async function fetchUserProjectionStatus() {
|
||||||
const { data } = await apiClient.get<UserProjectionStatus>(
|
const { data } = await apiClient.get<UserProjectionStatus>(
|
||||||
"/v1/admin/projections/users",
|
"/v1/admin/projections/users",
|
||||||
@@ -195,16 +277,73 @@ export async function fetchAdminRPUsageDaily({
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
|
export async function fetchTenants(
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
parentId?: string,
|
||||||
|
cursor?: string,
|
||||||
|
) {
|
||||||
const { data } = await apiClient.get<TenantListResponse>(
|
const { data } = await apiClient.get<TenantListResponse>(
|
||||||
"/v1/admin/tenants",
|
"/v1/admin/tenants",
|
||||||
{
|
{
|
||||||
params: { limit, offset, parentId },
|
params: { limit, offset, parentId, cursor },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAdminApiBaseUrl() {
|
||||||
|
if (
|
||||||
|
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||||
|
._IS_TEST_MODE
|
||||||
|
) {
|
||||||
|
return "http://playwright-mock/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
return import.meta.env.VITE_ADMIN_API_BASE ?? "/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAdminRequestHeaders() {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const user = await userManager.getUser();
|
||||||
|
const sessionToken =
|
||||||
|
user?.access_token || window.localStorage.getItem("admin_session");
|
||||||
|
|
||||||
|
if (sessionToken) {
|
||||||
|
headers.Authorization = `Bearer ${sessionToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = window.localStorage.getItem("admin_tenant");
|
||||||
|
if (tenantId) {
|
||||||
|
headers["X-Tenant-ID"] = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMockRoleEnabled =
|
||||||
|
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||||
|
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||||
|
if (isMockRoleEnabled && mockRole) {
|
||||||
|
headers["X-Test-Role"] = mockRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllTenants({
|
||||||
|
pageSize = 100,
|
||||||
|
parentId,
|
||||||
|
}: {
|
||||||
|
pageSize?: number;
|
||||||
|
parentId?: string;
|
||||||
|
} = {}) {
|
||||||
|
return fetchAllCursorPages<TenantSummary>({
|
||||||
|
baseUrl: getAdminApiBaseUrl(),
|
||||||
|
path: "/v1/admin/tenants",
|
||||||
|
pageSize,
|
||||||
|
params: { parentId },
|
||||||
|
headers: await buildAdminRequestHeaders(),
|
||||||
|
}) as Promise<TenantListResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchTenant(tenantId: string) {
|
export async function fetchTenant(tenantId: string) {
|
||||||
const { data } = await apiClient.get<TenantSummary>(
|
const { data } = await apiClient.get<TenantSummary>(
|
||||||
`/v1/admin/tenants/${tenantId}`,
|
`/v1/admin/tenants/${tenantId}`,
|
||||||
@@ -241,9 +380,9 @@ export async function deleteTenantsBulk(ids: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportTenantsCSV(includeIds = false) {
|
export async function exportTenantsCSV(includeIds = false, parentId?: string) {
|
||||||
const response = await apiClient.get<Blob>("/v1/admin/tenants/export", {
|
const response = await apiClient.get<Blob>("/v1/admin/tenants/export", {
|
||||||
params: { includeIds },
|
params: { includeIds, parentId: parentId || undefined },
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
const dispositionHeader = response.headers["content-disposition"];
|
const dispositionHeader = response.headers["content-disposition"];
|
||||||
@@ -440,6 +579,10 @@ export type ApiKeyCreateResponse = {
|
|||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiKeyUpdateScopesRequest = {
|
||||||
|
scopes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchApiKeys(limit = 50, offset = 0) {
|
export async function fetchApiKeys(limit = 50, offset = 0) {
|
||||||
const { data } = await apiClient.get<ApiKeyListResponse>(
|
const { data } = await apiClient.get<ApiKeyListResponse>(
|
||||||
"/v1/admin/api-keys",
|
"/v1/admin/api-keys",
|
||||||
@@ -458,6 +601,24 @@ export async function createApiKey(payload: ApiKeyCreateRequest) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateApiKeyScopes(
|
||||||
|
apiKeyId: string,
|
||||||
|
payload: ApiKeyUpdateScopesRequest,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.patch<ApiKeySummary>(
|
||||||
|
`/v1/admin/api-keys/${apiKeyId}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateApiKeySecret(apiKeyId: string) {
|
||||||
|
const { data } = await apiClient.post<ApiKeyCreateResponse>(
|
||||||
|
`/v1/admin/api-keys/${apiKeyId}/secret/rotate`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteApiKey(apiKeyId: string) {
|
export async function deleteApiKey(apiKeyId: string) {
|
||||||
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
||||||
}
|
}
|
||||||
@@ -628,11 +789,14 @@ export type WorksmobileOverview = {
|
|||||||
export type WorksmobileComparisonItem = {
|
export type WorksmobileComparisonItem = {
|
||||||
resourceType: string;
|
resourceType: string;
|
||||||
baronId?: string;
|
baronId?: string;
|
||||||
|
baronSlug?: string;
|
||||||
baronName?: string;
|
baronName?: string;
|
||||||
baronEmail?: string;
|
baronEmail?: string;
|
||||||
baronPrimaryOrgId?: string;
|
baronPrimaryOrgId?: string;
|
||||||
|
baronPrimaryOrgSlug?: string;
|
||||||
baronPrimaryOrgName?: string;
|
baronPrimaryOrgName?: string;
|
||||||
baronParentId?: string;
|
baronParentId?: string;
|
||||||
|
baronParentSlug?: string;
|
||||||
baronParentName?: string;
|
baronParentName?: string;
|
||||||
worksmobileId?: string;
|
worksmobileId?: string;
|
||||||
externalKey?: string;
|
externalKey?: string;
|
||||||
@@ -648,8 +812,13 @@ export type WorksmobileComparisonItem = {
|
|||||||
worksmobilePrimaryOrgPositionId?: string;
|
worksmobilePrimaryOrgPositionId?: string;
|
||||||
worksmobilePrimaryOrgPositionName?: string;
|
worksmobilePrimaryOrgPositionName?: string;
|
||||||
worksmobilePrimaryOrgIsManager?: boolean;
|
worksmobilePrimaryOrgIsManager?: boolean;
|
||||||
|
baronParentWorksmobileId?: string;
|
||||||
|
baronParentWorksmobileName?: string;
|
||||||
|
baronParentWorksmobileEmail?: string;
|
||||||
worksmobileParentId?: string;
|
worksmobileParentId?: string;
|
||||||
worksmobileParentName?: string;
|
worksmobileParentName?: string;
|
||||||
|
worksmobileParentEmail?: string;
|
||||||
|
worksmobileParentExternalKey?: string;
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -678,17 +847,9 @@ export async function fetchUser(userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(payload: UserCreateRequest) {
|
export async function createUser(payload: UserCreateRequest) {
|
||||||
// Map tenantSlug to companyCode for backend compatibility
|
|
||||||
const requestPayload: UserCreateRequest & { companyCode?: string } = {
|
|
||||||
...payload,
|
|
||||||
};
|
|
||||||
if (payload.tenantSlug !== undefined) {
|
|
||||||
requestPayload.companyCode = payload.tenantSlug;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await apiClient.post<UserCreateResponse>(
|
const { data } = await apiClient.post<UserCreateResponse>(
|
||||||
"/v1/admin/users",
|
"/v1/admin/users",
|
||||||
requestPayload,
|
payload,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -714,16 +875,9 @@ export async function exportUsersCSV(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
||||||
const mappedUsers = users.map((u) => {
|
|
||||||
const mapped: BulkUserItem & { companyCode?: string } = { ...u };
|
|
||||||
if (u.tenantSlug !== undefined) {
|
|
||||||
mapped.companyCode = u.tenantSlug;
|
|
||||||
}
|
|
||||||
return mapped;
|
|
||||||
});
|
|
||||||
const { data } = await apiClient.post<BulkUserResponse>(
|
const { data } = await apiClient.post<BulkUserResponse>(
|
||||||
"/v1/admin/users/bulk",
|
"/v1/admin/users/bulk",
|
||||||
{ users: mappedUsers },
|
{ users },
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -778,7 +932,17 @@ export async function enqueueWorksmobileOrgUnitSync(
|
|||||||
orgUnitId: string,
|
orgUnitId: string,
|
||||||
) {
|
) {
|
||||||
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
||||||
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${orgUnitId}/sync`,
|
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/sync`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueWorksmobileOrgUnitDelete(
|
||||||
|
tenantId: string,
|
||||||
|
orgUnitId: string,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/delete`,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -810,13 +974,7 @@ export async function bulkUpdateUsers(payload: {
|
|||||||
grade?: string;
|
grade?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
}) {
|
}) {
|
||||||
const requestPayload: typeof payload & { companyCode?: string } = {
|
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
||||||
...payload,
|
|
||||||
};
|
|
||||||
if (payload.tenantSlug !== undefined) {
|
|
||||||
requestPayload.companyCode = payload.tenantSlug;
|
|
||||||
}
|
|
||||||
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,16 +986,9 @@ export async function bulkDeleteUsers(userIds: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
||||||
const requestPayload: UserUpdateRequest & { companyCode?: string } = {
|
|
||||||
...payload,
|
|
||||||
};
|
|
||||||
if (payload.tenantSlug !== undefined) {
|
|
||||||
requestPayload.companyCode = payload.tenantSlug;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await apiClient.put<UserSummary>(
|
const { data } = await apiClient.put<UserSummary>(
|
||||||
`/v1/admin/users/${userId}`,
|
`/v1/admin/users/${userId}`,
|
||||||
requestPayload,
|
payload,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user