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
|
||||
**/*.swp
|
||||
**/.DS_Store
|
||||
**/.pnpm-store
|
||||
|
||||
@@ -32,6 +32,11 @@ BACKEND_LOG_LEVEL=
|
||||
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
|
||||
|
||||
# --- NAVER WORKS API ---
|
||||
WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token
|
||||
|
||||
|
||||
# Audit System Configuration
|
||||
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
|
||||
AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Build and push adminfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./adminfront
|
||||
context: .
|
||||
file: ./adminfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
- name: Build and push devfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./devfront
|
||||
context: .
|
||||
file: ./devfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
- name: Build and push orgfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./orgfront
|
||||
context: .
|
||||
file: ./orgfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
|
||||
@@ -57,11 +57,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
adminfront/package-lock.json
|
||||
devfront/package-lock.json
|
||||
orgfront/package-lock.json
|
||||
|
||||
- name: i18n resource check
|
||||
run: |
|
||||
@@ -91,7 +86,8 @@ jobs:
|
||||
- name: Install adminfront dependencies
|
||||
run: |
|
||||
cd adminfront
|
||||
npm ci
|
||||
npm install -g pnpm
|
||||
pnpm install -C ../common --no-frozen-lockfile
|
||||
|
||||
- name: Biome check adminfront (lint + format)
|
||||
run: |
|
||||
@@ -102,7 +98,8 @@ jobs:
|
||||
- name: Install devfront dependencies
|
||||
run: |
|
||||
cd devfront
|
||||
npm ci
|
||||
npm install -g pnpm
|
||||
pnpm install -C ../common --no-frozen-lockfile
|
||||
|
||||
- name: Biome check devfront (lint + format)
|
||||
run: |
|
||||
@@ -113,7 +110,8 @@ jobs:
|
||||
- name: Install orgfront dependencies
|
||||
run: |
|
||||
cd orgfront
|
||||
npm ci
|
||||
npm install -g pnpm
|
||||
pnpm install -C ../common --no-frozen-lockfile
|
||||
|
||||
- name: Biome check orgfront (lint + format)
|
||||
run: |
|
||||
@@ -336,8 +334,7 @@ jobs:
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
cd userfront-e2e
|
||||
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
||||
node scripts/playwrightPackageVersion.cjs userfront-e2e >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v4
|
||||
@@ -561,14 +558,11 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
cache-dependency-path: adminfront/package-lock.json
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
cd adminfront
|
||||
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
||||
node scripts/playwrightPackageVersion.cjs adminfront >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v4
|
||||
@@ -656,14 +650,12 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
cache-dependency-path: devfront/package-lock.json
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
working-directory: devfront
|
||||
run: |
|
||||
cd devfront
|
||||
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
||||
node ../scripts/playwrightPackageVersion.cjs . >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v4
|
||||
@@ -675,13 +667,12 @@ jobs:
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Install devfront dependencies
|
||||
working-directory: devfront
|
||||
run: |
|
||||
mkdir -p reports
|
||||
mkdir -p ../reports
|
||||
set +e
|
||||
cd devfront
|
||||
npm ci 2>&1 | tee ../reports/devfront-install.log
|
||||
pnpm install -C ../common --no-frozen-lockfile 2>&1 | tee ../reports/devfront-install.log
|
||||
install_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$install_exit_code" -ne 0 ]; then
|
||||
@@ -694,23 +685,22 @@ jobs:
|
||||
echo "- Exit Code: \`$install_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd devfront && npm ci\`"
|
||||
echo "\`cd devfront && pnpm install -C ../common --no-frozen-lockfile\`"
|
||||
echo
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-install.log
|
||||
tail -n 200 ../reports/devfront-install.log
|
||||
echo '```'
|
||||
} > reports/devfront-test-failure-report.md
|
||||
} > ../reports/devfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Provision browsers for devfront tests
|
||||
working-directory: devfront
|
||||
run: |
|
||||
set +e
|
||||
cd devfront
|
||||
npx playwright install --with-deps 2>&1 | tee ../reports/devfront-provision.log
|
||||
pnpm exec playwright install --with-deps 2>&1 | tee ../reports/devfront-provision.log
|
||||
provision_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$provision_exit_code" -ne 0 ]; then
|
||||
@@ -723,26 +713,25 @@ jobs:
|
||||
echo "- Exit Code: \`$provision_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd devfront && npx playwright install --with-deps\`"
|
||||
echo "\`cd devfront && pnpm exec playwright install --with-deps\`"
|
||||
echo
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-provision.log
|
||||
tail -n 200 ../reports/devfront-provision.log
|
||||
echo '```'
|
||||
} > reports/devfront-test-failure-report.md
|
||||
} > ../reports/devfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run devfront tests
|
||||
working-directory: devfront
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 2
|
||||
run: |
|
||||
mkdir -p reports
|
||||
mkdir -p ../reports
|
||||
set +e
|
||||
cd devfront
|
||||
npm test 2>&1 | tee ../reports/devfront-test.log
|
||||
pnpm test 2>&1 | tee ../reports/devfront-test.log
|
||||
test_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$test_exit_code" -ne 0 ]; then
|
||||
@@ -755,15 +744,15 @@ jobs:
|
||||
echo
|
||||
echo "## Commands"
|
||||
echo "1. \`cd devfront\`"
|
||||
echo "2. \`npm ci\`"
|
||||
echo "3. \`npx playwright install --with-deps\`"
|
||||
echo "4. \`npm test\`"
|
||||
echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`"
|
||||
echo "3. \`pnpm exec playwright install --with-deps\`"
|
||||
echo "4. \`pnpm test\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-test.log
|
||||
tail -n 200 ../reports/devfront-test.log
|
||||
echo '```'
|
||||
} > reports/devfront-test-failure-report.md
|
||||
} > ../reports/devfront-test-failure-report.md
|
||||
fi
|
||||
|
||||
exit "$test_exit_code"
|
||||
@@ -839,14 +828,11 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
cache-dependency-path: orgfront/package-lock.json
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
cd orgfront
|
||||
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
||||
node scripts/playwrightPackageVersion.cjs orgfront >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v4
|
||||
@@ -862,7 +848,8 @@ jobs:
|
||||
mkdir -p reports
|
||||
set +e
|
||||
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]}
|
||||
cd ..
|
||||
set -e
|
||||
@@ -891,7 +878,7 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
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]}
|
||||
cd ..
|
||||
set -e
|
||||
@@ -906,7 +893,7 @@ jobs:
|
||||
echo "- Exit Code: \`$provision_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd orgfront && npx playwright install --with-deps\`"
|
||||
echo "\`cd orgfront && pnpm exec playwright install --with-deps\`"
|
||||
echo
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
@@ -923,7 +910,7 @@ jobs:
|
||||
mkdir -p reports
|
||||
set +e
|
||||
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]}
|
||||
cd ..
|
||||
set -e
|
||||
@@ -939,8 +926,8 @@ jobs:
|
||||
echo "## Commands"
|
||||
echo "1. \`cd orgfront\`"
|
||||
echo "2. \`npm ci\`"
|
||||
echo "3. \`npx playwright install --with-deps\`"
|
||||
echo "4. \`npm test\`"
|
||||
echo "3. \`pnpm exec playwright install --with-deps\`"
|
||||
echo "4. \`pnpm run test\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
|
||||
@@ -48,6 +48,8 @@ jobs:
|
||||
APP_ENV=stage
|
||||
BACKEND_LOG_LEVEL=debug
|
||||
CLIENT_LOG_DEBUG=true
|
||||
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
TZ=Asia/Seoul
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ jobs:
|
||||
APP_ENV=stage
|
||||
BACKEND_LOG_LEVEL=debug
|
||||
CLIENT_LOG_DEBUG=true
|
||||
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
TZ=Asia/Seoul
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# General
|
||||
.env
|
||||
.env_backup
|
||||
.temp
|
||||
.DS_Store
|
||||
.idea/
|
||||
@@ -17,6 +18,8 @@ config/.generated/
|
||||
reports
|
||||
reports/*
|
||||
config/*.pem
|
||||
common/node_modules
|
||||
common/.baron-deps-install.lock
|
||||
|
||||
# Docker Services Data (Volumes)
|
||||
postgres_data/
|
||||
@@ -50,3 +53,4 @@ orgfront/playwright-report/
|
||||
orgfront/node_modules/
|
||||
orgfront/dist/
|
||||
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
|
||||
endif
|
||||
|
||||
.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app
|
||||
.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app
|
||||
|
||||
# --- 인증 설정 빌드/검증 ---
|
||||
build-auth-config:
|
||||
@@ -56,6 +56,7 @@ up: up-all
|
||||
up-all: ensure-networks render-ory-config
|
||||
@echo "Starting ALL stacks (infra + ory + app)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos
|
||||
|
||||
# --- 개별 스택 실행 ---
|
||||
up-infra: ensure-networks
|
||||
@@ -65,6 +66,7 @@ up-infra: ensure-networks
|
||||
up-ory: ensure-networks render-ory-config
|
||||
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos
|
||||
|
||||
up-app: ensure-networks render-ory-config
|
||||
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
|
||||
@@ -114,7 +116,8 @@ ensure-ory: ensure-networks render-ory-config
|
||||
echo "Starting missing Ory stack containers in daemon mode..."; \
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \
|
||||
else \
|
||||
echo "Ory stack is already running."; \
|
||||
echo "Ory stack is already running. Restarting Kratos to apply rendered dev config..."; \
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos; \
|
||||
fi
|
||||
|
||||
up-dev: ensure-infra ensure-ory
|
||||
@@ -125,7 +128,11 @@ up-front-dev: up-infra up-ory up-backend
|
||||
|
||||
dev: up-dev
|
||||
@echo "Starting development app containers in foreground attach mode..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
|
||||
BACKEND_LOG_LEVEL=info CLIENT_LOG_DEBUG=false VITE_CLIENT_LOG_DEBUG=false docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
|
||||
|
||||
dev-debug: up-dev
|
||||
@echo "Starting development app containers in foreground attach debug mode..."
|
||||
BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true VITE_CLIENT_LOG_DEBUG=true USERFRONT_FLUTTER_RUN_FLAGS=--debug docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
|
||||
|
||||
# --- 종료 (Down) ---
|
||||
down:
|
||||
@@ -256,7 +263,7 @@ code-check-userfront-lint:
|
||||
code-check-front-lint:
|
||||
@echo "==> adminfront biome lint/format check"
|
||||
rm -rf adminfront/playwright-report adminfront/test-results
|
||||
cd adminfront && npm ci --ignore-scripts
|
||||
cd adminfront && pnpm install --frozen-lockfile --ignore-scripts
|
||||
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
@echo "==> devfront biome lint/format check"
|
||||
|
||||
24
README.md
24
README.md
@@ -155,6 +155,7 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
|
||||
|
||||
### 한맥가족 User Import Email 정책
|
||||
- 전체 시스템에서 `users.email`은 unique입니다.
|
||||
- `active`, `temporary_leave`, `suspended`, `preboarding`, `baron_guest`, `extended_leave`, `archived` 등 모든 사용자 상태가 unique 검사 대상입니다. 특히 `preboarding`, `baron_guest`, `archived` 사용자는 email/local-part 선점 대상입니다.
|
||||
- 한맥가족 테넌트 root(`hanmac-family`)와 그 하위 subtree에서는 이메일 도메인과 무관하게 `@` 앞 local-part도 unique 해야 합니다.
|
||||
- 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다.
|
||||
- `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다.
|
||||
@@ -171,6 +172,22 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
|
||||
- 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다.
|
||||
- bulk import는 preview에서 제안/수정된 최종 email을 사용하되, backend가 생성 직전에 다시 unique 규칙을 검증합니다.
|
||||
|
||||
### User Status 정책
|
||||
| 상태 | 표시명 | Baron 사용 | Works 처리 | 일반 조직도 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `active` | 재직 | 가능 | 생성/갱신 | 노출 |
|
||||
| `temporary_leave` | 단기휴무 | 가능 | 계정 유지 | 노출 |
|
||||
| `suspended` | 정지 | 불가 | suspend | 노출 |
|
||||
| `preboarding` | 입사대기 | 불가 | 생성 안 함 | 비노출 |
|
||||
| `baron_guest` | Baron 게스트 | 가능 | 생성 금지, 기존 계정 delete/deprovision | 비노출 |
|
||||
| `extended_leave` | 장기휴직 | 불가 | delete/deprovision | 비노출 |
|
||||
| `archived` | 보관 | 불가 | delete/deprovision | 비노출 |
|
||||
|
||||
- 기존 `inactive` 입력은 `preboarding`으로, `leave_of_absence` 입력은 `temporary_leave`로 호환 처리합니다.
|
||||
- 이슈 #862의 초기 명칭 `baron_only`는 구현 명칭으로 사용하지 않고 `baron_guest`로 정리합니다.
|
||||
- backend bootstrap은 남아 있는 legacy `users.status` 값을 `inactive -> preboarding`, `leave_of_absence -> temporary_leave`, `baron_only -> baron_guest`로 자동 정규화합니다.
|
||||
- `archived` 사용자는 과거 이력 보존용 계정이며 AdminFront 같은 관리자 화면에서만 감사/운영/중복 확인 목적으로 조회할 수 있습니다.
|
||||
|
||||
|
||||
### 4. 주요 시나리오 (Core Scenarios)
|
||||
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
|
||||
@@ -541,11 +558,12 @@ KETO_WRITE_URL = "http://keto:4467"
|
||||
```
|
||||
|
||||
## 🌐 i18n 구조 (간략)
|
||||
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
|
||||
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
|
||||
- **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 현재 `userfront`와 전역 i18n 검증 기준 리소스입니다.
|
||||
- **Common locales**: `common/locales/template.toml`, `common/locales/ko.toml`, `common/locales/en.toml`은 `ui.common.*`, `msg.common.*` 같은 React 공통 문구 레이어입니다.
|
||||
- **React(Admin/Dev/Org)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`, `orgfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`를 사용하며 `common locale -> app locale override` 순서로 TOML을 `?raw` 로드합니다.
|
||||
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml`을 `tools/i18n-scanner/gen-flutter-i18n.js`로 `userfront/lib/i18n_data.dart`에 사전 생성합니다.
|
||||
- **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다.
|
||||
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다.
|
||||
- **검증**: `node tools/i18n-scanner/index.js`로 `root locales`와 `common/locales`의 코드-키-로케일 동기화 상태를 함께 점검합니다.
|
||||
|
||||
## 🧪 Code Check CI
|
||||
워크플로우 파일: `.gitea/workflows/code_check.yml`
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
FROM node:lts
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /workspace
|
||||
|
||||
# 패키지 정보 복사 및 의존성 설치
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# 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 패키지 글로벌 설치
|
||||
RUN npm install -g serve
|
||||
|
||||
# 소스 코드 복사
|
||||
COPY . .
|
||||
WORKDIR /workspace/adminfront
|
||||
|
||||
# Vite 기본 포트
|
||||
EXPOSE 5173
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time"
|
||||
"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"
|
||||
"Doe","Eric","eric.doe","eric@naver.com","eric2@company.com","Eric","Contract Employee","Manager","org.1|org.2|org.3|org.4|myteam","Manager","02-1234-0000","+1","9765412345","Japanese","General affairs","New York","Facebook","Eric","lunar","19840704","20240704","AB002","20240704 14:00"
|
||||
|
@@ -1,32 +1,6 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"style": {
|
||||
"useEnumInitializers": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"noLabelWithoutControl": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"extends": ["../common/config/biome.base.json"],
|
||||
"files": {
|
||||
"ignore": [
|
||||
"dist",
|
||||
".vite",
|
||||
"node_modules",
|
||||
"tsconfig*.json",
|
||||
"test-results",
|
||||
"test-results.nobody-backup",
|
||||
"playwright-report"
|
||||
]
|
||||
"ignore": [".vite"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
|
||||
"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","",""
|
||||
"인재성장","2","","","","hr@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"전산관리TF","4","한치영(cyhan@samaneng.com)","","","it@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"기술기획","8","김원기(ba.56669@baroncs.co.kr)","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"경영기획","0","","","","t_266py@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"ERP기획","0","","","","t_136ud@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"디자인기획","0","","","","t_618gm@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"협업증진","0","","","","t_752rp@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"솔루션통합","0","","","","t_683tq@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"네이버웍스관리용","2","슈퍼관리자(su-@samaneng.com)","","","su3@baroncs.co.kr","N","N","N","Y","","",""
|
||||
"기술개발센터","0","","","","t_536fc@baroncs.co.kr","Y","N","Y","Y","","",""
|
||||
"일반구조물 div","0","","","","t_568cz@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"DfMA","0","","","","t_538ub@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"일반구조물","0","","","","t_601cu@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"구조물계획","0","","","","t_388gh@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"하부구조","0","","","","t_131xd@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"CM기획","0","","","","t_349dy@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"터널","0","","","","t_068jk@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
|
||||
"CC","0","","","","t_116me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"공정관리","0","","","","t_628of@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
|
||||
"단가산출","0","","","","t_002sq@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
|
||||
"상하수도","0","","","","t_323pd@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"천지인","0","","","","t_859sx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"천지인셀","0","","","","t_827ax@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
|
||||
"용지도셀","0","","","","t_896yy@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
|
||||
"단지설계 개발","0","","","","t_602uo@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
|
||||
"인프라솔루션 개발","0","","","","t_566mk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"비탈면/구조물","0","","","","t_726dh@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
|
||||
"Way Draw","0","","","","t_504jn@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
|
||||
"Primal 평면","0","","","","t_284vk@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
|
||||
"Watch BIM","0","","","","t_170el@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
|
||||
"구조물S/W","0","","","","t_019ge@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"Strana","0","","","","t_595rj@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"그래픽스","0","","","","t_934zk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"Modeler","0","","","","t_932vs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
|
||||
"HmEG","0","","","","t_614xb@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
|
||||
"EG-BIM Draw","0","","","","t_563cv@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
|
||||
"Abut&시공통합관제","0","","","","t_762fs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
|
||||
"웹솔루션","0","","","","t_797wn@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"솔루션개발","0","","","","t_923oe@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
|
||||
"ERP","0","","","","t_481sa@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
|
||||
"웹디자인","0","","","","t_587ef@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
|
||||
"GSIM개발","0","","","","t_929kx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"bCMf","0","","","","t_833jy@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
|
||||
"GSIM","0","","","","t_263tv@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
|
||||
"PM","0","","","","t_335nb@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
|
||||
"수자원","0","","","","t_233cs@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"스마트건설","0","","","","t_842me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
"시공BIM","0","","","","t_942jh@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
|
||||
|
@@ -1,50 +0,0 @@
|
||||
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
|
||||
"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","",""
|
||||
"인재성장","2","","","","talent-growth@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"전산관리TF","4","","","","it-admin-tf@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"기술기획","8","","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"경영기획","0","","","","management-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"ERP기획","0","","","","erp-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"디자인기획","0","","","","design-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"협업증진","0","","","","collaboration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"솔루션통합","0","","","","solution-integration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
|
||||
"네이버웍스관리용","2","","","","su2@baroncs.co.kr","N","N","N","Y","","",""
|
||||
"기술개발센터","0","","","","tdc@baroncs.co.kr","Y","N","Y","Y","","",""
|
||||
"일반구조물 div","0","","","","structural-division@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"DfMA","0","","","","dfma@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"일반구조물","0","","","","structural-design@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"구조물계획","0","","","","structure-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"하부구조","0","","","","substructure@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"CM기획","0","","","","cm-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"터널","0","","","","tunnel@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
|
||||
"CC","0","","","","cost-control@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"공정관리","0","","","","schedule-control@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
|
||||
"단가산출","0","","","","cost-estimate@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
|
||||
"상하수도","0","","","","water-sewer@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"천지인","0","","","","cheonjijin@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"천지인셀","0","","","","cheonjijin-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
|
||||
"용지도셀","0","","","","land-map-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
|
||||
"단지설계 개발","0","","","","site-design-dev@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
|
||||
"인프라솔루션 개발","0","","","","infra-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"비탈면/구조물","0","","","","slope-structures@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
|
||||
"Way Draw","0","","","","way-draw@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
|
||||
"Primal 평면","0","","","","primal-plan@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
|
||||
"Watch BIM","0","","","","watch-bim@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
|
||||
"구조물S/W","0","","","","structural-software@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"Strana","0","","","","strana@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"그래픽스","0","","","","graphics@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"Modeler","0","","","","modeler@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
|
||||
"HmEG","0","","","","hmeg@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
|
||||
"EG-BIM Draw","0","","","","eg-bim-draw@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
|
||||
"Abut&시공통합관제","0","","","","abut-control@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
|
||||
"웹솔루션","0","","","","web-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"솔루션개발","0","","","","solution-dev@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
|
||||
"ERP","0","","","","erp@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
|
||||
"웹디자인","0","","","","web-design@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
|
||||
"GSIM개발","0","","","","gsim-dev@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"bCMf","0","","","","bcmf@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
|
||||
"GSIM","0","","","","gsim@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
|
||||
"PM","0","","","","project-management@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
|
||||
"수자원","0","","","","water-resources@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"스마트건설","0","","","","smart-construction@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
"시공BIM","0","","","","construction-bim@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
|
||||
|
@@ -1,474 +0,0 @@
|
||||
|
||||
> adminfront@0.0.0 i18n-scan
|
||||
> cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js
|
||||
|
||||
|
||||
ko.toml에 없는 키
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_history
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
|
||||
- ui.admin.users.list.table.msg.common.copied
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.email
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.history_title
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.password_done
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
|
||||
- ui.admin.users.list.table.ui.common.generate
|
||||
- ui.admin.users.list.table.ui.common.status.blocked
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
|
||||
- ui.admin.users.list.table.ui.dev.clients.registry.description
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.email
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
|
||||
- ui.admin.users.list.table.ui.dev.session.refresh
|
||||
- ui.admin.users.list.table.ui.dev.session.refreshing
|
||||
|
||||
en.toml에 없는 키
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_history
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
|
||||
- ui.admin.users.list.table.msg.common.copied
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.email
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.history_title
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.password_done
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
|
||||
- ui.admin.users.list.table.ui.common.generate
|
||||
- ui.admin.users.list.table.ui.common.status.blocked
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
|
||||
- ui.admin.users.list.table.ui.dev.clients.registry.description
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.email
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
|
||||
- ui.admin.users.list.table.ui.dev.session.refresh
|
||||
- ui.admin.users.list.table.ui.dev.session.refreshing
|
||||
|
||||
template.toml에 없는 코드 사용 키
|
||||
- msg.admin.users.detail.history_desc
|
||||
- msg.admin.users.detail.no_history
|
||||
- msg.admin.users.detail.no_tenants
|
||||
- msg.admin.users.detail.reset_auto_desc
|
||||
- msg.admin.users.detail.security_desc
|
||||
- msg.admin.users.detail.tenant_slug_help
|
||||
- msg.admin.users.detail.tenants_desc
|
||||
- msg.common.copied
|
||||
- msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- msg.dev.clients.general.public_key.cache_empty
|
||||
- msg.dev.clients.general.public_key.cache_help
|
||||
- msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- msg.dev.clients.general.public_key.cache_refreshed
|
||||
- msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- msg.dev.clients.general.public_key.cache_revoked
|
||||
- msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.detail.form.email
|
||||
- ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.detail.history_title
|
||||
- ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.detail.password_done
|
||||
- ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.detail.updated_at
|
||||
- ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.dev.clients.general.public_key.cache.error
|
||||
- ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.dev.clients.general.public_key.cache.status
|
||||
- ui.dev.clients.general.public_key.cache.title
|
||||
- ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.dev.clients.general.public_key.revoke_cache
|
||||
|
||||
코드에서 사용되지 않는 키
|
||||
- err.backend.authorization_pending
|
||||
- err.backend.bad_request
|
||||
- err.backend.conflict
|
||||
- err.backend.expired_token
|
||||
- err.backend.forbidden
|
||||
- err.backend.internal_error
|
||||
- err.backend.invalid_code
|
||||
- err.backend.invalid_or_expired_code
|
||||
- err.backend.invalid_session
|
||||
- err.backend.invalid_session_reference
|
||||
- err.backend.not_found
|
||||
- err.backend.not_supported
|
||||
- err.backend.password_or_email_mismatch
|
||||
- err.backend.rate_limited
|
||||
- err.backend.service_unavailable
|
||||
- err.backend.slow_down
|
||||
- msg.admin.groups.create.description
|
||||
- msg.admin.groups.create.title
|
||||
- msg.admin.groups.list.import_error
|
||||
- msg.admin.groups.list.import_success
|
||||
- msg.admin.header.subtitle
|
||||
- msg.admin.idp_env_prod
|
||||
- msg.admin.notice.idp_policy
|
||||
- msg.admin.notice.scope
|
||||
- msg.admin.overview.idp_fallback
|
||||
- msg.admin.overview.idp_primary
|
||||
- msg.admin.overview.playbook.description
|
||||
- msg.admin.overview.playbook.idp_body
|
||||
- msg.admin.overview.playbook.idp_title
|
||||
- msg.admin.overview.playbook.tenant_body
|
||||
- msg.admin.overview.playbook.tenant_title
|
||||
- msg.admin.overview.quick_links.description
|
||||
- msg.admin.overview.summary.audit_events_24h
|
||||
- msg.admin.overview.summary.oidc_clients
|
||||
- msg.admin.overview.summary.policy_gate
|
||||
- msg.admin.overview.summary.total_tenants
|
||||
- msg.admin.scope_admin
|
||||
- msg.admin.session_ttl
|
||||
- msg.admin.tenant_headers
|
||||
- msg.admin.users.create.form.login_id_help
|
||||
- msg.admin.users.detail.delete_error
|
||||
- msg.admin.users.detail.password_generated_help
|
||||
- msg.admin.users.detail.reset_password_confirm
|
||||
- msg.admin.users.detail.security.password_hint
|
||||
- msg.admin.users.detail.update_success
|
||||
- msg.common.copied_to_clipboard
|
||||
- msg.dev.audit.forbidden
|
||||
- msg.dev.clients.general.public_key.auth_method_client_secret_basic_help
|
||||
- msg.dev.clients.general.public_key.auth_method_none_help
|
||||
- msg.dev.clients.general.public_key.auth_method_private_key_jwt_help
|
||||
- msg.dev.clients.general.public_key.guide_example
|
||||
- msg.dev.clients.general.public_key.guide_intro
|
||||
- msg.dev.clients.general.public_key.guide_step_1
|
||||
- msg.dev.clients.general.public_key.guide_step_2
|
||||
- msg.dev.clients.general.public_key.guide_step_3
|
||||
- msg.dev.clients.general.public_key.jwks_inline_help
|
||||
- msg.dev.clients.general.public_key.request_object_alg_help
|
||||
- msg.dev.clients.general.public_key.source_help
|
||||
- msg.dev.clients.general.public_key.validation.headless_requires_alg
|
||||
- msg.dev.clients.general.public_key.validation.headless_requires_private_key_jwt
|
||||
- msg.dev.clients.general.public_key.validation.headless_requires_public_key
|
||||
- msg.dev.clients.general.public_key.validation.invalid_jwks_inline
|
||||
- msg.dev.clients.general.public_key.validation.missing_jwks_inline
|
||||
- msg.dev.clients.general.public_key.validation.private_key_jwt_requires_public_key
|
||||
- msg.userfront.signup.privacy_full
|
||||
- msg.userfront.signup.tos_full
|
||||
- non.existent.key
|
||||
- test.key
|
||||
- ui.admin.api_keys.list.breadcrumb.list
|
||||
- ui.admin.api_keys.list.breadcrumb.section
|
||||
- ui.admin.audit.breadcrumb.logs
|
||||
- ui.admin.audit.breadcrumb.section
|
||||
- ui.admin.groups.import_csv
|
||||
- ui.admin.overview.kicker
|
||||
- ui.admin.overview.playbook.title
|
||||
- ui.admin.overview.quick_links.add_tenant
|
||||
- ui.admin.overview.quick_links.api_key_management
|
||||
- ui.admin.overview.quick_links.user_management
|
||||
- ui.admin.overview.quick_links.view_audit_logs
|
||||
- ui.admin.tenants.breadcrumb.list
|
||||
- ui.admin.tenants.breadcrumb.section
|
||||
- ui.admin.tenants.create.breadcrumb.action
|
||||
- ui.admin.tenants.create.breadcrumb.section
|
||||
- ui.admin.tenants.detail.breadcrumb_list
|
||||
- ui.admin.tenants.detail.title
|
||||
- ui.admin.users.create.breadcrumb.new
|
||||
- ui.admin.users.create.breadcrumb.section
|
||||
- ui.admin.users.create.form.login_id
|
||||
- ui.admin.users.create.form.login_id_placeholder
|
||||
- ui.admin.users.detail.breadcrumb.section
|
||||
- ui.admin.users.detail.contact_title
|
||||
- ui.admin.users.detail.form.department_placeholder
|
||||
- ui.admin.users.detail.form.job_title_placeholder
|
||||
- ui.admin.users.detail.form.login_id
|
||||
- ui.admin.users.detail.form.login_id_placeholder
|
||||
- ui.admin.users.detail.form.name_placeholder
|
||||
- ui.admin.users.detail.form.phone_placeholder
|
||||
- ui.admin.users.detail.form.position_placeholder
|
||||
- ui.admin.users.detail.form.status_active
|
||||
- ui.admin.users.detail.form.status_inactive
|
||||
- ui.admin.users.detail.generate_password
|
||||
- ui.admin.users.detail.password_mode_generated
|
||||
- ui.admin.users.detail.password_mode_manual
|
||||
- ui.admin.users.detail.password_result_title
|
||||
- ui.admin.users.detail.reset_password_apply
|
||||
- ui.admin.users.detail.security.password
|
||||
- ui.admin.users.detail.security.password_placeholder
|
||||
- ui.admin.users.detail.security.title
|
||||
- ui.admin.users.detail.status_title
|
||||
- ui.admin.users.detail.tenants_section.additional
|
||||
- ui.admin.users.detail.tenants_section.primary
|
||||
- ui.admin.users.detail.tenants_section.title
|
||||
- ui.admin.users.detail.title
|
||||
- ui.admin.users.detail.toggle_password_visibility
|
||||
- ui.admin.users.list.breadcrumb.list
|
||||
- ui.admin.users.list.breadcrumb.section
|
||||
- ui.admin.users.list.empty
|
||||
- ui.admin.users.list.fetch_error
|
||||
- ui.admin.users.list.registry.count
|
||||
- ui.admin.users.list.subtitle
|
||||
- ui.admin.users.list.table.login_id
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_history
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
|
||||
- ui.admin.users.list.table.msg.common.copied
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.email
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.history_title
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.password_done
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
|
||||
- ui.admin.users.list.table.ui.common.generate
|
||||
- ui.admin.users.list.table.ui.common.status.blocked
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
|
||||
- ui.admin.users.list.table.ui.dev.clients.registry.description
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.email
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
|
||||
- ui.admin.users.list.table.ui.dev.session.refresh
|
||||
- ui.admin.users.list.table.ui.dev.session.refreshing
|
||||
- ui.common.generate
|
||||
- ui.common.status.blocked
|
||||
- ui.dev.clients.general.public_key.auth_method
|
||||
- ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.dev.clients.general.public_key.source
|
||||
- ui.dev.clients.general.public_key.source_uri
|
||||
- ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.dev.clients.help.docs_body
|
||||
- ui.dev.clients.help.subtitle
|
||||
- ui.dev.clients.registry.description
|
||||
- ui.dev.clients.scopes.email
|
||||
- ui.dev.clients.scopes.openid
|
||||
- ui.dev.clients.scopes.profile
|
||||
- ui.dev.session.refresh
|
||||
- ui.dev.session.refreshing
|
||||
|
||||
요약
|
||||
- [Sync Error] ko.toml 누락 키 84개
|
||||
- [Sync Error] en.toml 누락 키 84개
|
||||
- [Missing Key] template.toml 누락 키 59개
|
||||
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",
|
||||
"format": "biome format . --write",
|
||||
"preview": "vite preview",
|
||||
"test": "node ./node_modules/playwright/cli.js test",
|
||||
"test": "playwright test",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.1.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.66.8",
|
||||
"@tanstack/react-query-devtools": "^5.66.8",
|
||||
"axios": "^1.7.9",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"axios": "^1.16.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.24.1"
|
||||
"lucide-react": "^1.14.0",
|
||||
"oidc-client-ts": "^3.5.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-oidc-context": "^3.3.1",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^4.0.18"
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const port = Number.parseInt(process.env.PORT ?? "5173", 10);
|
||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||
const reuseExistingServer = !process.env.CI && !process.env.PORT;
|
||||
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -56,7 +57,12 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
launchOptions: chromiumExecutablePath
|
||||
? { executablePath: chromiumExecutablePath }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
lock_mode=""
|
||||
lock_file="$WORKSPACE_DIR/.baron-deps-install.lock"
|
||||
|
||||
acquire_install_lock() {
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
lock_mode="flock"
|
||||
exec 9>"$lock_file"
|
||||
flock 9
|
||||
trap 'release_install_lock' EXIT INT TERM
|
||||
return 0
|
||||
fi
|
||||
|
||||
lock_mode="mkdir"
|
||||
while ! mkdir "$lock_file" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
trap 'release_install_lock' EXIT INT TERM
|
||||
}
|
||||
|
||||
release_install_lock() {
|
||||
trap - EXIT INT TERM
|
||||
|
||||
if [ "$lock_mode" = "flock" ]; then
|
||||
flock -u 9 || true
|
||||
exec 9>&-
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$lock_mode" = "mkdir" ]; then
|
||||
rmdir "$lock_file" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum package.json package-lock.json | sha256sum | awk '{print $1}')"
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
deps_hash="$(cksum package.json package-lock.json | cksum | awk '{print $1}')"
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | cksum | awk '{print $1}')"
|
||||
fi
|
||||
deps_stamp="node_modules/.baron-deps-hash"
|
||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||
|
||||
if [ "$installed_hash" != "$deps_hash" ]; then
|
||||
echo "Installing frontend dependencies from package-lock.json..."
|
||||
npm ci
|
||||
echo "Installing frontend dependencies..."
|
||||
acquire_install_lock
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 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
|
||||
printf '%s\n' "$deps_hash" > "$deps_stamp"
|
||||
release_install_lock
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_frontend_dependencies
|
||||
|
||||
if [ "$mode" = "production" ]; then
|
||||
echo "Running in production mode with Vite preview..."
|
||||
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
|
||||
echo "Running in production mode with custom static server..."
|
||||
exec sh -c "npm run build && node ./scripts/serve-prod.mjs"
|
||||
fi
|
||||
|
||||
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 { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
defaultOptions: queryClientDefaultOptions,
|
||||
});
|
||||
|
||||
@@ -21,4 +21,32 @@ describe("admin routes", () => {
|
||||
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
|
||||
});
|
||||
|
||||
it("registers the super-admin data integrity management route", () => {
|
||||
const matches = matchRoutes(adminRoutes, "/system/data-integrity");
|
||||
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
|
||||
});
|
||||
|
||||
it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
|
||||
const rootRoute = adminRoutes.find((route) => route.path === "/");
|
||||
const protectedShellRoute = rootRoute?.children?.[0];
|
||||
|
||||
expect(getRouteElementName(rootRoute?.element)).toBe("AuthGuard");
|
||||
expect(getRouteElementName(protectedShellRoute?.element)).toBe("AppLayout");
|
||||
expect(protectedShellRoute?.children?.at(0)?.index).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function getRouteElementName(element: unknown) {
|
||||
if (
|
||||
typeof element === "object" &&
|
||||
element !== null &&
|
||||
"type" in element &&
|
||||
typeof element.type === "function"
|
||||
) {
|
||||
return element.type.name;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
|
||||
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
import AuthGuard from "../features/auth/AuthGuard";
|
||||
import AuthPage from "../features/auth/AuthPage";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
|
||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import UserProjectionPage from "../features/projections/UserProjectionPage";
|
||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||
@@ -34,34 +36,40 @@ export const adminRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AppLayout />,
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ index: true, element: <GlobalOverviewPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "auth", element: <AuthPage /> },
|
||||
{ path: "users", element: <UserListPage /> },
|
||||
{ path: "users/new", element: <UserCreatePage /> },
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <TenantProfilePage /> },
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||
{ index: true, element: <GlobalOverviewPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "auth", element: <AuthPage /> },
|
||||
{ path: "users", element: <UserListPage /> },
|
||||
{ path: "users/new", element: <UserCreatePage /> },
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
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 type * as React from "react";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { normalizeAdminRole } from "../../lib/roles";
|
||||
|
||||
interface RoleGuardProps {
|
||||
children: React.ReactNode;
|
||||
@@ -29,8 +30,10 @@ export function RoleGuard({
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
const userRole = profile?.role || "user";
|
||||
const hasAccess = roles.includes(userRole);
|
||||
const userRole = normalizeAdminRole(profile?.role);
|
||||
const hasAccess = roles
|
||||
.map((role) => normalizeAdminRole(role))
|
||||
.includes(userRole);
|
||||
|
||||
if (!hasAccess) {
|
||||
return <>{fallback}</>;
|
||||
|
||||
32
adminfront/src/components/common/LanguageSelector.test.tsx
Normal file
32
adminfront/src/components/common/LanguageSelector.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { 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";
|
||||
|
||||
const LOCALE_STORAGE_KEY = "locale";
|
||||
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||
|
||||
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
@@ -28,13 +27,27 @@ function resolveLocale(): Locale {
|
||||
function LanguageSelector() {
|
||||
const [locale, setLocale] = useState<Locale>(resolveLocale());
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = () => {
|
||||
setLocale(resolveLocale());
|
||||
};
|
||||
|
||||
window.addEventListener("localechange", syncLocale);
|
||||
window.addEventListener("storage", syncLocale);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("localechange", syncLocale);
|
||||
window.removeEventListener("storage", syncLocale);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (next: Locale) => {
|
||||
if (next === locale) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||
setLocale(next);
|
||||
window.location.reload();
|
||||
window.dispatchEvent(new Event("localechange"));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import LocaleRefreshBoundary from "./LocaleRefreshBoundary";
|
||||
|
||||
let renderCount = 0;
|
||||
|
||||
function RenderCounter() {
|
||||
renderCount += 1;
|
||||
return <span>{renderCount}</span>;
|
||||
}
|
||||
|
||||
describe("LocaleRefreshBoundary", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
renderCount = 0;
|
||||
});
|
||||
|
||||
it("re-renders children when locale changes", async () => {
|
||||
render(
|
||||
<LocaleRefreshBoundary>
|
||||
<RenderCounter />
|
||||
</LocaleRefreshBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
window.dispatchEvent(new Event("localechange"));
|
||||
});
|
||||
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
27
adminfront/src/components/common/LocaleRefreshBoundary.tsx
Normal file
27
adminfront/src/components/common/LocaleRefreshBoundary.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Fragment, type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
type LocaleRefreshBoundaryProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) {
|
||||
const [localeVersion, setLocaleVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = () => {
|
||||
setLocaleVersion((current) => current + 1);
|
||||
};
|
||||
|
||||
window.addEventListener("localechange", syncLocale);
|
||||
window.addEventListener("storage", syncLocale);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("localechange", syncLocale);
|
||||
window.removeEventListener("storage", syncLocale);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Fragment key={localeVersion}>{children}</Fragment>;
|
||||
}
|
||||
|
||||
export default LocaleRefreshBoundary;
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Moon,
|
||||
Network,
|
||||
NotebookTabs,
|
||||
ShieldCheck,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
User as UserIcon,
|
||||
@@ -19,9 +20,23 @@ import * as React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AppSidebar,
|
||||
type ShellSidebarNavItem,
|
||||
type ShellTranslator,
|
||||
applyShellTheme,
|
||||
buildShellProfileSummary,
|
||||
buildShellSessionStatus,
|
||||
readShellSessionExpiryEnabled,
|
||||
readShellTheme,
|
||||
shellLayoutClasses,
|
||||
writeShellSessionExpiryEnabled,
|
||||
} from "../../../../common/shell";
|
||||
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { debugLog } from "../../lib/debugLog";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
@@ -29,20 +44,84 @@ import {
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import RoleSwitcher from "./RoleSwitcher";
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
to: string;
|
||||
icon: React.ComponentType<{ size?: number | string }>;
|
||||
isExternal?: boolean;
|
||||
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
|
||||
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
|
||||
|
||||
const staticNavItems: ShellSidebarNavItem[] = [
|
||||
{
|
||||
labelKey: "ui.admin.nav.overview",
|
||||
labelFallback: "Overview",
|
||||
to: "/",
|
||||
icon: LayoutDashboard,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.users",
|
||||
labelFallback: "Users",
|
||||
to: "/users",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.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[] = [
|
||||
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
||||
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
||||
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
||||
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
||||
];
|
||||
function SessionStatusBadge(props: SessionStatusProps) {
|
||||
const sessionStatus = useSessionStatus(props);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
shellLayoutClasses.sessionBadge,
|
||||
sessionStatus.toneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionStatus.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionStatusText(props: SessionStatusProps) {
|
||||
const sessionStatus = useSessionStatus(props);
|
||||
|
||||
return <>{sessionStatus.text}</>;
|
||||
}
|
||||
|
||||
function AppLayout() {
|
||||
const auth = useAuth();
|
||||
@@ -52,6 +131,7 @@ function AppLayout() {
|
||||
const isRenewInFlightRef = useRef(false);
|
||||
const lastRenewAttemptAtRef = useRef(0);
|
||||
const lastVisitedRouteRef = useRef<string | null>(null);
|
||||
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||
const isDevRoleOverrideEnabled =
|
||||
import.meta.env.MODE === "development" ||
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
@@ -62,26 +142,12 @@ function AppLayout() {
|
||||
const mockRoleOverride = isMockRoleEnabled
|
||||
? window.localStorage.getItem("X-Mock-Role")
|
||||
: null;
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
|
||||
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
|
||||
return stored !== "false";
|
||||
});
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [, setDevelopmentRenderRevision] = useState(0);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||
);
|
||||
const {
|
||||
data: profile,
|
||||
isLoading: isProfileLoading,
|
||||
@@ -89,10 +155,10 @@ function AppLayout() {
|
||||
} = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: async () => {
|
||||
console.debug("[AppLayout] Fetching profile...");
|
||||
debugLog("[AppLayout] Fetching profile...");
|
||||
try {
|
||||
const data = await fetchMe();
|
||||
console.debug("[AppLayout] Profile fetched successfully:", data.email);
|
||||
debugLog("[AppLayout] Profile fetched successfully:", data.email);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("[AppLayout] Failed to fetch profile:", err);
|
||||
@@ -106,18 +172,19 @@ function AppLayout() {
|
||||
._IS_TEST_MODE === true,
|
||||
});
|
||||
|
||||
const navItems = React.useMemo(() => {
|
||||
const navItems = React.useMemo<ShellSidebarNavItem[]>(() => {
|
||||
const items = [...staticNavItems];
|
||||
const isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
const effectiveRole = mockRoleOverride || profile?.role;
|
||||
|
||||
const isSuperAdmin = isTest || effectiveRole === "super_admin";
|
||||
const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
|
||||
const isTenantAdmin = effectiveRole === "tenant_admin";
|
||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||
{ includeInternal: true },
|
||||
);
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
@@ -128,31 +195,42 @@ function AppLayout() {
|
||||
|
||||
if (isSuperAdmin) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.tenants",
|
||||
labelKey: "ui.admin.nav.tenants",
|
||||
labelFallback: "Tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
filteredItems.splice(2, 0, {
|
||||
label: "ui.admin.nav.org_chart",
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
filteredItems.splice(4, 0, {
|
||||
label: "ui.admin.nav.user_projection",
|
||||
labelKey: "ui.admin.nav.user_projection",
|
||||
labelFallback: "User Projection",
|
||||
to: "/system/projections/users",
|
||||
icon: Database,
|
||||
});
|
||||
filteredItems.splice(5, 0, {
|
||||
labelKey: "ui.admin.nav.data_integrity",
|
||||
labelFallback: "Data Integrity",
|
||||
to: "/system/data-integrity",
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
} else if (isTenantAdmin || manageableCount > 0) {
|
||||
if (manageableCount <= 1 && profile?.tenantId) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.my_tenant",
|
||||
labelKey: "ui.admin.nav.my_tenant",
|
||||
labelFallback: "My Tenant",
|
||||
to: `/tenants/${profile.tenantId}`,
|
||||
icon: Building2,
|
||||
});
|
||||
} else if (manageableCount > 1) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.tenants",
|
||||
labelKey: "ui.admin.nav.tenants",
|
||||
labelFallback: "Tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
@@ -161,7 +239,8 @@ function AppLayout() {
|
||||
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
|
||||
0,
|
||||
{
|
||||
label: "ui.admin.nav.org_chart",
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
@@ -170,7 +249,8 @@ function AppLayout() {
|
||||
} else {
|
||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.org_chart",
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
@@ -195,7 +275,7 @@ function AppLayout() {
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
|
||||
console.debug("[AppLayout] Auth state check:", {
|
||||
debugLog("[AppLayout] Auth state check:", {
|
||||
isLoading: auth.isLoading,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isTest,
|
||||
@@ -214,16 +294,30 @@ function AppLayout() {
|
||||
}, [auth.user]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
if (theme === "light") {
|
||||
root.classList.add("light");
|
||||
} else {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
window.localStorage.setItem("admin_theme", theme);
|
||||
applyShellTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDevelopmentRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rerenderDevelopmentShell = () => {
|
||||
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(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
@@ -289,6 +383,10 @@ function AppLayout() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDevelopmentRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeKeepSessionAlive = async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
@@ -331,6 +429,7 @@ function AppLayout() {
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isDevelopmentRuntime,
|
||||
isSessionExpiryEnabled,
|
||||
]);
|
||||
|
||||
@@ -388,71 +487,83 @@ function AppLayout() {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
const profileName =
|
||||
profile?.name?.trim() ||
|
||||
auth.user?.profile.name?.toString().trim() ||
|
||||
auth.user?.profile.preferred_username?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_name", "Unknown User");
|
||||
const profileEmail =
|
||||
profile?.email?.trim() ||
|
||||
auth.user?.profile.email?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_email", "unknown@example.com");
|
||||
const profileInitial = profileName.charAt(0).toUpperCase();
|
||||
const profileSummary = buildShellProfileSummary({
|
||||
profileName:
|
||||
profile?.name ||
|
||||
auth.user?.profile.name?.toString() ||
|
||||
auth.user?.profile.preferred_username?.toString(),
|
||||
profileEmail: profile?.email || auth.user?.profile.email?.toString(),
|
||||
fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
|
||||
fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
|
||||
});
|
||||
const profileRoleKey = 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 = () => {
|
||||
setIsSessionExpiryEnabled((prev) => {
|
||||
const next = !prev;
|
||||
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
|
||||
writeShellSessionExpiryEnabled(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const sidebarNavContent = (
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map((item) => {
|
||||
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={to}
|
||||
href={to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={[
|
||||
shellLayoutClasses.navItemBase,
|
||||
shellLayoutClasses.navItemIdle,
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={item.end ?? to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
item.isActive !== undefined
|
||||
? item.isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle
|
||||
: isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const sidebarFooterContent = (
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
@@ -463,87 +574,19 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t("ui.admin.brand", "Baron 로그인")}
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("ui.admin.title", "Admin Control")}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
{navItems.map((item: NavItem) => {
|
||||
const { label, to, icon: Icon, isExternal } = item;
|
||||
const isOrgChart = location.pathname === "/tenants/org-chart";
|
||||
const isTenantsRoot = to === "/tenants";
|
||||
const isCustomActive = isTenantsRoot
|
||||
? location.pathname.startsWith("/tenants") && !isOrgChart
|
||||
: to === "/"
|
||||
? location.pathname === "/"
|
||||
: location.pathname.startsWith(to);
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<AppSidebar
|
||||
brandLabel={t("ui.admin.brand", "Baron 로그인")}
|
||||
brandTitle={t("ui.admin.title", "Admin Control")}
|
||||
brandIcon={<ShieldHalf size={20} />}
|
||||
navContent={sidebarNavContent}
|
||||
footerContent={sidebarFooterContent}
|
||||
/>
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={to}
|
||||
href={to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(label, label)}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={() =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
isCustomActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(label, label)}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="relative min-w-0">
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={shellLayoutClasses.contentWide}>
|
||||
<header className={shellLayoutClasses.headerElevated}>
|
||||
<div className={shellLayoutClasses.headerInner}>
|
||||
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{t("ui.admin.header.plane", "ADMIN PLANE")}
|
||||
</p>
|
||||
@@ -552,12 +595,12 @@ function AppLayout() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={shellLayoutClasses.headerActions}>
|
||||
<LanguageSelector />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||
className={shellLayoutClasses.actionButton}
|
||||
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||
>
|
||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
@@ -566,14 +609,10 @@ function AppLayout() {
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
{isSessionExpiryEnabled ? (
|
||||
<span
|
||||
className={[
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
sessionToneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionText}
|
||||
</span>
|
||||
<SessionStatusBadge
|
||||
expiresAtSec={auth.user?.expires_at}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
<button
|
||||
@@ -582,17 +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"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isProfileOpen}
|
||||
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
|
||||
aria-label={t("ui.shell.profile.menu_aria", "계정 메뉴 열기")}
|
||||
>
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||
{profileInitial}
|
||||
<div className={shellLayoutClasses.profileInitial}>
|
||||
{profileSummary.initial}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left md:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
@@ -602,45 +641,44 @@ function AppLayout() {
|
||||
</button>
|
||||
|
||||
{isProfileOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
||||
>
|
||||
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.dev.profile.menu_title", "Account")}
|
||||
{t("ui.shell.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.profileCard}>
|
||||
<div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-1">
|
||||
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
|
||||
{t(
|
||||
`ui.admin.role.${profileRoleKey}`,
|
||||
`ui.shell.role.${profileRoleKey}`,
|
||||
profileRoleKey.toUpperCase(),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.settingsCard}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
||||
{t("ui.shell.session.auto_extend", "세션 만료 관리")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled
|
||||
? sessionText
|
||||
: t(
|
||||
"ui.dev.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
)}
|
||||
{isSessionExpiryEnabled ? (
|
||||
<SessionStatusText
|
||||
expiresAtSec={auth.user?.expires_at}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
t("ui.shell.session.disabled", "세션 만료 비활성화")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -717,7 +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"
|
||||
>
|
||||
<UserIcon size={16} className="text-muted-foreground" />
|
||||
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
|
||||
<span>{t("ui.shell.nav.profile", "내 정보")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -728,7 +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"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -736,7 +774,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10">
|
||||
<main className={shellLayoutClasses.mainMinWidth}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<RoleSwitcher />
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
|
||||
|
||||
const RoleSwitcher: FC = () => {
|
||||
const [currentRole, setCurrentRole] = useState<string>("");
|
||||
const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false);
|
||||
@@ -31,13 +33,13 @@ const RoleSwitcher: FC = () => {
|
||||
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
|
||||
setCurrentRole(role);
|
||||
setIsOverrideEnabled(true);
|
||||
window.location.reload();
|
||||
window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
|
||||
};
|
||||
|
||||
const clearRoleOverride = () => {
|
||||
window.localStorage.removeItem("X-Mock-Role-Enabled");
|
||||
setIsOverrideEnabled(false);
|
||||
window.location.reload();
|
||||
window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
|
||||
};
|
||||
|
||||
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 CommonBadgeVariant,
|
||||
getCommonBadgeClasses,
|
||||
} from "../../../../common/ui/badge";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "text-foreground",
|
||||
muted: "border-border bg-secondary/60 text-muted-foreground",
|
||||
success:
|
||||
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
warning:
|
||||
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CommonBadgeVariant;
|
||||
}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
<div
|
||||
className={cn(getCommonBadgeClasses({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export { Badge };
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type CommonButtonSize,
|
||||
type CommonButtonVariant,
|
||||
getCommonButtonClasses,
|
||||
} from "../../../../common/ui/button";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-6 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: CommonButtonVariant;
|
||||
size?: CommonButtonSize;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button };
|
||||
|
||||
@@ -1,65 +1,51 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
commonCardClass,
|
||||
commonCardContentClass,
|
||||
commonCardDescriptionClass,
|
||||
commonCardFooterClass,
|
||||
commonCardHeaderClass,
|
||||
commonCardTitleClass,
|
||||
} from "../../../../common/ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
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 * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
type DialogContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogContext = React.createContext<DialogContextValue | null>(null);
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
function useDialogContext(componentName: string) {
|
||||
const context = React.useContext(DialogContext);
|
||||
if (!context) {
|
||||
throw new Error(`${componentName} must be used within Dialog`);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
function composeEventHandlers<E extends React.SyntheticEvent>(
|
||||
theirs: ((event: E) => void) | undefined,
|
||||
ours: (event: E) => void,
|
||||
) {
|
||||
return (event: E) => {
|
||||
theirs?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
ours(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type DialogProps = {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function Dialog({
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: DialogProps) {
|
||||
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
||||
const isControlled = open !== undefined;
|
||||
const currentOpen = isControlled ? open : internalOpen;
|
||||
const setOpen = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[isControlled, onOpenChange],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ open: currentOpen, setOpen }),
|
||||
[currentOpen, setOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={value}>{children}</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
({ asChild = false, children, onClick, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogTrigger");
|
||||
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const child = children as React.ReactElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}>;
|
||||
return React.cloneElement(child, {
|
||||
...props,
|
||||
onClick: composeEventHandlers(
|
||||
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
|
||||
() => setOpen(true),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" ref={ref} onClick={handleOpen} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogTrigger.displayName = "DialogTrigger";
|
||||
|
||||
const DialogPortal = ({ children }: { children?: React.ReactNode }) => {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return createPortal(children, document.body);
|
||||
};
|
||||
DialogPortal.displayName = "DialogPortal";
|
||||
|
||||
const DialogClose = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
({ asChild = false, children, onClick, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogClose");
|
||||
const handleClose = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const child = children as React.ReactElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}>;
|
||||
return React.cloneElement(child, {
|
||||
...props,
|
||||
onClick: composeEventHandlers(
|
||||
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
|
||||
() => setOpen(false),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" ref={ref} onClick={handleClose} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogClose.displayName = "DialogClose";
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, onMouseDown, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogOverlay");
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed inset-0 z-50 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,
|
||||
)}
|
||||
data-state="open"
|
||||
onMouseDown={composeEventHandlers(onMouseDown, (event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setOpen(false);
|
||||
}
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
/>
|
||||
);
|
||||
});
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
HTMLDialogElement,
|
||||
React.HTMLAttributes<HTMLDialogElement>
|
||||
>(({ className, children, onKeyDown, ...props }, ref) => {
|
||||
const { open, setOpen } = useDialogContext("DialogContent");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const onDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onDocumentKeyDown);
|
||||
return () => document.removeEventListener("keydown", onDocumentKeyDown);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<dialog
|
||||
ref={ref}
|
||||
open
|
||||
aria-modal="true"
|
||||
data-state="open"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 m-0 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 backdrop:bg-transparent data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
onKeyDown={onKeyDown}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</dialog>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
@@ -80,10 +245,10 @@ const DialogFooter = ({
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
@@ -92,19 +257,19 @@ const DialogTitle = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
DialogTitle.displayName = "DialogTitle";
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
DialogDescription.displayName = "DialogDescription";
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { commonInputClass } from "../../../../common/ui/input";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
@@ -9,10 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
className={cn(commonInputClass, className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,26 +1,68 @@
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||
interface SwitchProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
onClick,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isControlled = checked !== undefined;
|
||||
const [internalChecked, setInternalChecked] =
|
||||
React.useState(defaultChecked);
|
||||
const currentChecked = isControlled ? checked : internalChecked;
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (event.defaultPrevented || disabled) {
|
||||
return;
|
||||
}
|
||||
const nextChecked = !currentChecked;
|
||||
if (!isControlled) {
|
||||
setInternalChecked(nextChecked);
|
||||
}
|
||||
onCheckedChange?.(nextChecked);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={currentChecked}
|
||||
data-state={currentChecked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-state={currentChecked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Switch.displayName = "Switch";
|
||||
|
||||
export { Switch };
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
commonTableBodyClass,
|
||||
commonTableCaptionClass,
|
||||
commonTableCellClass,
|
||||
commonTableClass,
|
||||
commonTableFooterClass,
|
||||
commonTableHeadClass,
|
||||
commonTableHeaderClass,
|
||||
commonTableRowClass,
|
||||
commonTableWrapperClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div className={commonTableWrapperClass}>
|
||||
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
@@ -19,7 +26,11 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(commonTableHeaderClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
@@ -27,11 +38,7 @@ const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn("bg-muted/50 font-medium text-foreground", className)}
|
||||
className={cn(commonTableFooterClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -51,14 +58,7 @@ const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
@@ -66,14 +66,7 @@ const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-foreground align-middle sticky top-0 bg-inherit",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
@@ -81,11 +74,7 @@ const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-6 align-middle text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
className={cn(commonTableCaptionClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
72
adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx
Normal file
72
adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiKey } from "../../lib/adminApi";
|
||||
import ApiKeyCreatePage from "./ApiKeyCreatePage";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
createApiKey: vi.fn(async () => ({
|
||||
apiKey: {
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id",
|
||||
scopes: ["audit:read", "user:read", "org-context:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
},
|
||||
clientSecret: "secret",
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<ApiKeyCreatePage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiKeyCreatePage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders org-context:read as a selectable API key scope", () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText("조직 Context 조회")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: org-context:read")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes org-context:read in the create request when selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText("서비스 또는 목적 식별 이름"),
|
||||
"org-context-client",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
|
||||
await user.click(screen.getByRole("button", { name: /API 키 발급하기/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createApiKey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "org-context-client",
|
||||
scopes: expect.arrayContaining(["org-context:read"]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,51 +28,7 @@ import {
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const AVAILABLE_SCOPES = [
|
||||
{
|
||||
id: "audit:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
|
||||
labelFallback: "감사 로그 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
|
||||
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "audit:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
|
||||
labelFallback: "감사 로그 생성",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
|
||||
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
|
||||
},
|
||||
{
|
||||
id: "user:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_read.title",
|
||||
labelFallback: "사용자 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.user_read.desc",
|
||||
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "user:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_write.title",
|
||||
labelFallback: "사용자 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.user_write.desc",
|
||||
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
|
||||
labelFallback: "테넌트 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
|
||||
descFallback: "등록된 모든 조직 정보를 조회합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
|
||||
labelFallback: "테넌트 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
|
||||
descFallback: "테넌트 정보를 직접 제어합니다.",
|
||||
},
|
||||
];
|
||||
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||
|
||||
function ApiKeyCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -298,7 +254,7 @@ function ApiKeyCreatePage() {
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{AVAILABLE_SCOPES.map((scope) => {
|
||||
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
|
||||
const isSelected = selectedScopes.includes(scope.id);
|
||||
return (
|
||||
<button
|
||||
|
||||
125
adminfront/src/features/api-keys/ApiKeyListPage.test.tsx
Normal file
125
adminfront/src/features/api-keys/ApiKeyListPage.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchApiKeys,
|
||||
rotateApiKeySecret,
|
||||
updateApiKeyScopes,
|
||||
} from "../../lib/adminApi";
|
||||
import ApiKeyListPage from "./ApiKeyListPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? "",
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchApiKeys: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id-stable",
|
||||
scopes: ["audit:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
deleteApiKey: vi.fn(async () => undefined),
|
||||
updateApiKeyScopes: vi.fn(async () => ({
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id-stable",
|
||||
scopes: ["audit:read", "org-context:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
})),
|
||||
rotateApiKeySecret: vi.fn(async () => ({
|
||||
apiKey: {
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id-stable",
|
||||
scopes: ["audit:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
},
|
||||
clientSecret: "rotated-secret",
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<ApiKeyListPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiKeyListPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("updates scopes without changing client_id", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /권한 수정/ }));
|
||||
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
|
||||
await user.click(screen.getByRole("button", { name: /권한 저장/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateApiKeyScopes).toHaveBeenCalledWith("api-key-id", {
|
||||
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rotates only the secret and shows the one-time secret", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Secret 재발급/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rotateApiKeySecret).toHaveBeenCalledWith("api-key-id");
|
||||
});
|
||||
expect(
|
||||
await screen.findByDisplayValue("rotated-secret"),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchApiKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh button refetches the list without navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("client-id-stable");
|
||||
|
||||
const refreshButton = screen.getByRole("button", { name: /새로고침/ });
|
||||
expect(refreshButton).toHaveAttribute("type", "button");
|
||||
|
||||
await user.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchApiKeys).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,19 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Key, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Copy,
|
||||
Edit3,
|
||||
Key,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { commonStickyTableHeaderClass } from "../../../../common/ui/table";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
@@ -11,6 +23,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,10 +40,27 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
|
||||
import {
|
||||
type ApiKeySummary,
|
||||
deleteApiKey,
|
||||
fetchApiKeys,
|
||||
rotateApiKeySecret,
|
||||
updateApiKeyScopes,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||
|
||||
function ApiKeyListPage() {
|
||||
const [editingKey, setEditingKey] = React.useState<ApiKeySummary | null>(
|
||||
null,
|
||||
);
|
||||
const [draftScopes, setDraftScopes] = React.useState<string[]>([]);
|
||||
const [rotatedSecret, setRotatedSecret] = React.useState<{
|
||||
key: ApiKeySummary;
|
||||
clientSecret: string;
|
||||
} | null>(null);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["api-keys", { limit: 50, offset: 0 }],
|
||||
queryFn: () => fetchApiKeys(50, 0),
|
||||
@@ -35,6 +73,27 @@ function ApiKeyListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateScopesMutation = useMutation({
|
||||
mutationFn: ({ id, scopes }: { id: string; scopes: string[] }) =>
|
||||
updateApiKeyScopes(id, { scopes }),
|
||||
onSuccess: () => {
|
||||
setEditingKey(null);
|
||||
setDraftScopes([]);
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const rotateSecretMutation = useMutation({
|
||||
mutationFn: (id: string) => rotateApiKeySecret(id),
|
||||
onSuccess: (data) => {
|
||||
setRotatedSecret({
|
||||
key: data.apiKey,
|
||||
clientSecret: data.clientSecret,
|
||||
});
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
@@ -62,42 +121,80 @@ function ApiKeyListPage() {
|
||||
deleteMutation.mutate(id);
|
||||
};
|
||||
|
||||
const openScopeEditor = (key: ApiKeySummary) => {
|
||||
setEditingKey(key);
|
||||
setDraftScopes(key.scopes);
|
||||
};
|
||||
|
||||
const toggleDraftScope = (scopeId: string) => {
|
||||
setDraftScopes((current) =>
|
||||
current.includes(scopeId)
|
||||
? current.filter((scope) => scope !== scopeId)
|
||||
: [...current, scopeId],
|
||||
);
|
||||
};
|
||||
|
||||
const saveScopes = () => {
|
||||
if (!editingKey || draftScopes.length === 0) return;
|
||||
updateScopesMutation.mutate({ id: editingKey.id, scopes: draftScopes });
|
||||
};
|
||||
|
||||
const handleRotateSecret = (key: ApiKeySummary) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.api_keys.list.rotate_confirm",
|
||||
'API 키 "{{name}}"의 Secret을 재발급할까요? 기존 Secret은 더 이상 사용할 수 없습니다.',
|
||||
{ name: key.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
rotateSecretMutation.mutate(key.id);
|
||||
};
|
||||
|
||||
const copyRotatedSecret = () => {
|
||||
if (!rotatedSecret) return;
|
||||
navigator.clipboard.writeText(rotatedSecret.clientSecret);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.api_keys.list.subtitle",
|
||||
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/api-keys/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<PageHeader
|
||||
sticky
|
||||
titleAs="h2"
|
||||
icon={<Key size={20} />}
|
||||
title={t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
|
||||
description={t(
|
||||
"msg.admin.api_keys.list.subtitle",
|
||||
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/api-keys/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -119,7 +216,7 @@ function ApiKeyListPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
||||
@@ -189,15 +286,40 @@ function ApiKeyListPage() {
|
||||
: t("ui.common.never", "Never")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openScopeEditor(key)}
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
{t(
|
||||
"ui.admin.api_keys.list.edit_scopes",
|
||||
"권한 수정",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRotateSecret(key)}
|
||||
disabled={rotateSecretMutation.isPending}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
{t(
|
||||
"ui.admin.api_keys.list.rotate_secret",
|
||||
"Secret 재발급",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -207,6 +329,137 @@ function ApiKeyListPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={editingKey !== null}
|
||||
onOpenChange={() => setEditingKey(null)}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.api_keys.list.edit_scopes", "권한 수정")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingKey
|
||||
? t(
|
||||
"msg.admin.api_keys.list.edit_scopes_desc",
|
||||
"{{clientId}}의 CLIENT_ID는 유지하고 권한만 변경합니다.",
|
||||
{ clientId: editingKey.client_id },
|
||||
)
|
||||
: null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
|
||||
const isSelected = draftScopes.includes(scope.id);
|
||||
return (
|
||||
<button
|
||||
key={scope.id}
|
||||
type="button"
|
||||
onClick={() => toggleDraftScope(scope.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-2 rounded-lg border-2 p-4 text-left transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-card hover:border-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<span className="font-bold text-sm">
|
||||
{t(scope.labelKey, scope.labelFallback)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t(scope.descKey, scope.descFallback)}
|
||||
</span>
|
||||
<code className="text-[9px] font-mono opacity-60 uppercase tracking-tighter">
|
||||
ID: {scope.id}
|
||||
</code>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{draftScopes.length === 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
{t(
|
||||
"msg.admin.api_keys.create.scope_required",
|
||||
"최소 하나 이상의 권한을 선택해야 합니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditingKey(null)}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveScopes}
|
||||
disabled={
|
||||
updateScopesMutation.isPending || draftScopes.length === 0
|
||||
}
|
||||
>
|
||||
<Save size={16} />
|
||||
{t("ui.admin.api_keys.list.save_scopes", "권한 저장")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={rotatedSecret !== null}
|
||||
onOpenChange={() => setRotatedSecret(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(
|
||||
"ui.admin.api_keys.list.rotate_secret_done",
|
||||
"Secret 재발급 완료",
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.api_keys.list.rotate_secret_notice",
|
||||
"새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{rotatedSecret && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
CLIENT ID
|
||||
</p>
|
||||
<code className="block rounded-md bg-muted px-3 py-2 text-sm">
|
||||
{rotatedSecret.key.client_id}
|
||||
</code>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
X-Baron-Key-Secret
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
readOnly
|
||||
value={rotatedSecret.clientSecret}
|
||||
className="font-mono pr-12"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
onClick={copyRotatedSecret}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setRotatedSecret(null)}>
|
||||
{t("ui.common.confirm", "확인")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
59
adminfront/src/features/api-keys/apiKeyScopes.ts
Normal file
59
adminfront/src/features/api-keys/apiKeyScopes.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type ApiKeyScopeOption = {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
labelFallback: string;
|
||||
descKey: string;
|
||||
descFallback: string;
|
||||
};
|
||||
|
||||
export const AVAILABLE_API_KEY_SCOPES: ApiKeyScopeOption[] = [
|
||||
{
|
||||
id: "audit:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
|
||||
labelFallback: "감사 로그 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
|
||||
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "audit:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
|
||||
labelFallback: "감사 로그 생성",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
|
||||
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
|
||||
},
|
||||
{
|
||||
id: "user:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_read.title",
|
||||
labelFallback: "사용자 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.user_read.desc",
|
||||
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "user:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_write.title",
|
||||
labelFallback: "사용자 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.user_write.desc",
|
||||
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
|
||||
labelFallback: "테넌트 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
|
||||
descFallback: "등록된 모든 조직 정보를 조회합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
|
||||
labelFallback: "테넌트 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
|
||||
descFallback: "테넌트 정보를 직접 제어합니다.",
|
||||
},
|
||||
{
|
||||
id: "org-context:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.org_context_read.title",
|
||||
labelFallback: "조직 Context 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.org_context_read.desc",
|
||||
descFallback: "외부 연동앱이 OrgFront SSOT 조직 JSON을 조회합니다.",
|
||||
},
|
||||
];
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
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 { Button } from "../../components/ui/button";
|
||||
import {
|
||||
@@ -19,92 +20,17 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const defaultAuditFilters = [
|
||||
"method:POST path:/api/v1/*",
|
||||
"status:failure",
|
||||
"latency_ms:>1000",
|
||||
];
|
||||
|
||||
type AuditDetails = {
|
||||
request_id?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
status?: number;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
tenant_id?: string;
|
||||
actor_id?: string;
|
||||
action?: string;
|
||||
target?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
};
|
||||
|
||||
function parseDetails(details?: string): AuditDetails {
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(details);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as AuditDetails;
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
function formatCellValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatIsoDateTime(value: string) {
|
||||
if (!value) {
|
||||
return { date: "-", time: "-" };
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return { date: value, time: "-" };
|
||||
}
|
||||
const date = parsed.toISOString().slice(0, 10);
|
||||
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
|
||||
return { date, time };
|
||||
}
|
||||
|
||||
function AuditLogsPage() {
|
||||
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
||||
const [filterDraft, setFilterDraft] = React.useState("");
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
const [searchActorId, setSearchActorId] = React.useState("");
|
||||
const [searchAction, setSearchAction] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const deferredSearchActorId = React.useDeferredValue(searchActorId.trim());
|
||||
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -126,20 +52,29 @@ function AuditLogsPage() {
|
||||
(page) =>
|
||||
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
||||
) ?? [];
|
||||
|
||||
const handleAddFilter = () => {
|
||||
const trimmed = filterDraft.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed]));
|
||||
setFilterDraft("");
|
||||
};
|
||||
const filteredLogs = React.useMemo(
|
||||
() =>
|
||||
logs.filter((row) => {
|
||||
const details = parseAuditDetails(row.details);
|
||||
const actorLabel = resolveAuditActor(row, details).toLowerCase();
|
||||
const actionLabel = resolveAuditAction(row, details).toLowerCase();
|
||||
const matchesActor =
|
||||
deferredSearchActorId === "" ||
|
||||
actorLabel.includes(deferredSearchActorId.toLowerCase());
|
||||
const matchesAction =
|
||||
deferredSearchAction === "" ||
|
||||
actionLabel.includes(deferredSearchAction.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || row.status === statusFilter;
|
||||
return matchesActor && matchesAction && matchesStatus;
|
||||
}),
|
||||
[logs, deferredSearchActorId, deferredSearchAction, statusFilter],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.admin.audit.loading", "Loading audit logs...")}
|
||||
{t("msg.common.audit.loading", "Loading audit logs...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -150,7 +85,7 @@ function AuditLogsPage() {
|
||||
(error as Error).message;
|
||||
return (
|
||||
<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,
|
||||
})}
|
||||
</div>
|
||||
@@ -158,445 +93,109 @@ function AuditLogsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.audit.title", "감사 로그")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.audit.subtitle",
|
||||
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button>
|
||||
<ListChecks size={16} />
|
||||
{t("ui.admin.audit.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t("ui.common.audit.title", "감사 로그")}
|
||||
description={t(
|
||||
"msg.admin.audit.subtitle",
|
||||
"관리자 작업 이력을 조회합니다.",
|
||||
)}
|
||||
icon={<NotebookTabs size={20} />}
|
||||
actions={
|
||||
<>
|
||||
<Badge variant="muted">
|
||||
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: filteredLogs.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button>
|
||||
<Download size={16} />
|
||||
{t("ui.common.export_csv", "CSV 내보내기")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.audit.registry.title", "Log Registry")}
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
{t("ui.common.audit.registry.title", "Audit registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: logs.length,
|
||||
})}
|
||||
{t(
|
||||
"msg.admin.audit.registry.description",
|
||||
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={filterDraft}
|
||||
onChange={(event) => setFilterDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
handleAddFilter();
|
||||
}
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
}}
|
||||
placeholder={t(
|
||||
"ui.admin.audit.filters.placeholder",
|
||||
"필터 추가 (예: status:failure)",
|
||||
)}
|
||||
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddFilter}>
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</div>
|
||||
{filters.length === 0 ? (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.filters.empty", "필터 없음")}
|
||||
</span>
|
||||
) : (
|
||||
filters.map((filter) => (
|
||||
<span
|
||||
key={filter}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||
>
|
||||
<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}
|
||||
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.admin.audit.load_more", "Load more")}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchActorId}
|
||||
onChange={(event) => setSearchActorId(event.target.value)}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.user_id",
|
||||
"Filter by User ID",
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,14 @@ import { ShieldHalf } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { debugLog } from "../../lib/debugLog";
|
||||
|
||||
function AuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("[AuthCallbackPage] State:", {
|
||||
debugLog("[AuthCallbackPage] State:", {
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
error: auth.error,
|
||||
|
||||
41
adminfront/src/features/auth/AuthGuard.tsx
Normal file
41
adminfront/src/features/auth/AuthGuard.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
const isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
|
||||
if (isTest) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
if (auth.isLoading || auth.activeNavigator) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (auth.error) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
|
||||
<div className="mb-4 text-destructive">
|
||||
<h2 className="text-xl font-bold">인증 오류</h2>
|
||||
<p>{auth.error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||
return (
|
||||
<Navigate
|
||||
to={`/login?returnTo=${encodeURIComponent(returnTo)}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
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";
|
||||
|
||||
function AuthPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Admin auth
|
||||
</p>
|
||||
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<KeyRound size={22} className="text-primary" />
|
||||
인증가드
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
titleAs="h2"
|
||||
icon={<ShieldHalf size={20} />}
|
||||
title={t("ui.admin.auth_guard.title", "Auth Guard")}
|
||||
description={t(
|
||||
"ui.admin.auth_guard.subtitle",
|
||||
"Verify admin privileges and ReBAC relationships against the policy engine.",
|
||||
)}
|
||||
/>
|
||||
|
||||
<PermissionChecker />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { debugLog } from "../../lib/debugLog";
|
||||
|
||||
function LoginPage() {
|
||||
const auth = useAuth();
|
||||
@@ -20,7 +21,7 @@ function LoginPage() {
|
||||
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("[LoginPage] Auth state check:", {
|
||||
debugLog("[LoginPage] Auth state check:", {
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
returnTo,
|
||||
@@ -84,8 +85,11 @@ function LoginPage() {
|
||||
variant="ghost"
|
||||
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
window.location.href =
|
||||
window.location.origin + window.location.pathname;
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
다시 시도하기
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckCircle2, ShieldAlert, XCircle } from "lucide-react";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import apiClient from "../../../lib/apiClient";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type CheckPermissionResponse = {
|
||||
allowed: boolean;
|
||||
@@ -46,50 +47,84 @@ function PermissionChecker() {
|
||||
return (
|
||||
<Card className="border-primary/20 bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldAlert size={20} className="text-primary" />
|
||||
ReBAC 권한 검증 도구
|
||||
<CardTitle className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.title",
|
||||
"ReBAC permission checker",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory
|
||||
Keto를 통해 실시간으로 확인합니다.
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.description",
|
||||
"Check in real time whether a subject has access to a resource through Ory Keto.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Namespace</Label>
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
|
||||
</Label>
|
||||
<select
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="Tenant">Tenant</option>
|
||||
<option value="TenantGroup">TenantGroup</option>
|
||||
<option value="RelyingParty">RelyingParty</option>
|
||||
<option value="System">System</option>
|
||||
<option value="Tenant">
|
||||
{t("ui.admin.auth_guard.checker.namespace.tenant", "Tenant")}
|
||||
</option>
|
||||
<option value="TenantGroup">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.namespace.tenant_group",
|
||||
"TenantGroup",
|
||||
)}
|
||||
</option>
|
||||
<option value="RelyingParty">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.namespace.relying_party",
|
||||
"RelyingParty",
|
||||
)}
|
||||
</option>
|
||||
<option value="System">
|
||||
{t("ui.admin.auth_guard.checker.namespace.system", "System")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Relation</Label>
|
||||
<Label>{t("ui.admin.auth_guard.checker.relation", "Relation")}</Label>
|
||||
<Input
|
||||
placeholder="view, manage, admins..."
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.relation_placeholder",
|
||||
"view, manage, admins...",
|
||||
)}
|
||||
value={relation}
|
||||
onChange={(e) => setRelation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Object ID</Label>
|
||||
<Label>{t("ui.admin.auth_guard.checker.object_id", "Object ID")}</Label>
|
||||
<Input
|
||||
placeholder="Tenant UUID 등"
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.object_id_placeholder",
|
||||
"Tenant UUID, etc.",
|
||||
)}
|
||||
value={object}
|
||||
onChange={(e) => setObject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subject (User:ID)</Label>
|
||||
<Label>
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.subject",
|
||||
"Subject (User:ID)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="User:uuid 또는 Namespace:ID#Relation"
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.subject_placeholder",
|
||||
"User:uuid or Namespace:ID#Relation",
|
||||
)}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
@@ -102,7 +137,9 @@ function PermissionChecker() {
|
||||
disabled={!object || !subject || checkMutation.isPending}
|
||||
className="w-full px-12 md:w-auto"
|
||||
>
|
||||
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
|
||||
{checkMutation.isPending
|
||||
? t("ui.admin.auth_guard.checker.checking", "Checking...")
|
||||
: t("ui.admin.auth_guard.checker.check", "Check permission")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -117,18 +154,33 @@ function PermissionChecker() {
|
||||
{result.allowed ? (
|
||||
<>
|
||||
<CheckCircle2 size={48} />
|
||||
<div className="text-xl font-bold">Access ALLOWED</div>
|
||||
<div className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.allowed",
|
||||
"Access ALLOWED",
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-sm opacity-80">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속
|
||||
포함)
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.allowed_description",
|
||||
"The subject has access to the requested resource, including inherited permissions.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle size={48} />
|
||||
<div className="text-xl font-bold">Access DENIED</div>
|
||||
<div className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.denied",
|
||||
"Access DENIED",
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-sm opacity-80">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.denied_description",
|
||||
"The subject does not have access to the requested resource.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
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 { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchAdminRPUsageDaily } from "../../lib/adminApi";
|
||||
import {
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchDataIntegrityReport,
|
||||
} from "../../lib/adminApi";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import AuthPage from "../auth/AuthPage";
|
||||
import GlobalOverviewPage from "./GlobalOverviewPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
let currentRole = "super_admin";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
|
||||
fetchMe: vi.fn(async () => ({ role: currentRole })),
|
||||
fetchAdminOverviewStats: vi.fn(async () => ({
|
||||
totalTenants: 10,
|
||||
totalUsers: 152,
|
||||
oidcClients: 3,
|
||||
auditEvents24h: 18,
|
||||
})),
|
||||
fetchTenants: vi.fn(async () => ({
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "group-1",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥그룹",
|
||||
slug: "hanmac-group",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "company-1",
|
||||
type: "COMPANY",
|
||||
@@ -52,7 +72,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
total: 3,
|
||||
total: 4,
|
||||
})),
|
||||
fetchAdminRPUsageDaily: vi.fn(async () => ({
|
||||
days: 14,
|
||||
@@ -93,6 +113,30 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchDataIntegrityReport: vi.fn(async () => ({
|
||||
status: "fail",
|
||||
checkedAt: "2026-05-14T00:00:00Z",
|
||||
summary: {
|
||||
totalChecks: 5,
|
||||
passed: 4,
|
||||
warnings: 0,
|
||||
failures: 1,
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
key: "tenant_integrity",
|
||||
label: "테넌트 정합성",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
},
|
||||
{
|
||||
key: "user_integrity",
|
||||
label: "사용자 정합성",
|
||||
status: "fail",
|
||||
checks: [],
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
@@ -112,6 +156,7 @@ function renderWithProviders(ui: React.ReactElement) {
|
||||
|
||||
describe("admin overview and auth guard pages", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -119,7 +164,7 @@ describe("admin overview and auth guard pages", () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"),
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByLabelText("일 단위 RP 요청 현황"),
|
||||
@@ -132,50 +177,85 @@ describe("admin overview and auth guard pages", () => {
|
||||
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders overview summary metrics from the admin stats API", async () => {
|
||||
it("renders overview tenant count from the fully fetched tenant list", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
(await screen.findByText("전체 테넌트 수")).parentElement,
|
||||
).toHaveTextContent("10");
|
||||
).toHaveTextContent("4");
|
||||
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
|
||||
"3",
|
||||
);
|
||||
expect(screen.getByText("전체 사용자 수").parentElement).toHaveTextContent(
|
||||
"152",
|
||||
);
|
||||
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
|
||||
"18",
|
||||
);
|
||||
});
|
||||
|
||||
it("changes the RP usage perspective and targets a permitted organization", async () => {
|
||||
it("limits the overview graph choices to company tenants", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
|
||||
expect(
|
||||
await screen.findByRole("checkbox", { name: "한맥 (hanmac)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("한맥그룹 (hanmac-group)"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("changes the RP usage perspective and targets a permitted company", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
fireEvent.click(screen.getByRole("button", { name: "주" }));
|
||||
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
|
||||
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "월" }));
|
||||
fireEvent.change(screen.getByLabelText("조직 검색"), {
|
||||
target: { value: "개발" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("대상 조직"), {
|
||||
target: { value: "org-1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
||||
days: 90,
|
||||
period: "month",
|
||||
tenantId: "org-1",
|
||||
});
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("한맥그룹 (hanmac-group)"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows the latest integrity summary at the bottom for super admins only", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(await screen.findByText("정합성 최종 검증")).toBeInTheDocument();
|
||||
expect(screen.getByText("실패 1건")).toBeInTheDocument();
|
||||
expect(screen.getByText("테넌트 정합성")).toBeInTheDocument();
|
||||
expect(screen.getByText("사용자 정합성")).toBeInTheDocument();
|
||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fetch or show the integrity summary for non-super admins", async () => {
|
||||
currentRole = "tenant_admin";
|
||||
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument();
|
||||
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
|
||||
renderWithProviders(<AuthPage />);
|
||||
|
||||
expect(screen.getByText("인증가드")).toBeInTheDocument();
|
||||
expect(screen.getByText("인증 가드")).toBeInTheDocument();
|
||||
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
LayoutDashboard,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
OverviewAxisNotes,
|
||||
OverviewMetric,
|
||||
OverviewSelectionChips,
|
||||
} from "../../../../common/core/components/overview";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
type DataIntegrityStatus,
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
type TenantSummary,
|
||||
fetchAdminOverviewStats,
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchTenants,
|
||||
fetchAllTenants,
|
||||
fetchDataIntegrityReport,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
@@ -26,10 +35,8 @@ type DailyPoint = {
|
||||
|
||||
type SeriesSummary = {
|
||||
key: string;
|
||||
tenantLabel: string;
|
||||
clientLabel: string;
|
||||
loginRequests: number;
|
||||
otherRequests: number;
|
||||
uniqueSubjects: number;
|
||||
};
|
||||
|
||||
@@ -55,19 +62,16 @@ function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
|
||||
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||
const bySeries = new Map<string, SeriesSummary>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.tenantId}:${row.clientId}`;
|
||||
const key = row.clientId;
|
||||
const current =
|
||||
bySeries.get(key) ??
|
||||
({
|
||||
key,
|
||||
tenantLabel: row.tenantName || row.tenantId || "-",
|
||||
clientLabel: row.clientName || row.clientId,
|
||||
loginRequests: 0,
|
||||
otherRequests: 0,
|
||||
uniqueSubjects: 0,
|
||||
} satisfies SeriesSummary);
|
||||
current.loginRequests += row.loginRequests;
|
||||
current.otherRequests += row.otherRequests;
|
||||
current.uniqueSubjects = Math.max(
|
||||
current.uniqueSubjects,
|
||||
row.uniqueSubjects,
|
||||
@@ -75,10 +79,7 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||
bySeries.set(key, current);
|
||||
}
|
||||
return Array.from(bySeries.values())
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
|
||||
)
|
||||
.sort((a, b) => b.loginRequests - a.loginRequests)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
@@ -133,30 +134,135 @@ function formatPeriodLabel(date: string, period: RPUsagePeriod) {
|
||||
return `${parts.monthText}.${parts.dayText}`;
|
||||
}
|
||||
|
||||
function OverviewMetric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
function formatOverviewDateTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function integrityStatusText(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return t("ui.admin.integrity.status.pass", "정상");
|
||||
case "warning":
|
||||
return t("ui.admin.integrity.status.warning", "주의");
|
||||
default:
|
||||
return t("ui.admin.integrity.status.fail", "실패");
|
||||
}
|
||||
}
|
||||
|
||||
function integrityStatusClass(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "text-emerald-700 dark:text-emerald-300";
|
||||
case "warning":
|
||||
return "text-amber-700 dark:text-amber-300";
|
||||
default:
|
||||
return "text-destructive";
|
||||
}
|
||||
}
|
||||
|
||||
function IntegrityOverviewSummary() {
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ["admin-overview-integrity"],
|
||||
queryFn: fetchDataIntegrityReport,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<section className="border-t border-border/60 pt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle size={16} />
|
||||
<span>
|
||||
{t(
|
||||
"ui.admin.integrity.fetch_error",
|
||||
"정합성 최종 검증 결과를 불러오지 못했습니다.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-semibold tabular-nums">{value}</span>
|
||||
</span>
|
||||
<section className="border-t border-border/60 pt-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{data.status === "pass" ? (
|
||||
<CheckCircle2 size={18} className="text-emerald-600" />
|
||||
) : (
|
||||
<AlertTriangle size={18} className="text-amber-600" />
|
||||
)}
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.summary.title",
|
||||
"정합성 최종 검증",
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span
|
||||
className={`font-semibold ${integrityStatusClass(data.status)}`}
|
||||
>
|
||||
{integrityStatusText(data.status)}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{t("ui.admin.integrity.summary.failures_text", "실패 {{count}}건", {
|
||||
count: data.summary.failures,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatOverviewDateTime(data.checkedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||
{data.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2"
|
||||
>
|
||||
<span>{integritySectionLabel(section.key, section.label)}</span>
|
||||
<span
|
||||
className={`font-medium ${integrityStatusClass(section.status)}`}
|
||||
>
|
||||
{integrityStatusText(section.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function integritySectionLabel(key: string, fallback: string) {
|
||||
switch (key) {
|
||||
case "tenant_integrity":
|
||||
return t("ui.admin.integrity.section.tenant_integrity", fallback);
|
||||
case "user_integrity":
|
||||
return t("ui.admin.integrity.section.user_integrity", fallback);
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function RPUsageMixedChart({
|
||||
rows,
|
||||
periodControls,
|
||||
filters,
|
||||
period,
|
||||
}: {
|
||||
rows: RPUsageDailyMetric[];
|
||||
periodControls: ReactNode;
|
||||
filters: ReactNode;
|
||||
period: RPUsagePeriod;
|
||||
}) {
|
||||
@@ -185,142 +291,131 @@ function RPUsageMixedChart({
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 size={18} className="text-primary" />
|
||||
<h3 className="text-base font-semibold">
|
||||
회사별 앱별 로그인요청/기타 요청 현황
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t("ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{filters}
|
||||
{periodControls}
|
||||
</div>
|
||||
|
||||
{filters}
|
||||
|
||||
{daily.length === 0 ? (
|
||||
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
||||
표시할 RP 이용 집계가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="일 단위 RP 요청 현황"
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
className="h-[235px] min-w-[720px] w-full"
|
||||
>
|
||||
<title>일 단위 RP 요청 현황</title>
|
||||
<g transform="translate(510 10)">
|
||||
<rect
|
||||
x="0"
|
||||
y="3"
|
||||
width="10"
|
||||
height="10"
|
||||
rx="2"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
|
||||
기타 요청
|
||||
</text>
|
||||
<line
|
||||
x1="78"
|
||||
x2="98"
|
||||
y1="8"
|
||||
y2="8"
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="일 단위 RP 요청 현황"
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
className="h-[235px] min-w-[720px] w-full"
|
||||
>
|
||||
<title>일 단위 RP 요청 현황</title>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const gridY = padTop + innerHeight * ratio;
|
||||
const label = Math.round(maxValue * (1 - ratio));
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padX}
|
||||
x2={chartWidth - padX}
|
||||
y1={gridY}
|
||||
y2={gridY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padX - 12}
|
||||
y={gridY + 4}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{daily.map((point, index) => {
|
||||
const center = x(index);
|
||||
const otherHeight =
|
||||
(point.otherRequests / maxValue) * innerHeight;
|
||||
return (
|
||||
<g key={point.date}>
|
||||
<rect
|
||||
x={center - barWidth / 2}
|
||||
y={padTop + innerHeight - otherHeight}
|
||||
width={barWidth}
|
||||
height={otherHeight}
|
||||
rx="3"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={chartHeight - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{formatPeriodLabel(point.date, period)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<polyline
|
||||
points={linePoints}
|
||||
fill="none"
|
||||
className="stroke-emerald-500"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<text
|
||||
x="104"
|
||||
y="12"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
로그인 요청
|
||||
</text>
|
||||
</g>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const gridY = padTop + innerHeight * ratio;
|
||||
const label = Math.round(maxValue * (1 - ratio));
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padX}
|
||||
x2={chartWidth - padX}
|
||||
y1={gridY}
|
||||
y2={gridY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padX - 12}
|
||||
y={gridY + 4}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{daily.map((point, index) => {
|
||||
const center = x(index);
|
||||
const otherHeight =
|
||||
(point.otherRequests / maxValue) * innerHeight;
|
||||
return (
|
||||
<g key={point.date}>
|
||||
<rect
|
||||
x={center - barWidth / 2}
|
||||
y={padTop + innerHeight - otherHeight}
|
||||
width={barWidth}
|
||||
height={otherHeight}
|
||||
rx="3"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={chartHeight - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{formatPeriodLabel(point.date, period)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<polyline
|
||||
points={linePoints}
|
||||
fill="none"
|
||||
className="stroke-emerald-500"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{daily.map((point, index) => (
|
||||
<circle
|
||||
key={`${point.date}-login`}
|
||||
cx={x(index)}
|
||||
cy={y(point.loginRequests)}
|
||||
r="4"
|
||||
className="fill-emerald-500 stroke-background"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{daily.map((point, index) => (
|
||||
<circle
|
||||
key={`${point.date}-login`}
|
||||
cx={x(index)}
|
||||
cy={y(point.loginRequests)}
|
||||
r="4"
|
||||
className="fill-emerald-500 stroke-background"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<OverviewAxisNotes
|
||||
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
|
||||
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{series.length > 0 && (
|
||||
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
|
||||
{series.map((item) => (
|
||||
<div key={item.key} className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{item.clientLabel}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{item.tenantLabel}
|
||||
</span>
|
||||
<span className="ml-auto whitespace-nowrap tabular-nums">
|
||||
로그인 {item.loginRequests.toLocaleString()} / 기타{" "}
|
||||
{item.otherRequests.toLocaleString()} / 사용자{" "}
|
||||
{item.uniqueSubjects.toLocaleString()}
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
|
||||
>
|
||||
<span className="font-medium">{item.clientLabel}</span>
|
||||
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{t(
|
||||
"ui.common.chart.series_summary.login_users",
|
||||
"로그인 {{login}} / 사용자 {{subjects}}",
|
||||
{
|
||||
login: item.loginRequests.toLocaleString(),
|
||||
subjects: item.uniqueSubjects.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -332,8 +427,7 @@ function RPUsageMixedChart({
|
||||
|
||||
function GlobalOverviewPage() {
|
||||
const [period, setPeriod] = useState<RPUsagePeriod>("day");
|
||||
const [tenantSearch, setTenantSearch] = useState("");
|
||||
const [selectedTenantId, setSelectedTenantId] = useState("");
|
||||
const [selectedTenantIds, setSelectedTenantIds] = useState<string[]>([]);
|
||||
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["admin-overview-stats"],
|
||||
@@ -342,97 +436,97 @@ function GlobalOverviewPage() {
|
||||
});
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["admin-overview-tenant-options"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
retry: false,
|
||||
});
|
||||
const tenantOptions = useMemo(() => {
|
||||
const term = tenantSearch.trim().toLowerCase();
|
||||
return (tenantsQuery.data?.items ?? [])
|
||||
.filter(
|
||||
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
|
||||
)
|
||||
.filter((tenant) => {
|
||||
if (!term) return true;
|
||||
return (
|
||||
tenant.name.toLowerCase().includes(term) ||
|
||||
tenant.slug.toLowerCase().includes(term) ||
|
||||
tenant.id.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [tenantSearch, tenantsQuery.data?.items]);
|
||||
return (tenantsQuery.data?.items ?? []).filter(
|
||||
(tenant) => tenant.type === "COMPANY",
|
||||
);
|
||||
}, [tenantsQuery.data?.items]);
|
||||
const usageQuery = useQuery({
|
||||
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
|
||||
queryKey: ["admin-rp-usage-daily", usageDays, period],
|
||||
queryFn: () =>
|
||||
fetchAdminRPUsageDaily({
|
||||
days: usageDays,
|
||||
period,
|
||||
tenantId: selectedTenantId || undefined,
|
||||
}),
|
||||
retry: false,
|
||||
});
|
||||
const stats = statsQuery.data;
|
||||
const visibleTenantCount = tenantsQuery.data?.items.length;
|
||||
const usageRows = usageQuery.data?.items ?? [];
|
||||
const filteredUsageRows = useMemo(() => {
|
||||
if (selectedTenantIds.length === 0) {
|
||||
return usageRows;
|
||||
}
|
||||
const selectedSet = new Set(selectedTenantIds);
|
||||
return usageRows.filter((row) => selectedSet.has(row.tenantId));
|
||||
}, [selectedTenantIds, usageRows]);
|
||||
const metric = (value: number | undefined) =>
|
||||
value === undefined ? "-" : value.toLocaleString();
|
||||
const periodControls = (
|
||||
<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 = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||
{[
|
||||
["day", "일"],
|
||||
["week", "주"],
|
||||
["month", "월"],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={period === value}
|
||||
onClick={() => setPeriod(value as RPUsagePeriod)}
|
||||
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
|
||||
period === value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/60 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
aria-label="조직 검색"
|
||||
value={tenantSearch}
|
||||
onChange={(event) => setTenantSearch(event.target.value)}
|
||||
placeholder="조직 검색"
|
||||
className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44"
|
||||
<div>
|
||||
<OverviewSelectionChips
|
||||
allLabel="전체"
|
||||
options={tenantOptions.map((tenant) => ({
|
||||
id: tenant.id,
|
||||
label: `${tenant.name} (${tenant.slug})`,
|
||||
}))}
|
||||
selectedIds={selectedTenantIds}
|
||||
onSelectAll={() => setSelectedTenantIds([])}
|
||||
onToggle={(tenantId) => {
|
||||
setSelectedTenantIds((current) =>
|
||||
current.includes(tenantId)
|
||||
? current.filter((item) => item !== tenantId)
|
||||
: [...current, tenantId],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
aria-label="대상 조직"
|
||||
value={selectedTenantId}
|
||||
onChange={(event) => setSelectedTenantId(event.target.value)}
|
||||
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-52"
|
||||
>
|
||||
<option value="">전체 조직</option>
|
||||
{tenantOptions.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.id}>
|
||||
{tenant.name} ({tenant.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-in fade-in duration-500">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("ui.admin.overview.title", "Dashboard")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.overview.description",
|
||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<LayoutDashboard size={20} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.common.overview.title", "운영 현황")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.overview.description",
|
||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -444,7 +538,7 @@ function GlobalOverviewPage() {
|
||||
"ui.admin.overview.summary.total_tenants",
|
||||
"전체 테넌트 수",
|
||||
)}
|
||||
value={metric(stats?.totalTenants)}
|
||||
value={metric(visibleTenantCount ?? stats?.totalTenants)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<ShieldCheck size={14} />}
|
||||
@@ -454,6 +548,11 @@ function GlobalOverviewPage() {
|
||||
)}
|
||||
value={metric(stats?.oidcClients)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Users size={14} />}
|
||||
label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")}
|
||||
value={metric(stats?.totalUsers)}
|
||||
/>
|
||||
</RoleGuard>
|
||||
<OverviewMetric
|
||||
icon={<Activity size={14} />}
|
||||
@@ -472,12 +571,24 @@ function GlobalOverviewPage() {
|
||||
|
||||
{usageQuery.isError ? (
|
||||
<section className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold">
|
||||
회사별 앱별 로그인요청/기타 요청 현황
|
||||
</h3>
|
||||
{chartFilters}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.overview.chart.title",
|
||||
"회사별 앱별 로그인 요청 현황",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{periodControls}
|
||||
</div>
|
||||
{chartFilters}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
||||
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
||||
@@ -486,11 +597,16 @@ function GlobalOverviewPage() {
|
||||
</section>
|
||||
) : (
|
||||
<RPUsageMixedChart
|
||||
rows={usageRows}
|
||||
rows={filteredUsageRows}
|
||||
periodControls={periodControls}
|
||||
filters={chartFilters}
|
||||
period={period}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<IntegrityOverviewSummary />
|
||||
</RoleGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
reconcileUserProjection,
|
||||
resetUserProjection,
|
||||
} from "../../lib/adminApi";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import UserProjectionPage from "./UserProjectionPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
let currentRole = "super_admin";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
@@ -52,18 +55,24 @@ describe("UserProjectionPage", () => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
});
|
||||
|
||||
it("renders projection status for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("사용자 Projection 관리"),
|
||||
await screen.findByText("사용자 동기화 관리"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Kratos users projection"),
|
||||
await screen.findByText(
|
||||
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("ready")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Kratos 사용자 동기화"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("준비됨")).toBeInTheDocument();
|
||||
expect(screen.getByText("152")).toBeInTheDocument();
|
||||
expect(fetchUserProjectionStatus).toHaveBeenCalled();
|
||||
});
|
||||
@@ -71,7 +80,7 @@ describe("UserProjectionPage", () => {
|
||||
it("runs reconcile and reset actions for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("사용자 Projection 관리");
|
||||
await screen.findByText("사용자 동기화 관리");
|
||||
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -92,8 +101,22 @@ describe("UserProjectionPage", () => {
|
||||
|
||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("사용자 Projection 관리"),
|
||||
screen.queryByText("사용자 동기화 관리"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders localized labels in English", async () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("User Projection Management"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Review and sync the Kratos user read model."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Re-sync")).toBeInTheDocument();
|
||||
expect(await screen.findByText("ready")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Database, RefreshCw, RotateCcw } from "lucide-react";
|
||||
import { AlertTriangle, RefreshCw, RotateCcw, Users } from "lucide-react";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
reconcileUserProjection,
|
||||
resetUserProjection,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { getAdminDateLocale } from "../../lib/locale";
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) {
|
||||
@@ -17,7 +19,7 @@ function formatDateTime(value?: string) {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
return new Intl.DateTimeFormat(getAdminDateLocale(), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
@@ -31,12 +33,26 @@ function ProjectionStatusBadge({
|
||||
status: string;
|
||||
}) {
|
||||
if (ready) {
|
||||
return <Badge variant="success">ready</Badge>;
|
||||
return (
|
||||
<Badge variant="success">
|
||||
{t("ui.admin.user_projection.status.ready", "ready")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === "failed") {
|
||||
return <Badge variant="warning">failed</Badge>;
|
||||
return (
|
||||
<Badge variant="warning">
|
||||
{t("ui.admin.user_projection.status.failed", "failed")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">{status || "not ready"}</Badge>;
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{status
|
||||
? status
|
||||
: t("ui.admin.user_projection.status.not_ready", "not ready")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function UserProjectionContent() {
|
||||
@@ -64,7 +80,10 @@ function UserProjectionContent() {
|
||||
|
||||
const handleReset = () => {
|
||||
const confirmed = window.confirm(
|
||||
"사용자 projection을 Kratos 기준으로 다시 구축하시겠습니까?",
|
||||
t(
|
||||
"msg.admin.user_projection.reset_confirm",
|
||||
"Rebuild user projection from the Kratos source of truth?",
|
||||
),
|
||||
);
|
||||
if (confirmed) {
|
||||
resetMutation.mutate();
|
||||
@@ -76,13 +95,26 @@ function UserProjectionContent() {
|
||||
const actionError = reconcileMutation.error ?? resetMutation.error;
|
||||
|
||||
return (
|
||||
<main className="space-y-6 p-6 md:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">System</p>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
사용자 Projection 관리
|
||||
</h2>
|
||||
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t(
|
||||
"ui.admin.user_projection.title",
|
||||
"User Projection Management",
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.user_projection.subtitle",
|
||||
"Review and sync the Kratos user read model.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
@@ -92,7 +124,7 @@ function UserProjectionContent() {
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
재동기화
|
||||
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -101,49 +133,72 @@ function UserProjectionContent() {
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
초기화 후 재구축
|
||||
{t(
|
||||
"ui.admin.user_projection.actions.reset",
|
||||
"Reset and rebuild",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message ||
|
||||
"projection 상태를 불러오지 못했습니다."}
|
||||
t(
|
||||
"msg.admin.user_projection.load_error",
|
||||
"Failed to load projection status.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{actionResult ? (
|
||||
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||
{actionResult.syncedUsers}명 기준으로 projection을 갱신했습니다.
|
||||
{t(
|
||||
"msg.admin.user_projection.action_success",
|
||||
"Refreshed the projection for {{count}} users.",
|
||||
{ count: actionResult.syncedUsers },
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(actionError as Error)?.message || "projection 작업에 실패했습니다."}
|
||||
{(actionError as Error)?.message ||
|
||||
t(
|
||||
"msg.admin.user_projection.action_error",
|
||||
"Projection operation failed.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-3 border-b border-border pb-4">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||
<Database size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Kratos users projection</h3>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.user_projection.card.title",
|
||||
"Kratos users projection",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Backend DB 통계가 참조하는 사용자 read model 상태입니다.
|
||||
{t(
|
||||
"ui.admin.user_projection.card.description",
|
||||
"Current user read model state referenced by backend DB statistics.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">불러오는 중</div>
|
||||
<div className="py-8 text-sm text-muted-foreground">
|
||||
{t("ui.admin.user_projection.loading", "Loading")}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">상태</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.user_projection.summary.status", "Status")}
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<ProjectionStatusBadge
|
||||
ready={data?.ready ?? false}
|
||||
@@ -153,20 +208,33 @@ function UserProjectionContent() {
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
Projection 사용자
|
||||
{t(
|
||||
"ui.admin.user_projection.summary.projected_users",
|
||||
"Projected users",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.projectedUsers ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">마지막 동기화</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.user_projection.summary.last_synced",
|
||||
"Last synced",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.lastSyncedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">상태 갱신</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.user_projection.summary.updated_at",
|
||||
"Updated at",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.updatedAt)}
|
||||
</dd>
|
||||
@@ -190,14 +258,22 @@ export default function UserProjectionPage() {
|
||||
<RoleGuard
|
||||
roles={["super_admin"]}
|
||||
fallback={
|
||||
<main className="p-6 md:p-8">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">접근 권한이 없습니다</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
이 화면은 super_admin 권한으로만 접근할 수 있습니다.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<main className="p-6 md:p-8">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t(
|
||||
"ui.admin.user_projection.forbidden.title",
|
||||
"Access denied",
|
||||
)}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.user_projection.forbidden.description",
|
||||
"This screen is only available to super_admin users.",
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<UserProjectionContent />
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
|
||||
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
|
||||
|
||||
export function filterParentTenants(
|
||||
tenants: TenantSummary[],
|
||||
search: string,
|
||||
companyOnly: boolean,
|
||||
excludeTenantId = "",
|
||||
) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
return tenants.filter((tenant) => {
|
||||
if (excludeTenantId && tenant.id === excludeTenantId) return false;
|
||||
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
|
||||
if (!normalizedSearch) return true;
|
||||
|
||||
return [tenant.name, tenant.slug, tenant.type]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { filterParentTenants } from "./ParentTenantSelector";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
const tenants: TenantSummary[] = [
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "../../users/orgChartPicker";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
type ParentTenantSelectorProps = {
|
||||
id: string;
|
||||
@@ -33,26 +35,6 @@ type ParentTenantSelectorProps = {
|
||||
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({
|
||||
id,
|
||||
label,
|
||||
@@ -106,27 +88,100 @@ export function ParentTenantSelector({
|
||||
</div>
|
||||
<input id={id} name={id} type="hidden" value={value} readOnly />
|
||||
<div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{orgChartPickerLabel ??
|
||||
selectedTenant?.name ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</Button>
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{orgChartPickerLabel ??
|
||||
selectedTenant?.name ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.parent.picker_description",
|
||||
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<iframe
|
||||
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
src={pickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{localPickerLabel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLocalPickerOpen(true)}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{localPickerLabel}
|
||||
</Button>
|
||||
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{localPickerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{localPickerLabel ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_description",
|
||||
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={localSearch}
|
||||
onChange={(event) => setLocalSearch(event.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.parent.local_search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색",
|
||||
)}
|
||||
/>
|
||||
<div className="max-h-[360px] space-y-2 overflow-y-auto">
|
||||
{localCandidates.map((tenant) => (
|
||||
<Button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-start px-3 py-2 text-left"
|
||||
onClick={() => {
|
||||
onChange(tenant.id);
|
||||
setLocalPickerOpen(false);
|
||||
setLocalSearch("");
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">
|
||||
{tenant.name}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{tenant.slug} · {tenant.type}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{localCandidates.length === 0 && (
|
||||
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_empty",
|
||||
"선택할 수 있는 테넌트가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{selectedTenant ? (
|
||||
<>
|
||||
@@ -156,85 +211,6 @@ export function ParentTenantSelector({
|
||||
{helpText && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
|
||||
)}
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.parent.picker_description",
|
||||
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<iframe
|
||||
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
src={pickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{localPickerLabel ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_description",
|
||||
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={localSearch}
|
||||
onChange={(event) => setLocalSearch(event.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.parent.local_search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색",
|
||||
)}
|
||||
/>
|
||||
<div className="max-h-[360px] space-y-2 overflow-y-auto">
|
||||
{localCandidates.map((tenant) => (
|
||||
<Button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-start px-3 py-2 text-left"
|
||||
onClick={() => {
|
||||
onChange(tenant.id);
|
||||
setLocalPickerOpen(false);
|
||||
setLocalSearch("");
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">
|
||||
{tenant.name}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{tenant.slug} · {tenant.type}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{localCandidates.length === 0 && (
|
||||
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_empty",
|
||||
"선택할 수 있는 테넌트가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -53,13 +54,27 @@ import { t } from "../../../lib/i18n";
|
||||
|
||||
type DialogMode = "owner" | "admin";
|
||||
|
||||
function mergePendingMembers(
|
||||
members: TenantAdmin[],
|
||||
pendingMembers: TenantAdmin[],
|
||||
) {
|
||||
const existingIds = new Set(members.map((member) => member.id));
|
||||
return [
|
||||
...members,
|
||||
...pendingMembers.filter((member) => !existingIds.has(member.id)),
|
||||
];
|
||||
}
|
||||
|
||||
export function TenantAdminsAndOwnersTab() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
|
||||
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
@@ -95,18 +110,22 @@ export function TenantAdminsAndOwnersTab() {
|
||||
// Optimistically add to the list to prevent immediate double clicks
|
||||
const addedUser = searchResults.find((u) => u.id === userId);
|
||||
if (addedUser) {
|
||||
const optimisticOwner = {
|
||||
id: userId,
|
||||
name: addedUser.name,
|
||||
email: addedUser.email,
|
||||
};
|
||||
setPendingOwners((old) =>
|
||||
old.some((owner) => owner.id === userId)
|
||||
? old
|
||||
: [...old, optimisticOwner],
|
||||
);
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-owners", tenantId],
|
||||
(old) => {
|
||||
if (!old)
|
||||
return [
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
if (!old) return [optimisticOwner];
|
||||
if (old.some((o) => o.id === userId)) return old;
|
||||
return [
|
||||
...old,
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
return [...old, optimisticOwner];
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -125,6 +144,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
setSearchTerm("");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
|
||||
if (context?.previousOwners) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-owners", tenantId],
|
||||
@@ -148,6 +168,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
"tenant-owners",
|
||||
tenantId,
|
||||
]);
|
||||
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-owners", tenantId],
|
||||
(old) => (old ? old.filter((o) => o.id !== userId) : []),
|
||||
@@ -194,18 +215,22 @@ export function TenantAdminsAndOwnersTab() {
|
||||
|
||||
const addedUser = searchResults.find((u) => u.id === userId);
|
||||
if (addedUser) {
|
||||
const optimisticAdmin = {
|
||||
id: userId,
|
||||
name: addedUser.name,
|
||||
email: addedUser.email,
|
||||
};
|
||||
setPendingAdmins((old) =>
|
||||
old.some((admin) => admin.id === userId)
|
||||
? old
|
||||
: [...old, optimisticAdmin],
|
||||
);
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-admins", tenantId],
|
||||
(old) => {
|
||||
if (!old)
|
||||
return [
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
if (!old) return [optimisticAdmin];
|
||||
if (old.some((a) => a.id === userId)) return old;
|
||||
return [
|
||||
...old,
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
return [...old, optimisticAdmin];
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -223,6 +248,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
setSearchTerm("");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
|
||||
if (context?.previousAdmins) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-admins", tenantId],
|
||||
@@ -246,6 +272,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
"tenant-admins",
|
||||
tenantId,
|
||||
]);
|
||||
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-admins", tenantId],
|
||||
(old) => (old ? old.filter((a) => a.id !== userId) : []),
|
||||
@@ -312,8 +339,10 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const currentOwners = ownersQuery.data || [];
|
||||
const currentAdmins = adminsQuery.data || [];
|
||||
const serverOwners = ownersQuery.data || [];
|
||||
const serverAdmins = adminsQuery.data || [];
|
||||
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
||||
const currentAdmins = mergePendingMembers(serverAdmins, pendingAdmins);
|
||||
const searchResults = usersQuery.data?.items || [];
|
||||
const isDialogOpen = dialogMode !== null;
|
||||
|
||||
@@ -363,7 +392,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.owners.table_name", "이름")}
|
||||
@@ -371,22 +400,19 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.owners.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.owners.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ownersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<TableCell colSpan={2} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentOwners.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
colSpan={2}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -404,7 +430,8 @@ export function TenantAdminsAndOwnersTab() {
|
||||
currentOwners.map((owner) => (
|
||||
<TableRow
|
||||
key={owner.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
className="hover:bg-muted/30 transition-colors group cursor-pointer"
|
||||
onClick={() => navigate(`/users/${owner.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -417,46 +444,6 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{owner.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="relative inline-block group/tt">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveOwner(owner.id, owner.name)
|
||||
}
|
||||
disabled={
|
||||
removeOwnerMutation.isPending ||
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
|
||||
{owner.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentOwners.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_last",
|
||||
"마지막 소유자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.owners.remove_title",
|
||||
"소유자 권한 회수",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
@@ -494,7 +481,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.admins.table_name", "이름")}
|
||||
@@ -502,22 +489,19 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.admins.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<TableCell colSpan={2} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentAdmins.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
colSpan={2}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -535,7 +519,8 @@ export function TenantAdminsAndOwnersTab() {
|
||||
currentAdmins.map((admin) => (
|
||||
<TableRow
|
||||
key={admin.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
className="hover:bg-muted/30 transition-colors group cursor-pointer"
|
||||
onClick={() => navigate(`/users/${admin.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -548,46 +533,6 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{admin.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="relative inline-block group/tt">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveAdmin(admin.id, admin.name)
|
||||
}
|
||||
disabled={
|
||||
removeAdminMutation.isPending ||
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
|
||||
{admin.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentAdmins.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_last",
|
||||
"마지막 관리자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.admins.remove_title",
|
||||
"관리자 권한 회수",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Building2, Sparkles } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
||||
import { createTenant, fetchAllTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||
@@ -30,12 +30,19 @@ import {
|
||||
shouldAllowHanmacOrgConfig,
|
||||
} from "../utils/orgConfig";
|
||||
|
||||
type AdminFrontTestHooks = {
|
||||
selectTenantParent?: (tenantId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function TenantCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("COMPANY");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [parentId, setParentId] = useState(
|
||||
() => searchParams.get("parentId") ?? "",
|
||||
);
|
||||
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
|
||||
const [orgUnitType, setOrgUnitType] = useState("");
|
||||
const [visibility, setVisibility] = useState<TenantVisibility>("public");
|
||||
@@ -47,8 +54,8 @@ function TenantCreatePage() {
|
||||
);
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", { limit: 1000 }],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
const tenants = parentQuery.data?.items ?? [];
|
||||
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
|
||||
@@ -74,10 +81,22 @@ function TenantCreatePage() {
|
||||
"ui.admin.tenants.create.parent_context.pick_required",
|
||||
"상위 테넌트 선택 필요",
|
||||
);
|
||||
const handleParentChange = (nextParentId: string) => {
|
||||
const handleParentChange = useCallback((nextParentId: string) => {
|
||||
setParentId(nextParentId);
|
||||
setParentStepConfirmed(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const testWindow = window as Window &
|
||||
typeof globalThis & {
|
||||
__adminfrontTestHooks?: AdminFrontTestHooks;
|
||||
};
|
||||
const hooks = testWindow.__adminfrontTestHooks ?? {};
|
||||
hooks.selectTenantParent = async (tenantId: string) => {
|
||||
handleParentChange(tenantId);
|
||||
};
|
||||
testWindow.__adminfrontTestHooks = hooks;
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (overrideForceDomains?: string[]) =>
|
||||
@@ -205,6 +224,14 @@ function TenantCreatePage() {
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tenant-test-select-hanmac-parent"
|
||||
hidden
|
||||
onClick={() => handleParentChange("family-1")}
|
||||
>
|
||||
test-select-hanmac-parent
|
||||
</button>
|
||||
</div>
|
||||
{canConfigureHanmacOrg && (
|
||||
<>
|
||||
|
||||
@@ -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 { canShowWorksmobileEntry } from "./TenantDetailPage";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||
|
||||
describe("TenantDetailPage Worksmobile entry visibility", () => {
|
||||
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 { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
export function canShowWorksmobileEntry(tenant?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
parentId?: string | null;
|
||||
}) {
|
||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
||||
}
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||
|
||||
function TenantDetailPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
@@ -30,8 +24,9 @@ function TenantDetailPage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccessSchema =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
|
||||
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -513,7 +514,7 @@ function TenantGroupsPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.name", "NAME")}
|
||||
@@ -610,7 +611,7 @@ function TenantGroupsPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.name", "이름")}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
filterTenantsByScope,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
tenantMatchesListSearch,
|
||||
} from "./tenantListView";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
name: string,
|
||||
slug: string,
|
||||
parentId?: string,
|
||||
): TenantSummary {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
parentId,
|
||||
type: parentId ? "ORGANIZATION" : "COMPANY",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
const tenants = [
|
||||
tenant("company-1", "한맥기술", "hanmac"),
|
||||
tenant("dept-1", "기술기획", "planning", "company-1"),
|
||||
tenant("team-1", "플랫폼팀", "platform", "dept-1"),
|
||||
tenant("company-2", "삼안", "saman"),
|
||||
];
|
||||
|
||||
describe("TenantListPage tenant list helpers", () => {
|
||||
it("selects a parent tenant together with every descendant", () => {
|
||||
expect(
|
||||
resolveTenantSelectionIds({
|
||||
currentIds: [],
|
||||
tenant: tenants[0],
|
||||
checked: true,
|
||||
tenants,
|
||||
deletableTenants: tenants,
|
||||
}),
|
||||
).toEqual(["company-1", "dept-1", "team-1"]);
|
||||
});
|
||||
|
||||
it("removes a parent tenant together with every descendant", () => {
|
||||
expect(
|
||||
resolveTenantSelectionIds({
|
||||
currentIds: ["company-1", "dept-1", "team-1", "company-2"],
|
||||
tenant: tenants[0],
|
||||
checked: false,
|
||||
tenants,
|
||||
deletableTenants: tenants,
|
||||
}),
|
||||
).toEqual(["company-2"]);
|
||||
});
|
||||
|
||||
it("filters to descendants of the selected scope tenant", () => {
|
||||
expect(
|
||||
filterTenantsByScope(tenants, "company-1").map((item) => item.id),
|
||||
).toEqual(["dept-1", "team-1"]);
|
||||
});
|
||||
|
||||
it("searches tenants by name, slug, and UUID", () => {
|
||||
expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true);
|
||||
expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true);
|
||||
expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true);
|
||||
});
|
||||
|
||||
it("can return tree rows or same-level table rows", () => {
|
||||
expect(getTenantViewRows(tenants, "tree").map((row) => row.depth)).toEqual([
|
||||
0, 1, 2, 0,
|
||||
]);
|
||||
expect(getTenantViewRows(tenants, "table").map((row) => row.depth)).toEqual(
|
||||
[0, 0, 0, 0],
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,8 @@ import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
approveTenant,
|
||||
deleteTenant,
|
||||
fetchAllTenants,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
updateTenant,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
@@ -58,7 +58,7 @@ export function TenantProfilePage() {
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
const [name, setName] = useState("");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage";
|
||||
import { createSchemaField, normalizeSchemaField } from "./tenantSchemaFields";
|
||||
|
||||
describe("TenantSchemaPage schema field helpers", () => {
|
||||
it("creates text fields without varchar maxLength policy", () => {
|
||||
|
||||
@@ -16,81 +16,13 @@ import { Label } from "../../../components/ui/label";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
export type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
export type SchemaField = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: SchemaFieldType;
|
||||
required: boolean;
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function isSchemaFieldType(value: unknown): value is SchemaFieldType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "date" ||
|
||||
value === "float" ||
|
||||
value === "datetime"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSchemaField(field: unknown): SchemaField {
|
||||
const source =
|
||||
typeof field === "object" && field !== null
|
||||
? (field as Record<string, unknown>)
|
||||
: {};
|
||||
const type = isSchemaFieldType(source.type) ? source.type : "text";
|
||||
const isLoginId = Boolean(source.isLoginId);
|
||||
|
||||
return {
|
||||
id: typeof source.id === "string" ? source.id : createFieldId(),
|
||||
key: typeof source.key === "string" ? source.key : "",
|
||||
label: typeof source.label === "string" ? source.label : "",
|
||||
type,
|
||||
required: Boolean(source.required),
|
||||
adminOnly: Boolean(source.adminOnly),
|
||||
validation: typeof source.validation === "string" ? source.validation : "",
|
||||
unsigned: Boolean(source.unsigned),
|
||||
isLoginId,
|
||||
indexed: isLoginId || Boolean(source.indexed),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSchemaField(): SchemaField {
|
||||
return {
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
indexed: false,
|
||||
};
|
||||
}
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import {
|
||||
type SchemaField,
|
||||
createSchemaField,
|
||||
isSchemaFieldType,
|
||||
normalizeSchemaField,
|
||||
} from "./tenantSchemaFields";
|
||||
|
||||
export function TenantSchemaPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
@@ -101,8 +33,9 @@ export function TenantSchemaPage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccess =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, Building2, Plus } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -18,7 +23,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { fetchTenants } from "../../../lib/adminApi";
|
||||
import { fetchAllTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantSubTenantsPage() {
|
||||
@@ -27,7 +32,7 @@ function TenantSubTenantsPage() {
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["sub-tenants", tenantId],
|
||||
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
|
||||
queryFn: () => fetchAllTenants({ parentId: tenantId ?? undefined }),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
@@ -58,10 +63,10 @@ function TenantSubTenantsPage() {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.name", "NAME")}
|
||||
@@ -72,16 +77,13 @@ function TenantSubTenantsPage() {
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.sub.table.action", "ACTION")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subTenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -92,7 +94,11 @@ function TenantSubTenantsPage() {
|
||||
</TableRow>
|
||||
)}
|
||||
{subTenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableRow
|
||||
key={tenant.id}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
<TableCell className="font-semibold">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
@@ -108,16 +114,6 @@ function TenantSubTenantsPage() {
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
|
||||
<ArrowRight size={12} className="ml-1" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
} 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 { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantUsersPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
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 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.name", "NAME")}
|
||||
@@ -137,15 +139,12 @@ function TenantUsersPage() {
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
{t("ui.admin.tenants.members.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-20">
|
||||
<TableCell colSpan={4} className="text-center py-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2
|
||||
className="animate-spin text-muted-foreground"
|
||||
@@ -160,7 +159,7 @@ function TenantUsersPage() {
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -171,7 +170,11 @@ function TenantUsersPage() {
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => navigate(`/users/${user.id}`)}
|
||||
>
|
||||
<TableCell className="font-semibold">
|
||||
{user.name}
|
||||
</TableCell>
|
||||
@@ -198,43 +201,6 @@ function TenantUsersPage() {
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/users/${user.id}`}>
|
||||
<User size={14} className="mr-2" />
|
||||
{t(
|
||||
"ui.admin.tenants.members.view_profile",
|
||||
"상세 정보",
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() =>
|
||||
handleRemoveMember(user.id, user.name)
|
||||
}
|
||||
disabled={removeTenantMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="mr-2" />
|
||||
{t(
|
||||
"ui.admin.tenants.members.remove",
|
||||
"조직에서 제외",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -4,22 +4,30 @@ import {
|
||||
canCreateWorksmobileRow,
|
||||
canOpenWorksmobilePasswordManage,
|
||||
canSelectWorksmobileRow,
|
||||
comparisonFilterOptions,
|
||||
filterVisibleWorksmobileComparisonRows,
|
||||
filterWorksmobileComparisonRows,
|
||||
filterWorksmobileComparisonRowsBySearch,
|
||||
formatWorksmobileOrgDetails,
|
||||
formatWorksmobilePersonName,
|
||||
formatWorksmobileUpdateDetails,
|
||||
getDefaultGroupComparisonFilters,
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
isImmutableWorksmobileAccount,
|
||||
summarizeWorksmobileComparison,
|
||||
userFilterOptions,
|
||||
} from "./TenantWorksmobilePage";
|
||||
} from "./worksmobileComparison";
|
||||
|
||||
describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
it("summarizes comparison rows by status", () => {
|
||||
const summary = summarizeWorksmobileComparison([
|
||||
{ resourceType: "USER", status: "matched" },
|
||||
{ resourceType: "GROUP", status: "needs_update" },
|
||||
{ resourceType: "USER", status: "missing_in_worksmobile" },
|
||||
{ resourceType: "USER", status: "missing_in_baron" },
|
||||
{ resourceType: "USER", status: "missing_external_key" },
|
||||
@@ -27,8 +35,9 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
]);
|
||||
|
||||
expect(summary).toEqual({
|
||||
total: 5,
|
||||
total: 6,
|
||||
matched: 1,
|
||||
needsUpdate: 1,
|
||||
missingInWorksmobile: 1,
|
||||
missingInBaron: 2,
|
||||
missingExternalKey: 1,
|
||||
@@ -46,6 +55,9 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
|
||||
"ex_key 없음",
|
||||
);
|
||||
expect(getWorksmobileComparisonStatusLabel("needs_update")).toBe(
|
||||
"업데이트 필요",
|
||||
);
|
||||
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
|
||||
"unknown_status",
|
||||
);
|
||||
@@ -143,6 +155,42 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides protected WORKS member accounts from comparison lists", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "su-@samaneng.com",
|
||||
worksmobileId: "works-su",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronEmail: "CYHAN1@HANMACENG.CO.KR",
|
||||
baronId: "baron-cyhan1",
|
||||
worksmobileEmail: "cyhan1@hanmaceng.co.kr",
|
||||
worksmobileId: "works-cyhan1",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "normal@samaneng.com",
|
||||
worksmobileId: "works-normal",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "su-@samaneng.com",
|
||||
worksmobileId: "works-group",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterVisibleWorksmobileComparisonRows(rows)).toEqual([
|
||||
rows[2],
|
||||
rows[3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps row selection keys separate from Baron action ids", () => {
|
||||
const rows = [
|
||||
{
|
||||
@@ -231,7 +279,8 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
|
||||
).toEqual([rows[0], rows[1], rows[3]]);
|
||||
expect(filterWorksmobileComparisonRows(rows, [])).toEqual(rows);
|
||||
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
|
||||
expect(filterWorksmobileComparisonRows(rows, [])).toEqual([]);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, [
|
||||
"baron_only",
|
||||
@@ -239,16 +288,198 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
"matched",
|
||||
]),
|
||||
).toEqual(rows);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(
|
||||
rows,
|
||||
["baron_only", "works_only", "matched"],
|
||||
true,
|
||||
),
|
||||
).toEqual([rows[0], rows[2], rows[3]]);
|
||||
});
|
||||
|
||||
it("narrows works-only rows to missing external key rows from the detail filter", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-only",
|
||||
baronName: "Baron only",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
worksmobileName: "WORKS only",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "missing-external-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "matched",
|
||||
baronId: "matched",
|
||||
worksmobileId: "works-matched",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, ["works_only"], false),
|
||||
).toEqual([rows[1], rows[2]]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["works_only"], true)).toEqual(
|
||||
[rows[2]],
|
||||
);
|
||||
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["baron_only"], true)).toEqual(
|
||||
[rows[0]],
|
||||
);
|
||||
});
|
||||
|
||||
it("filters comparison rows by names and identifiers in real time", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronId: "baron-user-uuid",
|
||||
baronName: "홍길동",
|
||||
worksmobileName: "Hong Gildong",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-org-uuid",
|
||||
worksmobileName: "기술연구소",
|
||||
worksmobileParentName: "한맥가족",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-org-uuid",
|
||||
baronSlug: "baron-group-design",
|
||||
baronName: "디자인팀",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "")).toEqual(rows);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "홍길동")).toEqual([
|
||||
rows[0],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "WORKS-ORG")).toEqual([
|
||||
rows[1],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "design")).toEqual([
|
||||
rows[2],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "없음")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only selected missing-external-key WORKS orgunit ids for delete", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-missing-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-user-missing-key",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getWorksmobileSelectedMissingExternalKeyOrgUnitIds(rows, [
|
||||
getWorksmobileRowSelectionKey(rows[0]),
|
||||
getWorksmobileRowSelectionKey(rows[1]),
|
||||
getWorksmobileRowSelectionKey(rows[2]),
|
||||
]),
|
||||
).toEqual(["works-missing-key"]);
|
||||
});
|
||||
|
||||
it("returns selected WORKS-only orgunit ids for Baron SSOT cleanup", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-missing-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
externalKey: "legacy-external-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "matched",
|
||||
baronId: "baron-matched",
|
||||
worksmobileId: "works-matched",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds(
|
||||
rows,
|
||||
rows.map(getWorksmobileRowSelectionKey),
|
||||
),
|
||||
).toEqual(["works-missing-key", "works-only"]);
|
||||
});
|
||||
|
||||
it("orders user comparison filter options from Baron-only first", () => {
|
||||
expect(userFilterOptions.map((option) => option.value)).toEqual([
|
||||
"baron_only",
|
||||
"needs_update",
|
||||
"works_only",
|
||||
"matched",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps all organization/group comparison filter labels available", () => {
|
||||
expect(comparisonFilterOptions).toEqual([
|
||||
{ value: "baron_only", label: "바론에만 있음" },
|
||||
{ value: "needs_update", label: "업데이트 필요" },
|
||||
{ value: "works_only", label: "웍스에만 있음" },
|
||||
{ value: "matched", label: "양쪽 다 있음" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows update-needed group rows by default", () => {
|
||||
const rows = [
|
||||
{ resourceType: "GROUP", status: "needs_update", baronId: "org-1" },
|
||||
{ resourceType: "GROUP", status: "matched", baronId: "org-2" },
|
||||
];
|
||||
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, getDefaultGroupComparisonFilters()),
|
||||
).toEqual([rows[0]]);
|
||||
});
|
||||
|
||||
it("formats update details for changed organization rows", () => {
|
||||
expect(
|
||||
formatWorksmobileUpdateDetails({
|
||||
resourceType: "GROUP",
|
||||
status: "needs_update",
|
||||
baronId: "818c856b-9545-442f-b827-d1c569f200b0",
|
||||
baronName: "삼안기술개발센터(조직도용)",
|
||||
worksmobileName: "기술개발센터(조직도용)",
|
||||
baronParentId: "9caf62e1-297d-4e8f-870b-61780998bbeb",
|
||||
baronParentWorksmobileId: "works-saman",
|
||||
baronParentWorksmobileName: "삼안",
|
||||
worksmobileParentId: "works-other",
|
||||
worksmobileParentName: "다른 상위",
|
||||
}),
|
||||
).toEqual([
|
||||
"이름: 기술개발센터(조직도용) -> 삼안기술개발센터(조직도용)",
|
||||
"상위: 다른 상위 -> 삼안",
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats WORKS account name with level on one line", () => {
|
||||
expect(
|
||||
formatWorksmobilePersonName({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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", () => {
|
||||
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([
|
||||
@@ -80,6 +80,8 @@ describe("tenantCsvImport", () => {
|
||||
slug: "hanmac-tech",
|
||||
memo: "Memo",
|
||||
emailDomain: "hanmac-tech.example.com",
|
||||
visibility: "internal",
|
||||
orgUnitType: "센터",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -109,15 +111,18 @@ describe("tenantCsvImport", () => {
|
||||
|
||||
it("serializes selected matches by filling tenant_id before upload", () => {
|
||||
const rows = parseTenantCSV(
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\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 csv = serializeTenantImportCSV(preview, {
|
||||
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(
|
||||
"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(
|
||||
"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(
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
|
||||
|
||||
@@ -10,6 +10,8 @@ export type TenantCSVRow = {
|
||||
slug: string;
|
||||
memo: string;
|
||||
emailDomain: string;
|
||||
visibility: string;
|
||||
orgUnitType: string;
|
||||
};
|
||||
|
||||
export type TenantCSVParseOptions = {
|
||||
@@ -76,6 +78,8 @@ const importHeaders = [
|
||||
"slug",
|
||||
"memo",
|
||||
"email_domain",
|
||||
"visibility",
|
||||
"org_unit_type",
|
||||
];
|
||||
|
||||
const headerAliases: Record<string, TenantCSVSourceKey> = {
|
||||
@@ -102,6 +106,16 @@ const headerAliases: Record<string, TenantCSVSourceKey> = {
|
||||
email_domain: "emailDomain",
|
||||
domain: "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(
|
||||
@@ -159,6 +173,8 @@ export function parseTenantCSV(
|
||||
slug,
|
||||
memo: value("memo"),
|
||||
emailDomain: value("emailDomain"),
|
||||
visibility: value("visibility"),
|
||||
orgUnitType: value("orgUnitType"),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -287,6 +303,8 @@ export function serializeTenantImportCSV(
|
||||
slug,
|
||||
preview.row.memo,
|
||||
preview.row.emailDomain,
|
||||
preview.row.visibility,
|
||||
preview.row.orgUnitType,
|
||||
]);
|
||||
}
|
||||
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 { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -21,14 +22,14 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
type TenantSummary,
|
||||
fetchAllTenants,
|
||||
fetchGroups,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export default function GlobalUserGroupListPage() {
|
||||
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
|
||||
queryKey: ["admin-tenants"],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
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 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">그룹명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CornerDownRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
LayoutDashboard,
|
||||
@@ -72,7 +73,8 @@ import {
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
createUser,
|
||||
fetchTenants,
|
||||
exportTenantsCSV,
|
||||
fetchAllTenants,
|
||||
fetchUsers,
|
||||
updateTenant,
|
||||
updateUser,
|
||||
@@ -422,6 +424,24 @@ function TenantUserGroupsTab() {
|
||||
const [isAddExistingOpen, setIsAddExistingOpen] = useState(false);
|
||||
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
|
||||
const {
|
||||
data: allTenantsData,
|
||||
@@ -429,7 +449,7 @@ function TenantUserGroupsTab() {
|
||||
refetch: refetchTree,
|
||||
} = useQuery({
|
||||
queryKey: ["tenants-full-tree-v2"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
const { currentBase, subTree } = useMemo(() => {
|
||||
@@ -611,6 +631,16 @@ function TenantUserGroupsTab() {
|
||||
<UserPlus size={16} className="mr-2" />
|
||||
{t("ui.admin.users.list.add", "멤버 추가")}
|
||||
</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>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AxiosError } from "axios";
|
||||
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -42,9 +43,9 @@ import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
addGroupMember,
|
||||
assignGroupRole,
|
||||
fetchAllTenants,
|
||||
fetchGroup,
|
||||
fetchGroupRoles,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
removeGroupMember,
|
||||
removeGroupRole,
|
||||
@@ -91,7 +92,7 @@ export function UserGroupDetailPage() {
|
||||
// Fetch all tenants for role assignment
|
||||
const { data: tenantList } = useQuery({
|
||||
queryKey: ["admin-tenants"],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
enabled: isAddRoleOpen,
|
||||
});
|
||||
|
||||
@@ -193,7 +194,14 @@ export function UserGroupDetailPage() {
|
||||
"Not found"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["user-group-detail", id],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("ui.common.retry", "다시 시도")}
|
||||
</Button>
|
||||
<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 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">
|
||||
{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 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
|
||||
|
||||
@@ -42,13 +42,13 @@ import {
|
||||
type UserAppointment,
|
||||
type UserCreateRequest,
|
||||
type UserCreateResponse,
|
||||
createTenant,
|
||||
createUser,
|
||||
fetchAllTenants,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
type OrgChartTenantSelection,
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
@@ -56,9 +56,10 @@ import {
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
|
||||
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
||||
type UserType = "hanmac" | "external" | "personal";
|
||||
type UserCategory = "hanmac" | "external" | "personal";
|
||||
|
||||
type PickerTarget = { kind: "appointment"; index: number };
|
||||
|
||||
@@ -66,6 +67,13 @@ type AppointmentDraft = UserAppointment & {
|
||||
draftId: string;
|
||||
};
|
||||
|
||||
type AdminFrontTestHooks = {
|
||||
selectUserAppointmentTenant?: (
|
||||
selection: OrgChartTenantSelection,
|
||||
index?: number,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
function createDraftId() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
|
||||
}
|
||||
@@ -114,8 +122,8 @@ function UserCreatePage() {
|
||||
>(null);
|
||||
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
||||
const [autoPassword, setAutoPassword] = React.useState(true);
|
||||
const [isHanmacFamily, setIsHanmacFamily] = React.useState(true);
|
||||
const [userType, setUserType] = React.useState<UserType>("hanmac");
|
||||
const [userCategory, setUserCategory] =
|
||||
React.useState<UserCategory>("hanmac");
|
||||
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
||||
AppointmentDraft[]
|
||||
>([]);
|
||||
@@ -125,8 +133,8 @@ function UserCreatePage() {
|
||||
const [isResolvingTenant, setIsResolvingTenant] = React.useState(false);
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
@@ -152,6 +160,7 @@ function UserCreatePage() {
|
||||
grade: "",
|
||||
position: "",
|
||||
jobTitle: "",
|
||||
role: "user",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
@@ -177,17 +186,11 @@ function UserCreatePage() {
|
||||
|
||||
const selectedTenantSlug = watch("tenantSlug");
|
||||
const personalTenant = React.useMemo(
|
||||
() =>
|
||||
tenants.find(
|
||||
(tenant) =>
|
||||
tenant.slug === "personal" ||
|
||||
(tenant.type === "PERSONAL" &&
|
||||
tenant.name.toLowerCase() === "personal"),
|
||||
),
|
||||
() => resolvePersonalTenant(tenants),
|
||||
[tenants],
|
||||
);
|
||||
const selectedTenant =
|
||||
userType !== "external"
|
||||
userCategory !== "external"
|
||||
? undefined
|
||||
: nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug);
|
||||
|
||||
@@ -231,7 +234,7 @@ function UserCreatePage() {
|
||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
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);
|
||||
}, [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 = () => {
|
||||
setAdditionalAppointments((current) => [
|
||||
...current,
|
||||
@@ -310,25 +328,16 @@ function UserCreatePage() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleUserTypeChange = (value: string) => {
|
||||
const nextType = value as UserType;
|
||||
setUserType(nextType);
|
||||
setIsHanmacFamily(nextType === "hanmac");
|
||||
if (nextType !== "hanmac") {
|
||||
const handleUserCategoryChange = (value: string) => {
|
||||
const nextCategory = value as UserCategory;
|
||||
setUserCategory(nextCategory);
|
||||
if (nextCategory !== "hanmac") {
|
||||
setAdditionalAppointments([]);
|
||||
}
|
||||
};
|
||||
|
||||
const ensurePersonalTenant = async () => {
|
||||
if (personalTenant) return personalTenant;
|
||||
const tenant = await createTenant({
|
||||
name: "Personal",
|
||||
slug: "personal",
|
||||
type: "PERSONAL",
|
||||
status: "active",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||
return tenant;
|
||||
return personalTenant;
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -355,10 +364,13 @@ function UserCreatePage() {
|
||||
setGeneratedPassword(null);
|
||||
setCreatedEmail(null);
|
||||
|
||||
const {
|
||||
hanmacFamily: _hanmacFamily,
|
||||
userType: _userType,
|
||||
...formMetadata
|
||||
} = data.metadata ?? {};
|
||||
const metadata: Record<string, unknown> = {
|
||||
...(data.metadata ?? {}),
|
||||
hanmacFamily: userType === "hanmac" && isHanmacFamily,
|
||||
userType,
|
||||
...formMetadata,
|
||||
};
|
||||
|
||||
const payload: UserCreateRequest = {
|
||||
@@ -366,10 +378,11 @@ function UserCreatePage() {
|
||||
password: data.password,
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
role: data.role,
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (userType === "external") {
|
||||
if (userCategory === "external") {
|
||||
if (!data.tenantSlug) {
|
||||
setError(
|
||||
t(
|
||||
@@ -386,7 +399,7 @@ function UserCreatePage() {
|
||||
payload.jobTitle = data.jobTitle;
|
||||
}
|
||||
|
||||
if (userType === "personal") {
|
||||
if (userCategory === "personal") {
|
||||
try {
|
||||
const tenant = await ensurePersonalTenant();
|
||||
payload.tenantSlug = tenant.slug;
|
||||
@@ -405,7 +418,7 @@ function UserCreatePage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (userType === "hanmac") {
|
||||
if (userCategory === "hanmac") {
|
||||
const appointments = additionalAppointments
|
||||
.filter((appointment) => appointment.tenantId)
|
||||
.map((appointment) => ({
|
||||
@@ -644,7 +657,32 @@ function UserCreatePage() {
|
||||
</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">
|
||||
<TabsTrigger
|
||||
value="hanmac"
|
||||
@@ -761,6 +799,7 @@ function UserCreatePage() {
|
||||
})
|
||||
}
|
||||
disabled={isResolvingTenant}
|
||||
data-testid={`appointment-tenant-picker-${index}`}
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
{appointment.tenantName || "테넌트 선택"}
|
||||
@@ -972,6 +1011,7 @@ function UserCreatePage() {
|
||||
title={t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
|
||||
src={pickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
data-testid="appointment-tenant-picker-frame"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -44,6 +44,13 @@ import {
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../components/ui/select";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Tabs,
|
||||
@@ -56,18 +63,18 @@ import {
|
||||
type TenantSummary,
|
||||
type UserAppointment,
|
||||
type UserUpdateRequest,
|
||||
createTenant,
|
||||
deleteUser,
|
||||
fetchAllTenants,
|
||||
fetchMe,
|
||||
fetchPasswordPolicy,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
fetchUser,
|
||||
fetchUserRpHistory,
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../lib/roles";
|
||||
import { generateSecurePassword } from "../../lib/utils";
|
||||
import {
|
||||
type OrgChartTenantSelection,
|
||||
@@ -78,11 +85,17 @@ import {
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
import {
|
||||
normalizeUserStatusValue,
|
||||
userStatusLabel,
|
||||
userStatusValues,
|
||||
} from "./userStatus";
|
||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
|
||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||
};
|
||||
type UserType = "hanmac" | "external" | "personal";
|
||||
type UserCategory = "hanmac" | "external" | "personal";
|
||||
|
||||
type PasswordResetMode = "generated" | "manual";
|
||||
type PickerTarget = { kind: "appointment"; index: number };
|
||||
@@ -318,8 +331,8 @@ function UserDetailPage() {
|
||||
const [passwordResetError, setPasswordResetError] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isHanmacFamily, setIsHanmacFamily] = React.useState(false);
|
||||
const [userType, setUserType] = React.useState<UserType>("external");
|
||||
const [userCategory, setUserCategory] =
|
||||
React.useState<UserCategory>("external");
|
||||
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
||||
AppointmentDraft[]
|
||||
>([]);
|
||||
@@ -346,8 +359,8 @@ function UserDetailPage() {
|
||||
});
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
const tenants = React.useMemo(
|
||||
() => tenantsData?.items ?? [],
|
||||
@@ -387,8 +400,9 @@ function UserDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
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 watchedStatus = watch("status");
|
||||
|
||||
@@ -465,20 +479,14 @@ function UserDetailPage() {
|
||||
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
||||
}, [tenants]);
|
||||
const personalTenant = React.useMemo(
|
||||
() =>
|
||||
tenants.find(
|
||||
(tenant) =>
|
||||
tenant.slug === "personal" ||
|
||||
(tenant.type === "PERSONAL" &&
|
||||
tenant.name.toLowerCase() === "personal"),
|
||||
),
|
||||
() => resolvePersonalTenant(tenants),
|
||||
[tenants],
|
||||
);
|
||||
|
||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
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 nextType = value as UserType;
|
||||
setUserType(nextType);
|
||||
setIsHanmacFamily(nextType === "hanmac");
|
||||
if (nextType !== "hanmac") {
|
||||
const handleUserCategoryChange = (value: string) => {
|
||||
const nextCategory = value as UserCategory;
|
||||
setUserCategory(nextCategory);
|
||||
if (nextCategory !== "hanmac") {
|
||||
setAdditionalAppointments([]);
|
||||
}
|
||||
};
|
||||
|
||||
const ensurePersonalTenant = async () => {
|
||||
if (personalTenant) return personalTenant;
|
||||
const tenant = await createTenant({
|
||||
name: "Personal",
|
||||
slug: "personal",
|
||||
type: "PERSONAL",
|
||||
status: "active",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||
return tenant;
|
||||
return personalTenant;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -598,8 +597,8 @@ function UserDetailPage() {
|
||||
name:
|
||||
typeof metadata.primaryTenantName === "string"
|
||||
? metadata.primaryTenantName
|
||||
: user.tenant?.name || user.companyCode || "",
|
||||
slug: user.companyCode,
|
||||
: user.tenant?.name || user.tenantSlug || "",
|
||||
slug: user.tenantSlug,
|
||||
}
|
||||
: null;
|
||||
const fallbackAppointment =
|
||||
@@ -616,9 +615,9 @@ function UserDetailPage() {
|
||||
name: user.name,
|
||||
phone: user.phone || "",
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
status: normalizeUserStatusValue(user.status),
|
||||
tenantSlug:
|
||||
user.companyCode ||
|
||||
user.tenantSlug ||
|
||||
user.joinedTenants?.find(
|
||||
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||
)?.slug ||
|
||||
@@ -638,14 +637,17 @@ function UserDetailPage() {
|
||||
tenants,
|
||||
hanmacFamilyTenantId,
|
||||
);
|
||||
const resolvedUserType =
|
||||
metadata.userType === "personal" || user.companyCode === "personal"
|
||||
? "personal"
|
||||
: isUserHanmacFamily
|
||||
? "hanmac"
|
||||
: "external";
|
||||
setUserType(resolvedUserType);
|
||||
setIsHanmacFamily(resolvedUserType === "hanmac");
|
||||
const isPersonalUser =
|
||||
user.tenantSlug === personalTenant.slug ||
|
||||
user.tenant?.id === personalTenant.id ||
|
||||
user.tenant?.slug === personalTenant.slug ||
|
||||
metadata.personalTenantId === personalTenant.id;
|
||||
const resolvedUserCategory = isPersonalUser
|
||||
? "personal"
|
||||
: isUserHanmacFamily
|
||||
? "hanmac"
|
||||
: "external";
|
||||
setUserCategory(resolvedUserCategory);
|
||||
const familyFallbackTenants = [
|
||||
...(user.joinedTenants ?? []),
|
||||
...(user.tenant ? [user.tenant] : []),
|
||||
@@ -696,7 +698,7 @@ function UserDetailPage() {
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}, [hanmacFamilyTenantId, tenants, user, reset]);
|
||||
}, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
||||
@@ -737,20 +739,22 @@ function UserDetailPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
hanmacFamily: _hanmacFamily,
|
||||
userType: _userType,
|
||||
...safeMetadata
|
||||
} = cleanMetadata;
|
||||
const metadata: Record<string, unknown> = {
|
||||
...cleanMetadata,
|
||||
hanmacFamily: userType === "hanmac" && isHanmacFamily,
|
||||
userType,
|
||||
...safeMetadata,
|
||||
};
|
||||
|
||||
const profileData = { ...data };
|
||||
profileData.role = undefined;
|
||||
const payload: UserUpdateRequest = {
|
||||
...profileData,
|
||||
...data,
|
||||
metadata,
|
||||
};
|
||||
payload.role = undefined;
|
||||
|
||||
if (userType === "personal") {
|
||||
if (userCategory === "personal") {
|
||||
try {
|
||||
const tenant = await ensurePersonalTenant();
|
||||
payload.tenantSlug = tenant.slug;
|
||||
@@ -768,7 +772,7 @@ function UserDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (userType === "hanmac") {
|
||||
if (userCategory === "hanmac") {
|
||||
const appointments = additionalAppointments
|
||||
.filter((appointment) => appointment.tenantId)
|
||||
.map((appointment) => ({
|
||||
@@ -906,7 +910,7 @@ function UserDetailPage() {
|
||||
>
|
||||
<Building2 size={12} className="mr-1.5" />
|
||||
{user.tenant?.name ||
|
||||
user.companyCode ||
|
||||
user.tenantSlug ||
|
||||
user.joinedTenants?.find(
|
||||
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||
)?.name ||
|
||||
@@ -1052,27 +1056,31 @@ function UserDetailPage() {
|
||||
>
|
||||
{t("ui.admin.users.detail.form.status", "상태")}
|
||||
</Label>
|
||||
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
|
||||
<Switch
|
||||
id="status"
|
||||
checked={watchedStatus === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue("status", checked ? "active" : "inactive")
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
`ui.common.status.${watchedStatus}`,
|
||||
watchedStatus || "inactive",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={normalizeUserStatusValue(watchedStatus || "")}
|
||||
onValueChange={(value) =>
|
||||
setValue("status", normalizeUserStatusValue(value), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="status" className="h-11 shadow-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={userType}
|
||||
onValueChange={handleUserTypeChange}
|
||||
value={userCategory}
|
||||
onValueChange={handleUserCategoryChange}
|
||||
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">
|
||||
@@ -1097,7 +1105,7 @@ function UserDetailPage() {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{userType === "external" && (
|
||||
{userCategory === "external" && (
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
@@ -1141,7 +1149,7 @@ function UserDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userType === "hanmac" && (
|
||||
{userCategory === "hanmac" && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
@@ -1314,7 +1322,7 @@ function UserDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userType === "personal" && (
|
||||
{userCategory === "personal" && (
|
||||
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
||||
{personalTenant
|
||||
? `Personal (${personalTenant.slug})`
|
||||
@@ -1322,7 +1330,7 @@ function UserDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userType === "external" && (
|
||||
{userCategory === "external" && (
|
||||
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,8 +20,8 @@ import {
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
bulkUpdateUsers,
|
||||
fetchAllTenants,
|
||||
fetchGroups,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
@@ -52,8 +52,8 @@ export function UserBulkMoveGroupModal({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
enabled: open,
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
@@ -18,13 +18,14 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { DropdownMenuItem } from "../../../components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import {
|
||||
type BulkUserItem,
|
||||
type BulkUserResult,
|
||||
bulkCreateUsers,
|
||||
createTenant,
|
||||
fetchTenants,
|
||||
fetchAllTenants,
|
||||
fetchUsers,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
@@ -42,6 +43,9 @@ import {
|
||||
|
||||
interface UserBulkUploadModalProps {
|
||||
onSuccess?: () => void;
|
||||
variant?: "button" | "dropdown" | "custom";
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function buildUserTenantPreviewRows(
|
||||
@@ -66,6 +70,8 @@ function buildUserTenantPreviewRows(
|
||||
slug: user.tenantImport?.slug || user.tenantSlug || key,
|
||||
memo: user.tenantImport?.memo ?? "",
|
||||
emailDomain: user.tenantImport?.emailDomain ?? "",
|
||||
visibility: "public",
|
||||
orgUnitType: "node",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,8 +124,34 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
export const downloadUserTemplate = () => {
|
||||
const headers =
|
||||
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
||||
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 [parsing, setParsing] = React.useState(false);
|
||||
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
|
||||
@@ -137,7 +169,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenants", "user-bulk-import"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
@@ -321,309 +353,334 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
(preview) => preview?.status === "blockingError",
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) reset();
|
||||
}}
|
||||
>
|
||||
const triggerProps = {
|
||||
disabled: mutation.isPending,
|
||||
"data-testid": "bulk-import-btn",
|
||||
};
|
||||
|
||||
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>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
data-testid="bulk-import-btn"
|
||||
>
|
||||
<Button variant="outline" className="gap-2" {...triggerProps}>
|
||||
<Upload size={16} />
|
||||
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
||||
</Button>
|
||||
</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 ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="secondary"
|
||||
>
|
||||
{file
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
{variant === "dropdown" ? triggerNode : null}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) reset();
|
||||
}}
|
||||
>
|
||||
{variant !== "dropdown" && variant !== "custom" && triggerNode}
|
||||
<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>
|
||||
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FileText className="text-primary" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({(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>
|
||||
)}
|
||||
{!results ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
{t(
|
||||
"ui.admin.users.bulk.download_template",
|
||||
"템플릿 다운로드",
|
||||
)}
|
||||
</Button>
|
||||
<Button asChild variant="secondary" className="cursor-pointer">
|
||||
<label>
|
||||
{file
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
<input
|
||||
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>
|
||||
)}
|
||||
|
||||
{tenantPreviewRows.length > 0 && (
|
||||
<div
|
||||
className="rounded-md border p-3 text-sm"
|
||||
data-testid="user-import-tenant-resolution"
|
||||
>
|
||||
<div className="mb-2 font-medium">
|
||||
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FileText className="text-primary" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({(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 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}
|
||||
)}
|
||||
|
||||
{tenantPreviewRows.length > 0 && (
|
||||
<div
|
||||
className="rounded-md border p-3 text-sm"
|
||||
data-testid="user-import-tenant-resolution"
|
||||
>
|
||||
<div className="mb-2 font-medium">
|
||||
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
|
||||
</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 className="space-y-2">
|
||||
<select
|
||||
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"
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={
|
||||
selectedTenantCreateSlugs[
|
||||
preview.row.rowNumber
|
||||
] ?? ""
|
||||
selectedTenantMatches[preview.row.rowNumber] ??
|
||||
"__create__"
|
||||
}
|
||||
onChange={(event) =>
|
||||
setSelectedTenantCreateSlugs((prev) => ({
|
||||
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={
|
||||
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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</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>
|
||||
</Dialog>
|
||||
|
||||
<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>
|
||||
</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", () => {
|
||||
expect(
|
||||
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
|
||||
@@ -34,12 +44,22 @@ describe("orgChartPicker", () => {
|
||||
},
|
||||
),
|
||||
).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(
|
||||
"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",
|
||||
);
|
||||
});
|
||||
@@ -169,4 +189,66 @@ describe("orgChartPicker", () => {
|
||||
),
|
||||
).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 = {
|
||||
id?: string;
|
||||
tenantId?: string;
|
||||
slug?: string;
|
||||
tenantSlug?: string;
|
||||
type?: string;
|
||||
parentId?: string | null;
|
||||
name?: string;
|
||||
tenantName?: string;
|
||||
};
|
||||
|
||||
export type HanmacFamilyUserTarget = {
|
||||
@@ -31,10 +34,12 @@ type OrgChartPickerMessage = {
|
||||
};
|
||||
|
||||
type OrgChartTenantPickerOptions = {
|
||||
includeInternal?: boolean;
|
||||
tenantId?: string;
|
||||
};
|
||||
|
||||
type OrgChartLoginOptions = {
|
||||
includeInternal?: boolean;
|
||||
returnTo?: string;
|
||||
};
|
||||
|
||||
@@ -120,20 +125,43 @@ export function isHanmacFamilyUser<T extends TenantFilterTarget>(
|
||||
tenants: T[],
|
||||
hanmacFamilyTenantId?: string,
|
||||
) {
|
||||
const metadata = user.metadata ?? {};
|
||||
if (metadata.hanmacFamily === true || metadata.userType === "hanmac") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const metadataAppointments = Array.isArray(
|
||||
user.metadata?.additionalAppointments,
|
||||
)
|
||||
? 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(
|
||||
tenants
|
||||
.filter((tenant) => tenant.slug?.trim())
|
||||
.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 = [
|
||||
user.tenant,
|
||||
...(user.joinedTenants ?? []),
|
||||
tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""),
|
||||
...metadataAppointments,
|
||||
...metadataAppointments.map((appointment) =>
|
||||
tenantById.get(appointment.id ?? ""),
|
||||
),
|
||||
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
|
||||
];
|
||||
|
||||
@@ -178,6 +206,9 @@ export function buildOrgChartTenantPickerUrl(
|
||||
if (tenantId) {
|
||||
params.set("tenantId", tenantId);
|
||||
}
|
||||
if (options.includeInternal) {
|
||||
params.set("includeInternal", "true");
|
||||
}
|
||||
|
||||
return `${normalizedBase}/embed/picker?${params.toString()}`;
|
||||
}
|
||||
@@ -186,16 +217,25 @@ export function buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
baseUrl?: string,
|
||||
options: OrgChartTenantPickerOptions = {},
|
||||
) {
|
||||
const pickerUrl = buildOrgChartTenantPickerUrl("", options);
|
||||
const pickerUrl = buildOrgChartTenantPickerUrl("", {
|
||||
includeInternal: true,
|
||||
...options,
|
||||
});
|
||||
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
|
||||
}
|
||||
|
||||
export function buildAuthenticatedOrgChartUrl(
|
||||
baseUrl?: string,
|
||||
options: OrgChartLoginOptions = {},
|
||||
options: OrgChartLoginOptions = { includeInternal: true },
|
||||
) {
|
||||
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({
|
||||
auto: "1",
|
||||
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 = [
|
||||
"active",
|
||||
"inactive",
|
||||
"temporary_leave",
|
||||
"suspended",
|
||||
"leave_of_absence",
|
||||
"preboarding",
|
||||
"baron_guest",
|
||||
"extended_leave",
|
||||
"archived",
|
||||
] as const;
|
||||
|
||||
export type UserStatusValue = (typeof userStatusValues)[number];
|
||||
|
||||
export function userStatusLabel(status: string) {
|
||||
return t(`ui.common.status.${status}`, status);
|
||||
export function normalizeUserStatusValue(status?: string | null): UserStatusValue {
|
||||
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 components;
|
||||
@tailwind utilities;
|
||||
@@ -24,37 +26,7 @@
|
||||
--input: 215 25% 24%;
|
||||
--ring: 209 79% 52%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.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(
|
||||
--app-background-image: radial-gradient(
|
||||
circle at 10% 18%,
|
||||
rgba(54, 211, 153, 0.16),
|
||||
transparent 28%
|
||||
@@ -70,14 +42,4 @@
|
||||
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 { userManager } from "./auth";
|
||||
|
||||
export type AuditLog = {
|
||||
event_id: string;
|
||||
@@ -51,6 +53,9 @@ export type TenantListResponse = {
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
cursor?: string;
|
||||
nextCursor?: string;
|
||||
next_cursor?: string;
|
||||
};
|
||||
|
||||
export type TenantUpdateRequest = {
|
||||
@@ -124,6 +129,7 @@ export type RPUsageDailyResponse = {
|
||||
|
||||
export type AdminOverviewStats = {
|
||||
totalTenants: number;
|
||||
totalUsers: number;
|
||||
oidcClients: number;
|
||||
auditEvents24h: number;
|
||||
};
|
||||
@@ -144,6 +150,60 @@ export type UserProjectionActionResult = {
|
||||
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) {
|
||||
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||
params: { limit, cursor },
|
||||
@@ -156,6 +216,28 @@ export async function fetchAdminOverviewStats() {
|
||||
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() {
|
||||
const { data } = await apiClient.get<UserProjectionStatus>(
|
||||
"/v1/admin/projections/users",
|
||||
@@ -195,16 +277,73 @@ export async function fetchAdminRPUsageDaily({
|
||||
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>(
|
||||
"/v1/admin/tenants",
|
||||
{
|
||||
params: { limit, offset, parentId },
|
||||
params: { limit, offset, parentId, cursor },
|
||||
},
|
||||
);
|
||||
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) {
|
||||
const { data } = await apiClient.get<TenantSummary>(
|
||||
`/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", {
|
||||
params: { includeIds },
|
||||
params: { includeIds, parentId: parentId || undefined },
|
||||
responseType: "blob",
|
||||
});
|
||||
const dispositionHeader = response.headers["content-disposition"];
|
||||
@@ -440,6 +579,10 @@ export type ApiKeyCreateResponse = {
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
export type ApiKeyUpdateScopesRequest = {
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
export async function fetchApiKeys(limit = 50, offset = 0) {
|
||||
const { data } = await apiClient.get<ApiKeyListResponse>(
|
||||
"/v1/admin/api-keys",
|
||||
@@ -458,6 +601,24 @@ export async function createApiKey(payload: ApiKeyCreateRequest) {
|
||||
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) {
|
||||
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
||||
}
|
||||
@@ -628,11 +789,14 @@ export type WorksmobileOverview = {
|
||||
export type WorksmobileComparisonItem = {
|
||||
resourceType: string;
|
||||
baronId?: string;
|
||||
baronSlug?: string;
|
||||
baronName?: string;
|
||||
baronEmail?: string;
|
||||
baronPrimaryOrgId?: string;
|
||||
baronPrimaryOrgSlug?: string;
|
||||
baronPrimaryOrgName?: string;
|
||||
baronParentId?: string;
|
||||
baronParentSlug?: string;
|
||||
baronParentName?: string;
|
||||
worksmobileId?: string;
|
||||
externalKey?: string;
|
||||
@@ -648,8 +812,13 @@ export type WorksmobileComparisonItem = {
|
||||
worksmobilePrimaryOrgPositionId?: string;
|
||||
worksmobilePrimaryOrgPositionName?: string;
|
||||
worksmobilePrimaryOrgIsManager?: boolean;
|
||||
baronParentWorksmobileId?: string;
|
||||
baronParentWorksmobileName?: string;
|
||||
baronParentWorksmobileEmail?: string;
|
||||
worksmobileParentId?: string;
|
||||
worksmobileParentName?: string;
|
||||
worksmobileParentEmail?: string;
|
||||
worksmobileParentExternalKey?: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
@@ -678,17 +847,9 @@ export async function fetchUser(userId: string) {
|
||||
}
|
||||
|
||||
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>(
|
||||
"/v1/admin/users",
|
||||
requestPayload,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -714,16 +875,9 @@ export async function exportUsersCSV(
|
||||
}
|
||||
|
||||
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>(
|
||||
"/v1/admin/users/bulk",
|
||||
{ users: mappedUsers },
|
||||
{ users },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -778,7 +932,17 @@ export async function enqueueWorksmobileOrgUnitSync(
|
||||
orgUnitId: string,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
@@ -810,13 +974,7 @@ export async function bulkUpdateUsers(payload: {
|
||||
grade?: string;
|
||||
jobTitle?: string;
|
||||
}) {
|
||||
const requestPayload: typeof payload & { companyCode?: string } = {
|
||||
...payload,
|
||||
};
|
||||
if (payload.tenantSlug !== undefined) {
|
||||
requestPayload.companyCode = payload.tenantSlug;
|
||||
}
|
||||
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
|
||||
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -828,16 +986,9 @@ export async function bulkDeleteUsers(userIds: string[]) {
|
||||
}
|
||||
|
||||
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>(
|
||||
`/v1/admin/users/${userId}`,
|
||||
requestPayload,
|
||||
payload,
|
||||
);
|
||||
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