forked from baron/baron-sso
Compare commits
316 Commits
stage/CDC
...
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 | |||
| d4c48da426 | |||
| 5e649c279f | |||
| 3063450ee0 | |||
| d3853fac2a | |||
| a98721e4f7 | |||
| 9ea7db8b3c | |||
| 7a798d8466 | |||
| 2bf0248e95 | |||
| 132cdf3eda | |||
| 360b5a6f2a | |||
| 46b661cff0 | |||
| 843b4100ad | |||
| 9a64a16cb9 | |||
| f46a7cc088 | |||
| 636da587e3 | |||
| 8307f65f6a | |||
| e78b2bea50 | |||
| 59cb482219 | |||
| 888863094d | |||
| 94db1dab08 | |||
| 5ee9a46663 | |||
| 7a9dff372b | |||
| 819ac00040 | |||
| 8bf8520d62 | |||
| 262c988226 | |||
| ef286330a2 | |||
| ab66f13afd | |||
| 074c3e30d1 | |||
| f6cf261fd5 | |||
| 9e45313b5d | |||
| 80bb6abb72 | |||
| e378fdd3e8 | |||
| 37a878fb93 | |||
| 43b4bd5a83 | |||
| 57a00c0236 | |||
| 404e5179e8 | |||
| b540482bf5 | |||
| c398237c35 | |||
| 281b690c38 | |||
| 629a1fe9a4 | |||
| 5615e9a4fb | |||
| 49778af905 | |||
| 7acf3cf5be | |||
| d1859d593d | |||
| 0d259db7ce | |||
| 64cdef81a6 | |||
| 9a87af93f1 | |||
| cbaa208f79 | |||
| 53cad429a1 | |||
| 3e8adbfbfd | |||
| 2cba9c9c1f | |||
| 45a14163bf | |||
| 5096930d68 | |||
| 3064126709 | |||
| 13dee9ae9b | |||
| 97fb89b831 | |||
| 92c547db3c | |||
| 71de98e0d9 | |||
| 6d05bb212b | |||
| 5f9a61de98 | |||
| 6cdd0fd81e | |||
| 3169dd958a | |||
| 2495fcb13d | |||
| c398fc13a4 | |||
| a2b328f3b0 | |||
| 9f78698f54 | |||
| b888b33cde | |||
| 0978adcee5 | |||
| 128ac94575 | |||
| 428ea888a7 | |||
| f9f0ed0f14 | |||
| a72df2e839 | |||
| 0664640c6f | |||
| 068d0adbd4 | |||
| 67b3420d00 | |||
| 894565d87e | |||
| 6d5a861d17 | |||
| 52936b2b88 | |||
| 613d198690 | |||
| 0fb761f284 | |||
| 572ac39e60 | |||
| 68e7fb9ba2 | |||
| 0844befb35 | |||
| e484d8c100 | |||
| 20afede89c | |||
| 3dcdd97882 | |||
| 6eb4c293ff | |||
| d16f6cdcb4 | |||
| 28a440734c | |||
| ef679d41ea | |||
| c6190bbab6 | |||
| 7d893431d1 | |||
| 790be37930 | |||
| 6c45eca3d3 | |||
| f7e4d43b16 | |||
| 24807eab0f | |||
| 4b5defcf12 | |||
| 9ce7a67f58 | |||
| 02375af08d | |||
| 01e7b15c46 | |||
| 438f844f2b | |||
| 5e0b041d0a | |||
| f4d894fe7d | |||
| 7607d8d9b9 | |||
| 0c5a302105 | |||
| eae3e0bd2a | |||
| 6be0914b65 | |||
| d0340fc062 | |||
| 955128a25a | |||
| 367368805a | |||
| 3f85f6cfe3 | |||
| b9232687b5 | |||
| 373751996a | |||
| d86c4111ad | |||
| f97b244a59 | |||
| 5acf248285 | |||
| 0c80063311 | |||
| e3f9bbf925 | |||
| ff7a786c21 | |||
| bbf29bf400 | |||
| 08aa745e30 | |||
| 3fe32b1dfe | |||
| 2f350517b0 | |||
| 8bddce43c1 | |||
| 9378a5a75d | |||
| 3de28410ae | |||
| 093d2f2af0 |
@@ -16,3 +16,4 @@
|
||||
**/*.log
|
||||
**/*.swp
|
||||
**/.DS_Store
|
||||
**/.pnpm-store
|
||||
|
||||
25
.env.sample
25
.env.sample
@@ -7,7 +7,6 @@ APP_ENV=stage # 애플리케이션 실행 환경 (dev, stage, production)
|
||||
TZ=Asia/Seoul
|
||||
|
||||
|
||||
# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업)
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
# --- Infrastructure Ports ---
|
||||
@@ -33,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 # 감사 로그 대기열(채널) 버퍼 크기
|
||||
@@ -77,20 +81,20 @@ HYDRA_DB=ory_hydra
|
||||
KETO_DB=ory_keto
|
||||
|
||||
# Ory Kratos Configuration
|
||||
KRATOS_VERSION=v25.4.0-distroless
|
||||
KRATOS_VERSION=v26.2.0-distroless
|
||||
# KRATOS_PUBLIC_PORT=4433 # Internal only
|
||||
# KRATOS_ADMINFRONT_PORT=4434 # Internal only
|
||||
|
||||
KRATOS_UI_NODE_VERSION=v25.4.0
|
||||
KRATOS_UI_NODE_VERSION=v26.2.0
|
||||
# KRATOS_UI_PORT=4455 # Internal only
|
||||
|
||||
# Ory Hydra Configuration
|
||||
HYDRA_VERSION=v25.4.0-distroless
|
||||
HYDRA_VERSION=v26.2.0-distroless
|
||||
# HYDRA_PUBLIC_PORT=4441 # Internal only
|
||||
# HYDRA_ADMINFRONT_PORT=4445 # Internal only
|
||||
|
||||
# Ory Keto Configuration
|
||||
KETO_VERSION=v25.4.0-distroless
|
||||
KETO_VERSION=v26.2.0-distroless
|
||||
# KETO_READ_PORT=4466 # Internal only
|
||||
# KETO_WRITE_PORT=4467 # Internal only
|
||||
KETO_READ_URL=http://keto:4466
|
||||
@@ -110,16 +114,21 @@ KRATOS_UI_URL=http://localhost:5000
|
||||
HYDRA_ADMIN_URL=http://hydra:4445
|
||||
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
|
||||
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
|
||||
# 선택: Hydra 화면 핸드오프 URL을 USERFRONT_URL 기준 기본값과 다르게 둘 때만 설정합니다.
|
||||
# HYDRA_LOGIN_URL=https://sso.hmac.kr/login
|
||||
# HYDRA_CONSENT_URL=https://sso.hmac.kr/consent
|
||||
# HYDRA_ERROR_URL=https://sso.hmac.kr/error
|
||||
|
||||
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
|
||||
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
|
||||
KRATOS_ALLOWED_RETURN_URLS_JSON=["http://localhost:5000","http://localhost:5000/","https://sso.hmac.kr","https://sso.hmac.kr/","https://sso.hmac.kr/ko","https://sso.hmac.kr/ko/","https://sso.hmac.kr/en","https://sso.hmac.kr/en/","https://sso.hmac.kr/auth/callback","https://sso.hmac.kr/ko/auth/callback","https://sso.hmac.kr/en/auth/callback","http://localhost:5173/auth/callback","http://localhost:5174/auth/callback","http://localhost:5175/auth/callback","https://sso.hmac.kr/orgfront/auth/callback"]
|
||||
|
||||
# Oathkeeper JWKS (내부 통신용)
|
||||
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
|
||||
|
||||
# Oathkeeper 실행 사용자/프로브 설정
|
||||
OATHKEEPER_VERSION=v25.4.0
|
||||
OATHKEEPER_VERSION=v26.2.0
|
||||
OATHKEEPER_UID=1001
|
||||
OATHKEEPER_GID=1001
|
||||
OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready
|
||||
@@ -141,5 +150,5 @@ VITE_OIDC_CLIENT_ID=devfront
|
||||
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
|
||||
DEVFRONT_URL=http://localhost:5174
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
||||
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback
|
||||
VITE_ORGCHART_URL=
|
||||
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback
|
||||
VITE_ORGCHART_URL=
|
||||
|
||||
@@ -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,13 +99,23 @@ 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 }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build and push orgfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./orgfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build and push userfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_lint:
|
||||
description: "Run lint/format checks for Go, Flutter, adminfront, devfront"
|
||||
description: "Run lint/format checks for Go, Flutter, adminfront, devfront, orgfront"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
@@ -39,6 +39,11 @@ on:
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_orgfront_tests:
|
||||
description: "Run orgfront Playwright tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -52,10 +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
|
||||
|
||||
- name: i18n resource check
|
||||
run: |
|
||||
@@ -85,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: |
|
||||
@@ -96,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: |
|
||||
@@ -104,6 +107,18 @@ jobs:
|
||||
npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
|
||||
- name: Install orgfront dependencies
|
||||
run: |
|
||||
cd orgfront
|
||||
npm install -g pnpm
|
||||
pnpm install -C ../common --no-frozen-lockfile
|
||||
|
||||
- name: Biome check orgfront (lint + format)
|
||||
run: |
|
||||
cd orgfront
|
||||
npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
|
||||
- name: Lint Go backend
|
||||
run: |
|
||||
docker run --rm \
|
||||
@@ -319,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
|
||||
@@ -544,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
|
||||
@@ -639,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
|
||||
@@ -658,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
|
||||
@@ -677,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
|
||||
@@ -706,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
|
||||
@@ -738,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"
|
||||
@@ -809,3 +815,184 @@ jobs:
|
||||
devfront/playwright-report
|
||||
devfront/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
orgfront-tests:
|
||||
needs: lint
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
node scripts/playwrightPackageVersion.cjs orgfront >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Install orgfront dependencies
|
||||
run: |
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd orgfront
|
||||
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
|
||||
|
||||
if [ "$install_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# OrgFront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`orgfront-tests\`"
|
||||
echo "- Reason: \`Dependency install failed\`"
|
||||
echo "- Exit Code: \`$install_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd orgfront && npm ci\`"
|
||||
echo
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/orgfront-install.log
|
||||
echo '```'
|
||||
} > reports/orgfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Provision browsers for orgfront tests
|
||||
run: |
|
||||
set +e
|
||||
cd orgfront
|
||||
pnpm exec playwright install --with-deps 2>&1 | tee ../reports/orgfront-provision.log
|
||||
provision_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$provision_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# OrgFront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`orgfront-tests\`"
|
||||
echo "- Reason: \`Browser provisioning failed\`"
|
||||
echo "- Exit Code: \`$provision_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd orgfront && pnpm exec playwright install --with-deps\`"
|
||||
echo
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/orgfront-provision.log
|
||||
echo '```'
|
||||
} > reports/orgfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run orgfront tests
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 2
|
||||
run: |
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd orgfront
|
||||
pnpm run test 2>&1 | tee ../reports/orgfront-test.log
|
||||
test_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$test_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# OrgFront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`orgfront-tests\`"
|
||||
echo "- Exit Code: \`$test_exit_code\`"
|
||||
echo
|
||||
echo "## Commands"
|
||||
echo "1. \`cd orgfront\`"
|
||||
echo "2. \`npm ci\`"
|
||||
echo "3. \`pnpm exec playwright install --with-deps\`"
|
||||
echo "4. \`pnpm run test\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/orgfront-test.log
|
||||
echo '```'
|
||||
} > reports/orgfront-test-failure-report.md
|
||||
fi
|
||||
|
||||
exit "$test_exit_code"
|
||||
|
||||
- name: Ensure orgfront failure report exists
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
mkdir -p reports
|
||||
if [ -f reports/orgfront-test-failure-report.md ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "# OrgFront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`orgfront-tests\`"
|
||||
echo "- Reason: \`Job failed before detailed report generation\`"
|
||||
echo
|
||||
if [ -f reports/orgfront-install.log ]; then
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/orgfront-install.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/orgfront-provision.log ]; then
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/orgfront-provision.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/orgfront-test.log ]; then
|
||||
echo "## Test Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/orgfront-test.log
|
||||
echo '```'
|
||||
fi
|
||||
} > reports/orgfront-test-failure-report.md
|
||||
|
||||
- name: Publish orgfront failure summary
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
if [ -f reports/orgfront-test-failure-report.md ]; then
|
||||
cat reports/orgfront-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload orgfront failure report artifact
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: orgfront-test-failure-report
|
||||
path: |
|
||||
reports/orgfront-test-failure-report.md
|
||||
reports/orgfront-install.log
|
||||
reports/orgfront-provision.log
|
||||
reports/orgfront-test.log
|
||||
orgfront/playwright-report
|
||||
orgfront/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
|
||||
ssh-keyscan -H "${PROD_HOST}" >> ~/.ssh/known_hosts
|
||||
|
||||
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}'"
|
||||
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}/adminfront'"
|
||||
|
||||
# Create the main .env file for Baron SSO on the remote server
|
||||
# Note: All values are pulled from Gitea secrets and variables
|
||||
@@ -122,6 +122,7 @@ jobs:
|
||||
> .env
|
||||
|
||||
# Copy compose template and .env file to the remote server
|
||||
scp adminfront/seed-tenant.csv "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/adminfront/"
|
||||
scp docker/docker-compose.template.yaml .env "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/"
|
||||
scp docker/compose.infra.prd.yaml "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/compose.infra.yml"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,6 +65,7 @@ jobs:
|
||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
||||
|
||||
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
||||
@@ -77,7 +80,6 @@ jobs:
|
||||
AUDIT_WORKER_COUNT=5
|
||||
AUDIT_QUEUE_SIZE=2000
|
||||
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
|
||||
DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
||||
@@ -91,7 +93,7 @@ jobs:
|
||||
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
|
||||
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
|
||||
VITE_ORGCHART_URL=${{ vars.VITE_ORGCHART_URL }}
|
||||
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
|
||||
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||
@@ -124,11 +126,13 @@ jobs:
|
||||
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
|
||||
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
|
||||
|
||||
# Frontend OIDC configs for Staging
|
||||
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
|
||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback,http://172.16.10.176:5173/auth/callback,https://sadmin.hmac.kr/auth/callback
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback,http://172.16.10.176:5174/auth/callback,https://sdev.hmac.kr/auth/callback
|
||||
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback
|
||||
# Frontend/Ory URL configs for Staging
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}
|
||||
KRATOS_ALLOWED_RETURN_URLS_JSON=${{ vars.KRATOS_ALLOWED_RETURN_URLS_JSON }}
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.KRATOS_ALLOWED_RETURN_URLS_EXTRA }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
EOF
|
||||
@@ -161,8 +165,9 @@ jobs:
|
||||
done
|
||||
|
||||
|
||||
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
|
||||
chmod -R 777 docker/ory || true
|
||||
# Ory 컨테이너가 직접 읽는 설정은 env 기반으로 완성한 뒤 mount합니다.
|
||||
bash scripts/render_ory_config.sh
|
||||
chmod -R 777 config/.generated/ory || true
|
||||
|
||||
cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml
|
||||
|
||||
@@ -171,11 +176,36 @@ jobs:
|
||||
# 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등)
|
||||
docker compose -f staging_pull_compose.yaml build --pull
|
||||
|
||||
docker compose -f staging_pull_compose.yaml up -d --remove-orphans
|
||||
docker compose -f staging_pull_compose.yaml up -d --remove-orphans --renew-anon-volumes
|
||||
docker compose -f staging_pull_compose.yaml up -d --force-recreate kratos hydra keto oathkeeper
|
||||
docker compose -f staging_pull_compose.yaml up -d --force-recreate ory_stack_check
|
||||
docker compose -f staging_pull_compose.yaml up -d init-rp
|
||||
|
||||
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
|
||||
sleep 10
|
||||
|
||||
check_container_http() {
|
||||
name="$1"
|
||||
port="$2"
|
||||
max="${FRONTEND_HEALTH_MAX_ATTEMPTS:-60}"
|
||||
i=1
|
||||
while [ "${i}" -le "${max}" ]; do
|
||||
if docker exec "${name}" node -e "fetch('http://127.0.0.1:${port}/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then
|
||||
echo "Frontend ready: ${name}:${port}"
|
||||
return 0
|
||||
fi
|
||||
echo "Waiting for frontend: ${name}:${port} (${i}/${max})"
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
echo "ERROR: frontend not ready: ${name}:${port}" >&2
|
||||
docker logs "${name}" --tail 200 >&2 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
check_container_http baron_adminfront 5173
|
||||
check_container_http baron_devfront 5173
|
||||
check_container_http baron_orgfront 5175
|
||||
|
||||
echo "===== INIT-RP LOGS ====="
|
||||
docker compose -f staging_pull_compose.yaml logs init-rp || true
|
||||
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
|
||||
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
|
||||
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
|
||||
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
|
||||
|
||||
# Staging-specific variables
|
||||
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||
@@ -57,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
|
||||
|
||||
@@ -72,6 +75,7 @@ jobs:
|
||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
||||
|
||||
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
||||
@@ -86,7 +90,6 @@ jobs:
|
||||
AUDIT_WORKER_COUNT=5
|
||||
AUDIT_QUEUE_SIZE=2000
|
||||
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
|
||||
DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
||||
@@ -98,6 +101,7 @@ jobs:
|
||||
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
||||
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
||||
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
|
||||
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||
@@ -133,12 +137,14 @@ jobs:
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
EOF
|
||||
|
||||
# 파일 복사
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker"
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/adminfront"
|
||||
|
||||
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
|
||||
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
|
||||
@@ -151,6 +157,7 @@ jobs:
|
||||
scp -r gateway "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
|
||||
fi
|
||||
|
||||
scp adminfront/seed-tenant.csv "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/adminfront/"
|
||||
scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
|
||||
scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml"
|
||||
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
|
||||
@@ -162,6 +169,7 @@ jobs:
|
||||
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
|
||||
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
|
||||
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
|
||||
export ORGFRONT_IMAGE_NAME='${ORGFRONT_IMAGE_NAME}'; \
|
||||
export IMAGE_TAG='${IMAGE_TAG}'; \
|
||||
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
|
||||
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# General
|
||||
.env
|
||||
.env_backup
|
||||
.temp
|
||||
.DS_Store
|
||||
.idea/
|
||||
@@ -8,6 +9,7 @@
|
||||
.codex/
|
||||
.serena/
|
||||
.generated/
|
||||
config/.generated/
|
||||
*.swp
|
||||
*.log
|
||||
*.out
|
||||
@@ -15,6 +17,9 @@
|
||||
.npm-cache/
|
||||
reports
|
||||
reports/*
|
||||
config/*.pem
|
||||
common/node_modules
|
||||
common/.baron-deps-install.lock
|
||||
|
||||
# Docker Services Data (Volumes)
|
||||
postgres_data/
|
||||
@@ -39,6 +44,13 @@ userfront/.env
|
||||
|
||||
# Frontend test artifacts
|
||||
adminfront/test-results/
|
||||
adminfront/test-results.nobody-backup/
|
||||
devfront/test-results/
|
||||
orgfront/test-results/
|
||||
adminfront/playwright-report/
|
||||
devfront/playwright-report/
|
||||
orgfront/playwright-report/
|
||||
orgfront/node_modules/
|
||||
orgfront/dist/
|
||||
orgfront/.vite/
|
||||
.pnpm-store
|
||||
|
||||
23
.playwright-mcp/page-2026-05-11T09-39-57-342Z.yml
Normal file
23
.playwright-mcp/page-2026-05-11T09-39-57-342Z.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]: Developer Control Plane
|
||||
- generic [ref=e12]:
|
||||
- generic [ref=e13]:
|
||||
- heading "개발자 포털 로그인" [level=3] [ref=e14]:
|
||||
- img [ref=e15]
|
||||
- text: 개발자 포털 로그인
|
||||
- paragraph [ref=e18]: Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
|
||||
- generic [ref=e19]:
|
||||
- button "SSO 계정으로 로그인" [ref=e20] [cursor=pointer]:
|
||||
- img [ref=e21]
|
||||
- text: SSO 계정으로 로그인
|
||||
- img [ref=e23]
|
||||
- paragraph [ref=e27]:
|
||||
- text: 개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
|
||||
- text: 민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||
- paragraph [ref=e32]:
|
||||
- text: 인증 정보가 없거나 로그인이 되지 않는 경우
|
||||
- text: 시스템 관리자에게 문의하세요.
|
||||
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: 시스템 관리자에게 문의하세요.
|
||||
143
Makefile
143
Makefile
@@ -10,7 +10,13 @@ endif
|
||||
COMPOSE_INFRA := compose.infra.yaml
|
||||
COMPOSE_ORY := compose.ory.yaml
|
||||
COMPOSE_APP := docker-compose.yaml
|
||||
AUTH_CONFIG_ENV := .generated/auth-config.env
|
||||
AUTH_CONFIG_ENV := config/.generated/auth-config.env
|
||||
DEV_SERVICES ?= backend adminfront devfront orgfront userfront
|
||||
DEV_NETWORKS := baron_net ory-net hydranet kratosnet public_net
|
||||
INFRA_CONTAINERS := baron_postgres baron_clickhouse baron_redis baron_gateway
|
||||
ORY_CONTAINERS := ory_postgres ory_kratos ory_hydra ory_keto ory_oathkeeper ory_clickhouse ory_vector
|
||||
APP_CONTAINERS := baron_backend baron_adminfront baron_devfront baron_orgfront baron_userfront
|
||||
DROP_CONTAINERS := $(INFRA_CONTAINERS) $(ORY_CONTAINERS) $(APP_CONTAINERS) ory_stack_check
|
||||
|
||||
COMPOSE_CLI_ENV_ARGS :=
|
||||
ifneq (,$(wildcard ./.env))
|
||||
@@ -18,10 +24,17 @@ COMPOSE_CLI_ENV_ARGS += --env-file .env
|
||||
endif
|
||||
COMPOSE_CLI_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
|
||||
|
||||
COMPOSE_DROP_ENV_ARGS :=
|
||||
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 dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app
|
||||
|
||||
# --- 인증 설정 빌드/검증 ---
|
||||
build-auth-config:
|
||||
@echo "Building auth config..."
|
||||
@mkdir -p .generated
|
||||
@mkdir -p config/.generated
|
||||
@bash scripts/auth_config.sh build
|
||||
|
||||
validate-auth-config: build-auth-config
|
||||
@@ -32,40 +45,109 @@ verify-auth-config: validate-auth-config
|
||||
@echo "Verifying auth config wiring..."
|
||||
@bash scripts/auth_config.sh verify
|
||||
|
||||
render-ory-config: validate-auth-config
|
||||
@echo "Rendering Ory config..."
|
||||
@bash scripts/render_ory_config.sh
|
||||
|
||||
# --- 기본 실행 ---
|
||||
# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음)
|
||||
up-all: validate-auth-config
|
||||
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 -d
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos
|
||||
|
||||
# --- 개별 스택 실행 ---
|
||||
up-infra:
|
||||
up-infra: ensure-networks
|
||||
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
|
||||
docker compose -f $(COMPOSE_INFRA) up -d
|
||||
|
||||
up-ory: validate-auth-config
|
||||
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: validate-auth-config
|
||||
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d
|
||||
up-app: ensure-networks render-ory-config
|
||||
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d
|
||||
|
||||
up-backend: validate-auth-config
|
||||
up-backend: ensure-networks render-ory-config
|
||||
@echo "Starting Backend only..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d backend
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d backend
|
||||
|
||||
up-dev: up-infra up-ory
|
||||
ensure-networks:
|
||||
@echo "Ensuring Docker networks..."
|
||||
@for network in $(DEV_NETWORKS); do \
|
||||
if ! docker network inspect "$$network" >/dev/null 2>&1; then \
|
||||
echo "Creating Docker network $$network..."; \
|
||||
docker network create "$$network"; \
|
||||
else \
|
||||
echo "Docker network $$network already exists."; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
ensure-infra: ensure-networks
|
||||
@echo "Ensuring Infra stack..."
|
||||
@missing=0; \
|
||||
for container in $(INFRA_CONTAINERS); do \
|
||||
if [ "$$(docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null)" != "true" ]; then \
|
||||
missing=1; \
|
||||
break; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$missing" -eq 1 ]; then \
|
||||
echo "Starting missing Infra stack containers in daemon mode..."; \
|
||||
docker compose -f $(COMPOSE_INFRA) up -d; \
|
||||
else \
|
||||
echo "Infra stack is already running."; \
|
||||
fi
|
||||
|
||||
ensure-ory: ensure-networks render-ory-config
|
||||
@echo "Ensuring Ory stack..."
|
||||
@missing=0; \
|
||||
for container in $(ORY_CONTAINERS); do \
|
||||
if [ "$$(docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null)" != "true" ]; then \
|
||||
missing=1; \
|
||||
break; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$missing" -eq 1 ]; then \
|
||||
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. 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
|
||||
@echo "Dev stack is up (infra + ory)."
|
||||
|
||||
up-front-dev: up-infra up-ory up-backend
|
||||
@echo "Dev stack is up (infra + ory + backend)."
|
||||
|
||||
dev: up-dev
|
||||
@echo "Starting development app containers in foreground attach mode..."
|
||||
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:
|
||||
@echo "Stopping ALL stacks (infra + ory + app)..."
|
||||
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
|
||||
|
||||
drop:
|
||||
@echo "Dropping Baron SSO local Docker stack containers, volumes, and local images..."
|
||||
-docker compose $(COMPOSE_DROP_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down -v --rmi local
|
||||
@echo "Removing any remaining fixed-name Baron SSO containers..."
|
||||
@for container in $(DROP_CONTAINERS); do \
|
||||
docker rm -f "$$container" >/dev/null 2>&1 || true; \
|
||||
done
|
||||
@echo "Drop complete. External Docker networks are preserved."
|
||||
|
||||
down-app:
|
||||
@echo "Stopping App stack..."
|
||||
docker compose -f $(COMPOSE_APP) down
|
||||
@@ -112,15 +194,10 @@ PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTAL
|
||||
PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE
|
||||
PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE
|
||||
|
||||
ifeq ($(CI),)
|
||||
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi'
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
|
||||
else
|
||||
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium
|
||||
endif
|
||||
|
||||
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
|
||||
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-orgfront-tests code-check-userfront-e2e-tests
|
||||
|
||||
CODE_CHECK_TEST_JOBS ?= 1
|
||||
PLAYWRIGHT_WORKERS ?= 1
|
||||
@@ -138,7 +215,8 @@ code-check-test-jobs:
|
||||
code-check-userfront-tests \
|
||||
code-check-userfront-e2e-tests \
|
||||
code-check-adminfront-tests \
|
||||
code-check-devfront-tests
|
||||
code-check-devfront-tests \
|
||||
code-check-orgfront-tests
|
||||
|
||||
code-check-i18n:
|
||||
@echo "==> i18n resource check"
|
||||
@@ -185,14 +263,19 @@ 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
|
||||
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"
|
||||
rm -rf devfront/playwright-report devfront/test-results
|
||||
cd devfront && npm ci
|
||||
cd devfront && npm ci --ignore-scripts
|
||||
cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
@echo "==> orgfront biome lint/format check"
|
||||
rm -rf orgfront/playwright-report orgfront/test-results
|
||||
cd orgfront && npm ci --ignore-scripts
|
||||
cd orgfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd orgfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
|
||||
code-check-backend-tests:
|
||||
@echo "==> backend tests"
|
||||
@@ -229,7 +312,7 @@ code-check-devfront-tests:
|
||||
@mkdir -p reports/devfront
|
||||
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
|
||||
@status=0; \
|
||||
(cd devfront && npm ci) || status=$$?; \
|
||||
(cd devfront && npm ci --ignore-scripts) || status=$$?; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||
fi; \
|
||||
@@ -240,6 +323,22 @@ code-check-devfront-tests:
|
||||
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
|
||||
exit $$status
|
||||
|
||||
code-check-orgfront-tests:
|
||||
@echo "==> orgfront tests"
|
||||
@mkdir -p reports/orgfront
|
||||
@rm -rf reports/orgfront/playwright-report reports/orgfront/test-results
|
||||
@status=0; \
|
||||
(cd orgfront && npm ci --ignore-scripts) || status=$$?; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd orgfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd orgfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
|
||||
fi; \
|
||||
[ -d orgfront/playwright-report ] && cp -R orgfront/playwright-report reports/orgfront/ || true; \
|
||||
[ -d orgfront/test-results ] && cp -R orgfront/test-results reports/orgfront/ || true; \
|
||||
exit $$status
|
||||
|
||||
code-check-userfront-e2e-tests:
|
||||
@echo "==> userfront wasm playwright e2e tests (isolated workspace)"
|
||||
@mkdir -p reports/userfront-e2e
|
||||
|
||||
114
README.md
114
README.md
@@ -66,7 +66,7 @@ flowchart
|
||||
```
|
||||
|
||||
### 1. Backend (Go Fiber)
|
||||
- **Language**: Go 1.25+
|
||||
- **Language**: Go 1.26.2+
|
||||
- **Framework**: Fiber v2.25+
|
||||
- **Database**:
|
||||
- **ClickHouse**: 감사 로그 (고성능 데이터 수집)
|
||||
@@ -95,6 +95,99 @@ flowchart
|
||||
- RP 등록 및 관리
|
||||
- RP별 Consent 관리
|
||||
|
||||
## 관리 데이터 Export/Import 정책
|
||||
|
||||
AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접 검토하고 재반입할 수 있는 흐름을 기준으로 설계합니다. 기본 원칙은 내부 UUID를 불필요하게 노출하지 않고, 사람이 이해하기 쉬운 `slug`와 이름을 우선 사용하는 것입니다.
|
||||
|
||||
### 공통 원칙
|
||||
- CSV는 Excel 호환을 위해 UTF-8 BOM을 포함해 내려받습니다.
|
||||
- 기본 export는 시스템 내부 ID를 제외합니다.
|
||||
- 같은 데이터를 정확히 재동기화해야 하는 운영 작업에서는 `includeIds=true` 옵션으로 내부 ID 컬럼을 포함할 수 있습니다.
|
||||
- import는 preview/검토 단계를 거친 뒤 실행하는 것을 기본으로 합니다.
|
||||
- 기존 데이터와 충돌 가능성이 있는 row는 자동 적용하지 않고 관리자 선택 또는 확인 상태로 표시합니다.
|
||||
- 삭제는 export/import로 암묵 처리하지 않습니다. 삭제가 필요하면 별도 삭제 기능을 사용합니다.
|
||||
|
||||
### Tenant Export
|
||||
- 기본 컬럼은 운영자가 다시 import하기 쉬운 형태를 유지합니다.
|
||||
- `includeIds=false`가 기본이며, 이 경우 내부 `tenant_id`는 제외합니다.
|
||||
- `includeIds=true`를 사용하면 기존 테넌트 update 또는 staging/production 간 매핑 확인에 필요한 ID를 포함합니다.
|
||||
- 주요 의미:
|
||||
- `tenant_id`: 내부 UUID. 기본 export에서는 제외됩니다.
|
||||
- `name`: 테넌트 표시 이름입니다.
|
||||
- `type`: `PERSONAL`, `COMPANY`, `COMPANY_GROUP`, `USER_GROUP` 중 하나입니다.
|
||||
- `parent_tenant_id`: 상위 테넌트 내부 ID입니다.
|
||||
- `parent_tenant_slug`: 상위 테넌트를 slug로 연결할 때 사용합니다.
|
||||
- `slug`: 운영상 사람이 다루는 테넌트 식별자입니다.
|
||||
- `memo`: 설명 또는 비고입니다.
|
||||
- `email_domain`: 테넌트에 연결된 이메일 도메인입니다. 여러 도메인은 `;`, `,`, 줄바꿈으로 구분할 수 있습니다.
|
||||
|
||||
### Tenant Import
|
||||
- 필수 컬럼은 `name`, `type`, `slug`입니다.
|
||||
- 허용되는 header alias:
|
||||
- `tenant_id`: `id`, `tenantid`, `tenant_id`
|
||||
- `parent_tenant_id`: `parentid`, `parent_id`, `parenttenantid`, `parent_tenant_id`
|
||||
- `parent_tenant_slug`: `parenttenantslug`, `parent_tenant_slug`
|
||||
- `memo`: `memo`, `description`
|
||||
- `email_domain`: `email-domain`, `emaildomain`, `email_domain`, `domain`, `domains`
|
||||
- `tenant_id`가 있고 기존 테넌트가 있으면 update 대상으로 봅니다.
|
||||
- `tenant_id`가 없으면 `slug` 기준으로 기존 테넌트를 찾고, 없으면 신규 생성 후보로 봅니다.
|
||||
- `parent_tenant_slug`가 같은 import 파일 안에 있으면 부모 row를 먼저 처리하도록 정렬합니다.
|
||||
- import preview는 이름/slug 유사도 기반 후보를 보여주며, 관리자가 기존 테넌트 사용, 신규 생성, skip 중 선택할 수 있어야 합니다.
|
||||
- 외부 시스템에서 가져온 `tenant_id`처럼 현재 DB에 없는 ID는 충돌로 표시하고, 관리자가 새 slug 또는 기존 테넌트 매핑을 결정해야 합니다.
|
||||
|
||||
### User Export
|
||||
- 기본 컬럼은 `Email`, `Name`, `Phone`, `Status`, `tenant_slug`, `Position`, `JobTitle`, `CreatedAt`입니다.
|
||||
- `includeIds=true`이면 `user_id`, `tenant_id`를 함께 포함합니다.
|
||||
- 사용자 role은 export 기본 컬럼에서 제외합니다. role은 일괄 변경의 실수 위험이 크므로 명시적 관리 화면 또는 별도 정책으로 다룹니다.
|
||||
- 사용자 metadata는 `Meta:<key>` 컬럼으로 뒤에 추가됩니다.
|
||||
- `includeIds=false`일 때는 `id`, `user_id`, `tenant_id`, `tenantid` 성격의 metadata key를 export에서 제외합니다.
|
||||
- tenant admin의 export는 관리 가능한 테넌트 범위로 제한됩니다.
|
||||
|
||||
### User Import
|
||||
- 사용자 CSV의 기본 컬럼은 `email`, `name`, `phone`, `role`, `tenant_slug`, `department`, `position`, `jobTitle`입니다.
|
||||
- `email`과 `name`은 CSV parsing 단계의 필수값입니다.
|
||||
- backend 생성 단계에서는 `tenantSlug`도 필수입니다.
|
||||
- `tenant`, `tenant_slug`, `companyCode` header는 사용자 소속 테넌트 slug로 매핑됩니다.
|
||||
- `tenant_id`, `tenant_name`, `tenant_type`, `parent_tenant_id`, `parent_tenant_slug`, `parent_tenant_name`, `tenant_memo`, `email_domain` 컬럼이 있으면 사용자 import 과정에서 필요한 테넌트 생성/매핑 preview에 사용합니다.
|
||||
- 위 기본 컬럼에 속하지 않는 컬럼은 사용자 metadata로 들어갑니다.
|
||||
- 테넌트에 `userSchema`가 있으면 import 중 metadata required/validation/loginId 규칙을 적용합니다.
|
||||
- 테넌트 schema에서 `isLoginId`로 지정된 metadata 값은 custom login ID로 동기화하며, 이메일/전화번호/예약어와 충돌하지 않아야 합니다.
|
||||
|
||||
### 한맥가족 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를 제안합니다.
|
||||
- 이름 기반 local-part 기본 규칙은 `이름 부분 초성 + 성 로마자`입니다.
|
||||
- 예: `한치영` -> `치영`의 초성 `c + y` + 성 `han` -> `cyhan`
|
||||
- 이미 `cyhan`, `cyhan1`이 있으면 다음 후보인 `cyhan2`를 제안합니다.
|
||||
- 외부 로마자화 패키지는 backend 의존성으로 추가하지 않고, 내부 한글 음절 분해와 성씨/초성 매핑을 사용합니다.
|
||||
- import preview의 row 상태:
|
||||
- `valid`: unique와 이름 기반 권장 규칙을 모두 만족합니다.
|
||||
- `suggested`: 도메인만 있거나 suffix 제안이 필요한 row입니다.
|
||||
- `needsReview`: 이름 매핑이 애매해 관리자가 직접 확인해야 합니다.
|
||||
- `ruleMismatch`: 최종 local-part가 `이름 이니셜 + 성 + 숫자 suffix` 규칙과 다릅니다. 예외 진행은 가능하지만 관리자에게 표시해야 합니다.
|
||||
- `blockingError`: local-part 중복, email 형식 오류, 필수값 누락처럼 생성을 차단해야 하는 상태입니다.
|
||||
- 단건 사용자 생성은 한맥가족 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 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
|
||||
@@ -319,11 +412,13 @@ USERFRONT_URL=https://sso.example.com
|
||||
- `KRATOS_BROWSER_URL`: 보통 `${OATHKEEPER_PUBLIC_URL}/auth`
|
||||
- `KRATOS_UI_URL`: UserFront UI URL (로컬 예: `http://localhost:5000`)
|
||||
- `ADMINFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5173/auth/callback`)
|
||||
- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/callback`)
|
||||
- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/auth/callback`)
|
||||
- 주의: callback URL 끝에 `/`가 붙으면 `make validate-auth-config`에서 실패 처리됩니다.
|
||||
- `KRATOS_ALLOWED_RETURN_URLS_EXTRA`: 추가 허용 return URL (선택)
|
||||
- 빈값: `[]`
|
||||
- 다중값: `["https://a.example.com/callback","https://b.example.com/callback"]` 또는 `https://a.example.com/callback,https://b.example.com/callback`
|
||||
- `KRATOS_ALLOWED_RETURN_URLS_JSON`: stage/prod에서 권장하는 전체 허용 return URL 목록
|
||||
- 공개 도메인, `/ko`, `/en`, `/auth/callback`, `/ko/auth/callback`, `/en/auth/callback`, 각 front callback을 포함해야 합니다.
|
||||
- `CLIENT_LOG_DEBUG`: 클라이언트 로그 디버그 모드 강제 (기본: 비운영 `true`, 운영 `false`)
|
||||
- 운영(`APP_ENV=production|prod`)에서 `true|1|on|yes` 설정 시 `INFO/DEBUG` 클라이언트 로그 수집 허용
|
||||
- 미설정(기본) 시 운영에서는 `WARN/ERROR`만 수집
|
||||
@@ -358,7 +453,7 @@ USERFRONT_URL=https://sso.example.com
|
||||
```bash
|
||||
make validate-auth-config
|
||||
```
|
||||
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `.generated/auth-config.env`를 생성합니다.
|
||||
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `config/.generated/auth-config.env`를 생성합니다.
|
||||
|
||||
### 전체 스택 실행 (Running the Stack)
|
||||
|
||||
@@ -400,7 +495,7 @@ make validate-auth-config
|
||||
make verify-auth-config
|
||||
```
|
||||
|
||||
- 생성 파일: `.generated/auth-config.env` (compose 실행 시 자동 주입)
|
||||
- 생성 파일: `config/.generated/auth-config.env` (compose 실행 시 자동 주입)
|
||||
- 게이트웨이 경유 환경은 URL 문자열 완전일치 대신 매핑 유효성(`direct_match` / `mapped_match`) 기준으로 검증합니다.
|
||||
- 관련 정책 문서: `docs/oidc_redirect_mapping_validation_policy.md`
|
||||
|
||||
@@ -415,8 +510,8 @@ make up-app
|
||||
|
||||
직접 Compose를 사용하려면 다음처럼 env 파일을 함께 주입하세요.
|
||||
```bash
|
||||
docker compose --env-file .env --env-file .generated/auth-config.env -f compose.infra.yaml -f compose.ory.yaml up -d
|
||||
docker compose --env-file .env --env-file .generated/auth-config.env -f docker-compose.yaml up -d
|
||||
docker compose --env-file .env --env-file config/.generated/auth-config.env -f compose.infra.yaml -f compose.ory.yaml up -d
|
||||
docker compose --env-file .env --env-file config/.generated/auth-config.env -f docker-compose.yaml up -d
|
||||
```
|
||||
|
||||
- **gateway (UserFront 프록시)**: http://localhost:5000 접속
|
||||
@@ -463,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`
|
||||
|
||||
@@ -14,7 +14,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
|
||||
- Descope SDK Integration (Enchanted Link, Magic Link)
|
||||
|
||||
### 2. Backend (Go Fiber)
|
||||
- **Language**: Go 1.25+
|
||||
- **Language**: Go 1.26.2+
|
||||
- **Framework**: Fiber v2.25+
|
||||
- **Database**:
|
||||
- **ClickHouse**: Audit Logs (High performance ingestion)
|
||||
|
||||
@@ -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,30 +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",
|
||||
"node_modules",
|
||||
"tsconfig*.json",
|
||||
"test-results",
|
||||
"playwright-report"
|
||||
]
|
||||
"ignore": [".vite"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "npx playwright test",
|
||||
"test": "playwright test",
|
||||
"test:unit": "vitest run",
|
||||
"test:ui": "npx playwright 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { shouldIncludeWebKit } =
|
||||
require("../scripts/playwrightHostDeps.cjs") as {
|
||||
shouldIncludeWebKit: () => boolean;
|
||||
};
|
||||
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
@@ -7,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.
|
||||
@@ -49,7 +57,12 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
launchOptions: chromiumExecutablePath
|
||||
? { executablePath: chromiumExecutablePath }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
@@ -57,10 +70,14 @@ export default defineConfig({
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
...(shouldIncludeWebKit()
|
||||
? [
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
|
||||
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
@@ -3,6 +3,19 @@ set -eu
|
||||
|
||||
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
if [ -z "${VITE_ADMIN_PUBLIC_URL:-}" ] && [ -n "${ADMINFRONT_URL:-}" ]; then
|
||||
export VITE_ADMIN_PUBLIC_URL="$ADMINFRONT_URL"
|
||||
fi
|
||||
|
||||
if [ -z "${VITE_ADMIN_PUBLIC_URL:-}" ] && [ -n "${ADMINFRONT_CALLBACK_URLS:-}" ]; then
|
||||
first_admin_callback="${ADMINFRONT_CALLBACK_URLS%%,*}"
|
||||
case "$first_admin_callback" in
|
||||
http://*/auth/callback | https://*/auth/callback)
|
||||
export VITE_ADMIN_PUBLIC_URL="${first_admin_callback%/auth/callback}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
case "$app_env" in
|
||||
production|prod|stage|staging)
|
||||
mode="production"
|
||||
@@ -12,14 +25,102 @@ case "$app_env" in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${1:-}" = "--print-admin-public-url" ]; then
|
||||
printf '%s\n' "${VITE_ADMIN_PUBLIC_URL:-}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--print-mode" ]; then
|
||||
printf '%s\n' "$mode"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ensure_frontend_dependencies() {
|
||||
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 "$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
|
||||
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..."
|
||||
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}`);
|
||||
});
|
||||
11
adminfront/seed-tenant.csv
Normal file
11
adminfront/seed-tenant.csv
Normal file
@@ -0,0 +1,11 @@
|
||||
id,name,type,parent_tenant_slug,slug,memo,email_domain
|
||||
038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,
|
||||
9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com
|
||||
369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr
|
||||
5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr
|
||||
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,
|
||||
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com
|
||||
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr
|
||||
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,baron-group,hanlla,,hanllasanup.co.kr
|
||||
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr
|
||||
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,
|
||||
|
@@ -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,
|
||||
});
|
||||
|
||||
52
adminfront/src/app/routes.test.tsx
Normal file
52
adminfront/src/app/routes.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { matchRoutes } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAdminAuthRedirectUris } from "../lib/authConfig";
|
||||
import { adminRoutes } from "./routes";
|
||||
|
||||
describe("admin routes", () => {
|
||||
it("accepts the auth callback path generated from the public admin URL", () => {
|
||||
const { redirectUri } = buildAdminAuthRedirectUris(
|
||||
"https://sadmin.hmac.kr",
|
||||
);
|
||||
const callbackPath = new URL(redirectUri).pathname;
|
||||
|
||||
const matches = matchRoutes(adminRoutes, callbackPath);
|
||||
|
||||
expect(callbackPath).toBe("/auth/callback");
|
||||
expect(matches?.at(-1)?.route.path).toBe("/auth/callback");
|
||||
});
|
||||
|
||||
it("registers the super-admin user projection management route", () => {
|
||||
const matches = matchRoutes(adminRoutes, "/system/projections/users");
|
||||
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
|
||||
});
|
||||
|
||||
it("registers the super-admin data integrity management route", () => {
|
||||
const matches = matchRoutes(adminRoutes, "/system/data-integrity");
|
||||
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
|
||||
});
|
||||
|
||||
it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
|
||||
const rootRoute = adminRoutes.find((route) => route.path === "/");
|
||||
const protectedShellRoute = rootRoute?.children?.[0];
|
||||
|
||||
expect(getRouteElementName(rootRoute?.element)).toBe("AuthGuard");
|
||||
expect(getRouteElementName(protectedShellRoute?.element)).toBe("AppLayout");
|
||||
expect(protectedShellRoute?.children?.at(0)?.index).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function getRouteElementName(element: unknown) {
|
||||
if (
|
||||
typeof element === "object" &&
|
||||
element !== null &&
|
||||
"type" in element &&
|
||||
typeof element.type === "function"
|
||||
) {
|
||||
return element.type.name;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,64 +1,80 @@
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import AppLayout from "../components/layout/AppLayout";
|
||||
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
|
||||
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
import AuthGuard from "../features/auth/AuthGuard";
|
||||
import AuthPage from "../features/auth/AuthPage";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
|
||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import UserProjectionPage from "../features/projections/UserProjectionPage";
|
||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
|
||||
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
||||
import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage";
|
||||
import UserCreatePage from "../features/users/UserCreatePage";
|
||||
import UserDetailPage from "../features/users/UserDetailPage";
|
||||
import UserListPage from "../features/users/UserListPage";
|
||||
import { ADMIN_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
||||
|
||||
export const adminRoutes: RouteObject[] = [
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: ADMIN_AUTH_CALLBACK_PATH,
|
||||
element: <AuthCallbackPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{
|
||||
element: <AppLayout />,
|
||||
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 />,
|
||||
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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: "/auth/callback",
|
||||
element: <AuthCallbackPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AppLayout />,
|
||||
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 />,
|
||||
children: [
|
||||
{ index: true, element: <TenantProfilePage /> },
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId/organization/:id",
|
||||
element: <TenantUserGroupsTab />,
|
||||
},
|
||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
adminRoutes,
|
||||
// React Router v7 플래그는 Provider에서 적용합니다.
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
Database,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
Moon,
|
||||
Network,
|
||||
NotebookTabs,
|
||||
ShieldCheck,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
User as UserIcon,
|
||||
@@ -18,8 +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,
|
||||
@@ -27,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();
|
||||
@@ -50,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 })
|
||||
@@ -60,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,
|
||||
@@ -87,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);
|
||||
@@ -104,16 +172,20 @@ 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) => {
|
||||
if (isTest) return true;
|
||||
@@ -123,26 +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",
|
||||
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
filteredItems.splice(4, 0, {
|
||||
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,
|
||||
});
|
||||
@@ -151,8 +239,9 @@ function AppLayout() {
|
||||
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
|
||||
0,
|
||||
{
|
||||
label: "ui.admin.nav.org_chart",
|
||||
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
},
|
||||
@@ -160,8 +249,9 @@ function AppLayout() {
|
||||
} else {
|
||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.org_chart",
|
||||
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
@@ -185,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,
|
||||
@@ -204,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 (
|
||||
@@ -279,6 +383,10 @@ function AppLayout() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDevelopmentRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeKeepSessionAlive = async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
@@ -321,6 +429,7 @@ function AppLayout() {
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isDevelopmentRuntime,
|
||||
isSessionExpiryEnabled,
|
||||
]);
|
||||
|
||||
@@ -378,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 (
|
||||
@@ -453,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">
|
||||
<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>
|
||||
@@ -542,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} />}
|
||||
@@ -556,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
|
||||
@@ -572,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
|
||||
@@ -592,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
|
||||
@@ -707,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"
|
||||
@@ -718,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}
|
||||
@@ -726,7 +774,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="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,109 +1,22 @@
|
||||
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
|
||||
|
||||
const flows = [
|
||||
{
|
||||
title: "Admin login",
|
||||
description:
|
||||
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
|
||||
pill: "15m TTL",
|
||||
},
|
||||
{
|
||||
title: "Tenant pick",
|
||||
description:
|
||||
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
|
||||
pill: "Header-ready",
|
||||
},
|
||||
{
|
||||
title: "Device approval",
|
||||
description:
|
||||
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
|
||||
pill: "App session",
|
||||
},
|
||||
];
|
||||
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-8">
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Admin auth
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Build the admin-only login flow first, keeping app login separate.
|
||||
Respect the “fallback only when user chooses” rule for SMS/email
|
||||
vs app approval.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
|
||||
IDP session placeholder
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
Connect auth layer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="space-y-6">
|
||||
<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.",
|
||||
)}
|
||||
/>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{flows.map((flow) => (
|
||||
<div
|
||||
key={flow.title}
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
|
||||
<span>{flow.pill}</span>
|
||||
<Fingerprint size={14} />
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{flow.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<Smartphone size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.18em]">
|
||||
App-based approvals
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
App session as MFA replacement
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
If the admin keeps the mobile app signed in and opts in, use
|
||||
push/deeplink approval instead of OTP. Otherwise fall back to
|
||||
SMS/email based on user choice.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<ArrowRight size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.18em]">
|
||||
TTL discipline
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
Keep admin sessions short
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
|
||||
with step-up MFA when critical actions (rotate secret, export logs)
|
||||
happen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<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,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
다시 시도하기
|
||||
|
||||
194
adminfront/src/features/auth/components/PermissionChecker.tsx
Normal file
194
adminfront/src/features/auth/components/PermissionChecker.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
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;
|
||||
query: {
|
||||
namespace: string;
|
||||
object: string;
|
||||
relation: string;
|
||||
subject: string;
|
||||
};
|
||||
};
|
||||
|
||||
function PermissionChecker() {
|
||||
const [namespace, setNamespace] = useState("Tenant");
|
||||
const [object, setObject] = useState("");
|
||||
const [relation, setRelation] = useState("manage");
|
||||
const [subject, setSubject] = useState("");
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.get<CheckPermissionResponse>(
|
||||
"/v1/admin/debug/check-permission",
|
||||
{
|
||||
params: { namespace, object, relation, subject },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkMutation.data;
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.title",
|
||||
"ReBAC permission checker",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{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>
|
||||
{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">
|
||||
{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>{t("ui.admin.auth_guard.checker.relation", "Relation")}</Label>
|
||||
<Input
|
||||
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>{t("ui.admin.auth_guard.checker.object_id", "Object ID")}</Label>
|
||||
<Input
|
||||
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>
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.subject",
|
||||
"Subject (User:ID)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.subject_placeholder",
|
||||
"User:uuid or Namespace:ID#Relation",
|
||||
)}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={!object || !subject || checkMutation.isPending}
|
||||
className="w-full px-12 md:w-auto"
|
||||
>
|
||||
{checkMutation.isPending
|
||||
? t("ui.admin.auth_guard.checker.checking", "Checking...")
|
||||
: t("ui.admin.auth_guard.checker.check", "Check permission")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{checkMutation.isSuccess && result && (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 animate-in zoom-in duration-300 ${
|
||||
result.allowed
|
||||
? "border-green-500/50 bg-green-500/10 text-green-600"
|
||||
: "border-destructive/50 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{result.allowed ? (
|
||||
<>
|
||||
<CheckCircle2 size={48} />
|
||||
<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-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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionChecker;
|
||||
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>
|
||||
);
|
||||
}
|
||||
266
adminfront/src/features/overview/GlobalOverviewPage.test.tsx
Normal file
266
adminfront/src/features/overview/GlobalOverviewPage.test.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
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,
|
||||
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: currentRole })),
|
||||
fetchAdminOverviewStats: vi.fn(async () => ({
|
||||
totalTenants: 10,
|
||||
totalUsers: 152,
|
||||
oidcClients: 3,
|
||||
auditEvents24h: 18,
|
||||
})),
|
||||
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",
|
||||
name: "한맥",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "org-1",
|
||||
type: "ORGANIZATION",
|
||||
name: "개발팀",
|
||||
slug: "dev-team",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "personal-1",
|
||||
type: "PERSONAL",
|
||||
name: "개인",
|
||||
slug: "personal",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
total: 4,
|
||||
})),
|
||||
fetchAdminRPUsageDaily: vi.fn(async () => ({
|
||||
days: 14,
|
||||
period: "day",
|
||||
items: [
|
||||
{
|
||||
date: "2026-05-05",
|
||||
tenantId: "company-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "한맥",
|
||||
clientId: "orgfront",
|
||||
clientName: "OrgFront",
|
||||
loginRequests: 12,
|
||||
otherRequests: 4,
|
||||
uniqueSubjects: 8,
|
||||
},
|
||||
{
|
||||
date: "2026-05-06",
|
||||
tenantId: "company-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "한맥",
|
||||
clientId: "adminfront",
|
||||
clientName: "AdminFront",
|
||||
loginRequests: 7,
|
||||
otherRequests: 3,
|
||||
uniqueSubjects: 5,
|
||||
},
|
||||
{
|
||||
date: "2026-09-28",
|
||||
tenantId: "company-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "한맥",
|
||||
clientId: "devfront",
|
||||
clientName: "DevFront",
|
||||
loginRequests: 2,
|
||||
otherRequests: 1,
|
||||
uniqueSubjects: 2,
|
||||
},
|
||||
],
|
||||
})),
|
||||
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) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin overview and auth guard pages", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders usage trend chart without quick navigation or permission checker", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByLabelText("일 단위 RP 요청 현황"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("05.05")).toBeInTheDocument();
|
||||
expect(await screen.findByText("05.06")).toBeInTheDocument();
|
||||
expect(screen.queryByText("빠른 작업")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("빠른 이동")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("테넌트 추가")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders overview tenant count from the fully fetched tenant list", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
(await screen.findByText("전체 테넌트 수")).parentElement,
|
||||
).toHaveTextContent("4");
|
||||
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
|
||||
"3",
|
||||
);
|
||||
expect(screen.getByText("전체 사용자 수").parentElement).toHaveTextContent(
|
||||
"152",
|
||||
);
|
||||
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
|
||||
"18",
|
||||
);
|
||||
});
|
||||
|
||||
it("limits the overview graph choices to company tenants", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
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.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
||||
days: 90,
|
||||
period: "month",
|
||||
});
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("한맥그룹 (hanmac-group)"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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("ReBAC 권한 검증 도구")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("IDP session placeholder"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Admin login")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,200 +1,611 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
Key,
|
||||
PlusCircle,
|
||||
LayoutDashboard,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
OverviewAxisNotes,
|
||||
OverviewMetric,
|
||||
OverviewSelectionChips,
|
||||
} from "../../../../common/core/components/overview";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
type DataIntegrityStatus,
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
type TenantSummary,
|
||||
fetchAdminOverviewStats,
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchAllTenants,
|
||||
fetchDataIntegrityReport,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import PermissionChecker from "./components/PermissionChecker";
|
||||
|
||||
function GlobalOverviewPage() {
|
||||
return (
|
||||
<div className="space-y-8 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-3xl font-bold tracking-tight">
|
||||
{t("ui.admin.overview.title", "Dashboard")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
type DailyPoint = {
|
||||
date: string;
|
||||
loginRequests: number;
|
||||
otherRequests: number;
|
||||
};
|
||||
|
||||
type SeriesSummary = {
|
||||
key: string;
|
||||
clientLabel: string;
|
||||
loginRequests: number;
|
||||
uniqueSubjects: number;
|
||||
};
|
||||
|
||||
function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
|
||||
const byDate = new Map<string, DailyPoint>();
|
||||
for (const row of rows) {
|
||||
const current =
|
||||
byDate.get(row.date) ??
|
||||
({
|
||||
date: row.date,
|
||||
loginRequests: 0,
|
||||
otherRequests: 0,
|
||||
} satisfies DailyPoint);
|
||||
current.loginRequests += row.loginRequests;
|
||||
current.otherRequests += row.otherRequests;
|
||||
byDate.set(row.date, current);
|
||||
}
|
||||
return Array.from(byDate.values()).sort((a, b) =>
|
||||
a.date.localeCompare(b.date),
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||
const bySeries = new Map<string, SeriesSummary>();
|
||||
for (const row of rows) {
|
||||
const key = row.clientId;
|
||||
const current =
|
||||
bySeries.get(key) ??
|
||||
({
|
||||
key,
|
||||
clientLabel: row.clientName || row.clientId,
|
||||
loginRequests: 0,
|
||||
uniqueSubjects: 0,
|
||||
} satisfies SeriesSummary);
|
||||
current.loginRequests += row.loginRequests;
|
||||
current.uniqueSubjects = Math.max(
|
||||
current.uniqueSubjects,
|
||||
row.uniqueSubjects,
|
||||
);
|
||||
bySeries.set(key, current);
|
||||
}
|
||||
return Array.from(bySeries.values())
|
||||
.sort((a, b) => b.loginRequests - a.loginRequests)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
function parseDateParts(date: string) {
|
||||
const parts = date.split("-");
|
||||
if (parts.length === 3) {
|
||||
return {
|
||||
year: Number(parts[0]),
|
||||
month: Number(parts[1]),
|
||||
day: Number(parts[2]),
|
||||
monthText: parts[1],
|
||||
dayText: parts[2],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getISOWeekNumber(year: number, month: number, day: number) {
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
const dayOfWeek = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
}
|
||||
|
||||
function getISOWeekThursday(year: number, month: number, day: number) {
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
const dayOfWeek = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatPeriodLabel(date: string, period: RPUsagePeriod) {
|
||||
const parts = parseDateParts(date);
|
||||
if (!parts) {
|
||||
return date;
|
||||
}
|
||||
if (period === "month") {
|
||||
return `${parts.monthText}월`;
|
||||
}
|
||||
if (period === "week") {
|
||||
const weekNumber = String(
|
||||
getISOWeekNumber(parts.year, parts.month, parts.day),
|
||||
).padStart(2, "0");
|
||||
const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day);
|
||||
const weekMonth = weekThursday.getUTCMonth() + 1;
|
||||
const weekDay = weekThursday.getUTCDate();
|
||||
const weekMonthText = String(weekMonth).padStart(2, "0");
|
||||
const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7)));
|
||||
return `${weekNumber}(${weekMonthText}월${weekOfMonth}주)`;
|
||||
}
|
||||
return `${parts.monthText}.${parts.dayText}`;
|
||||
}
|
||||
|
||||
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(
|
||||
"msg.admin.overview.description",
|
||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||
"ui.admin.integrity.fetch_error",
|
||||
"정합성 최종 검증 결과를 불러오지 못했습니다.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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;
|
||||
}) {
|
||||
const daily = summarizeDaily(rows);
|
||||
const series = summarizeSeries(rows);
|
||||
const chartWidth = 720;
|
||||
const chartHeight = 230;
|
||||
const padX = 48;
|
||||
const padTop = 32;
|
||||
const padBottom = 34;
|
||||
const innerWidth = chartWidth - padX * 2;
|
||||
const innerHeight = chartHeight - padTop - padBottom;
|
||||
const maxValue = Math.max(
|
||||
1,
|
||||
...daily.map((point) => point.loginRequests + point.otherRequests),
|
||||
...daily.map((point) => point.loginRequests),
|
||||
);
|
||||
const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth;
|
||||
const barWidth = Math.min(28, Math.max(10, slot * 0.42));
|
||||
const y = (value: number) =>
|
||||
padTop + innerHeight - (value / maxValue) * innerHeight;
|
||||
const x = (index: number) => padX + slot * index + slot / 2;
|
||||
const linePoints = daily
|
||||
.map((point, index) => `${x(index)},${y(point.loginRequests)}`)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<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>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("ui.admin.overview.summary.total_tenants", "총 테넌트")}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-primary/10 p-2 text-primary">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
활성화된 테넌트 수
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-blue-500/10 p-2 text-blue-500">
|
||||
<ShieldCheck size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
등록된 OIDC 앱
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</RoleGuard>
|
||||
{filters}
|
||||
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{daily.length === 0 ? (
|
||||
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
||||
표시할 RP 이용 집계가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<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"
|
||||
/>
|
||||
{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 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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalOverviewPage() {
|
||||
const [period, setPeriod] = useState<RPUsagePeriod>("day");
|
||||
const [selectedTenantIds, setSelectedTenantIds] = useState<string[]>([]);
|
||||
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["admin-overview-stats"],
|
||||
queryFn: fetchAdminOverviewStats,
|
||||
retry: false,
|
||||
});
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["admin-overview-tenant-options"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
retry: false,
|
||||
});
|
||||
const tenantOptions = useMemo(() => {
|
||||
return (tenantsQuery.data?.items ?? []).filter(
|
||||
(tenant) => tenant.type === "COMPANY",
|
||||
);
|
||||
}, [tenantsQuery.data?.items]);
|
||||
const usageQuery = useQuery({
|
||||
queryKey: ["admin-rp-usage-daily", usageDays, period],
|
||||
queryFn: () =>
|
||||
fetchAdminRPUsageDaily({
|
||||
days: usageDays,
|
||||
period,
|
||||
}),
|
||||
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>
|
||||
<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],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-in fade-in duration-500">
|
||||
<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(
|
||||
"ui.admin.overview.summary.audit_events_24h",
|
||||
"최근 감사 로그 (24h)",
|
||||
"msg.admin.overview.description",
|
||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500">
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
발생한 이벤트 수
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("ui.admin.overview.summary.policy_gate", "정책 상태")}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-green-500/10 p-2 text-green-500">
|
||||
<Database size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600 dark:text-green-500">
|
||||
Active
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
접근 제어 정상 동작
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold tracking-tight">
|
||||
{t("ui.admin.overview.quick_links.title", "빠른 작업")}
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Link
|
||||
to="/tenants/new"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
<PlusCircle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-primary">
|
||||
테넌트 추가
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
새로운 조직이나 그룹을 생성합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</RoleGuard>
|
||||
|
||||
<Link
|
||||
to="/users"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500 transition-colors group-hover:bg-blue-500 group-hover:text-white">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-blue-500">
|
||||
사용자 관리
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
전체 사용자를 조회하고 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Link
|
||||
to="/api-keys"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-purple-500/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 text-purple-500 transition-colors group-hover:bg-purple-500 group-hover:text-white">
|
||||
<Key size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-purple-500">
|
||||
API 키 관리
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
시스템 연동을 위한 키를 발급합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</RoleGuard>
|
||||
|
||||
<Link
|
||||
to="/audit-logs"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-orange-500/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500/10 text-orange-500 transition-colors group-hover:bg-orange-500 group-hover:text-white">
|
||||
<Activity size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-orange-500">
|
||||
감사 로그
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
보안 이벤트를 모니터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<OverviewMetric
|
||||
icon={<Users size={14} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.total_tenants",
|
||||
"전체 테넌트 수",
|
||||
)}
|
||||
value={metric(visibleTenantCount ?? stats?.totalTenants)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<ShieldCheck size={14} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.oidc_clients",
|
||||
"OIDC 클라이언트",
|
||||
)}
|
||||
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} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.audit_events_24h",
|
||||
"24시간 이벤트",
|
||||
)}
|
||||
value={metric(stats?.auditEvents24h)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Database size={14} />}
|
||||
label={t("ui.admin.overview.summary.policy_gate", "정책 상태")}
|
||||
value="Active"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{usageQuery.isError ? (
|
||||
<section className="space-y-2">
|
||||
<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`가 준비되면 이 영역에 일 단위
|
||||
그래프가 표시됩니다.
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<RPUsageMixedChart
|
||||
rows={filteredUsageRows}
|
||||
periodControls={periodControls}
|
||||
filters={chartFilters}
|
||||
period={period}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<div className="pt-4">
|
||||
<PermissionChecker />
|
||||
</div>
|
||||
<IntegrityOverviewSummary />
|
||||
</RoleGuard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckCircle2, ShieldAlert, XCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import apiClient from "../../../lib/apiClient";
|
||||
|
||||
type CheckPermissionResponse = {
|
||||
allowed: boolean;
|
||||
query: {
|
||||
namespace: string;
|
||||
object: string;
|
||||
relation: string;
|
||||
subject: string;
|
||||
};
|
||||
};
|
||||
|
||||
function PermissionChecker() {
|
||||
const [namespace, setNamespace] = useState("Tenant");
|
||||
const [object, setObject] = useState("");
|
||||
const [relation, setRelation] = useState("manage");
|
||||
const [subject, setSubject] = useState("");
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.get<CheckPermissionResponse>(
|
||||
"/v1/admin/debug/check-permission",
|
||||
{
|
||||
params: { namespace, object, relation, subject },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkMutation.data;
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--color-panel)] border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldAlert size={20} className="text-primary" />
|
||||
ReBAC 권한 검증 도구
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 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>
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Relation</Label>
|
||||
<Input
|
||||
placeholder="view, manage, admins..."
|
||||
value={relation}
|
||||
onChange={(e) => setRelation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Object ID</Label>
|
||||
<Input
|
||||
placeholder="Tenant UUID 등"
|
||||
value={object}
|
||||
onChange={(e) => setObject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subject (User:ID)</Label>
|
||||
<Input
|
||||
placeholder="User:uuid 또는 Namespace:ID#Relation"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={!object || !subject || checkMutation.isPending}
|
||||
className="w-full md:w-auto px-12"
|
||||
>
|
||||
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{checkMutation.isSuccess && result && (
|
||||
<div
|
||||
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
|
||||
result.allowed
|
||||
? "bg-green-500/10 border-green-500/50 text-green-600"
|
||||
: "bg-destructive/10 border-destructive/50 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{result.allowed ? (
|
||||
<>
|
||||
<CheckCircle2 size={48} />
|
||||
<div className="text-xl font-bold">Access ALLOWED</div>
|
||||
<p className="text-sm opacity-80 text-center">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속
|
||||
포함)
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle size={48} />
|
||||
<div className="text-xl font-bold">Access DENIED</div>
|
||||
<p className="text-sm opacity-80 text-center">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionChecker;
|
||||
122
adminfront/src/features/projections/UserProjectionPage.test.tsx
Normal file
122
adminfront/src/features/projections/UserProjectionPage.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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 {
|
||||
fetchUserProjectionStatus,
|
||||
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", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: currentRole })),
|
||||
fetchUserProjectionStatus: vi.fn(async () => ({
|
||||
name: "kratos_users",
|
||||
status: "ready",
|
||||
ready: true,
|
||||
lastSyncedAt: "2026-05-11T03:00:00Z",
|
||||
updatedAt: "2026-05-11T03:00:10Z",
|
||||
projectedUsers: 152,
|
||||
})),
|
||||
reconcileUserProjection: vi.fn(async () => ({
|
||||
status: "success",
|
||||
syncedUsers: 152,
|
||||
updatedAt: "2026-05-11T03:01:00Z",
|
||||
})),
|
||||
resetUserProjection: vi.fn(async () => ({
|
||||
status: "success",
|
||||
syncedUsers: 152,
|
||||
updatedAt: "2026-05-11T03:02:00Z",
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<UserProjectionPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("UserProjectionPage", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
});
|
||||
|
||||
it("renders projection status for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("사용자 동기화 관리"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Kratos 사용자 동기화"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("준비됨")).toBeInTheDocument();
|
||||
expect(screen.getByText("152")).toBeInTheDocument();
|
||||
expect(fetchUserProjectionStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs reconcile and reset actions for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("사용자 동기화 관리");
|
||||
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetUserProjection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks non-super admins", async () => {
|
||||
currentRole = "tenant_admin";
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("사용자 동기화 관리"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders localized labels in English", async () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("User Projection Management"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Review and sync the Kratos user read model."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Re-sync")).toBeInTheDocument();
|
||||
expect(await screen.findByText("ready")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
282
adminfront/src/features/projections/UserProjectionPage.tsx
Normal file
282
adminfront/src/features/projections/UserProjectionPage.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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";
|
||||
import {
|
||||
fetchUserProjectionStatus,
|
||||
reconcileUserProjection,
|
||||
resetUserProjection,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { getAdminDateLocale } from "../../lib/locale";
|
||||
|
||||
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 ProjectionStatusBadge({
|
||||
ready,
|
||||
status,
|
||||
}: {
|
||||
ready: boolean;
|
||||
status: string;
|
||||
}) {
|
||||
if (ready) {
|
||||
return (
|
||||
<Badge variant="success">
|
||||
{t("ui.admin.user_projection.status.ready", "ready")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === "failed") {
|
||||
return (
|
||||
<Badge variant="warning">
|
||||
{t("ui.admin.user_projection.status.failed", "failed")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{status
|
||||
? status
|
||||
: t("ui.admin.user_projection.status.not_ready", "not ready")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function UserProjectionContent() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["user-projection-status"],
|
||||
queryFn: fetchUserProjectionStatus,
|
||||
});
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["user-projection-status"],
|
||||
});
|
||||
};
|
||||
|
||||
const reconcileMutation = useMutation({
|
||||
mutationFn: reconcileUserProjection,
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: resetUserProjection,
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const handleReset = () => {
|
||||
const confirmed = window.confirm(
|
||||
t(
|
||||
"msg.admin.user_projection.reset_confirm",
|
||||
"Rebuild user projection from the Kratos source of truth?",
|
||||
),
|
||||
);
|
||||
if (confirmed) {
|
||||
resetMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const isWorking = reconcileMutation.isPending || resetMutation.isPending;
|
||||
const actionResult = reconcileMutation.data ?? resetMutation.data;
|
||||
const actionError = reconcileMutation.error ?? resetMutation.error;
|
||||
|
||||
return (
|
||||
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t(
|
||||
"ui.admin.user_projection.title",
|
||||
"User Projection Management",
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.user_projection.subtitle",
|
||||
"Review and sync the Kratos user read model.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => reconcileMutation.mutate()}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleReset}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
{t(
|
||||
"ui.admin.user_projection.actions.reset",
|
||||
"Reset and rebuild",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{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.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">
|
||||
{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 ||
|
||||
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>
|
||||
<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">
|
||||
{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">
|
||||
{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">
|
||||
{t("ui.admin.user_projection.summary.status", "Status")}
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<ProjectionStatusBadge
|
||||
ready={data?.ready ?? false}
|
||||
status={data?.status ?? "unknown"}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{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">
|
||||
{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">
|
||||
{t(
|
||||
"ui.admin.user_projection.summary.updated_at",
|
||||
"Updated at",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.updatedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{data?.lastError ? (
|
||||
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
|
||||
<span>{data.lastError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserProjectionPage() {
|
||||
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.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 />
|
||||
</RoleGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DomainTagInput } from "./DomainTagInput";
|
||||
|
||||
describe("DomainTagInput", () => {
|
||||
it("shows a clear duplicate tenant warning and adds the domain after confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const onConfirmedConflictsChange = vi.fn();
|
||||
|
||||
render(
|
||||
<DomainTagInput
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
tenants={[
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "한맥가족",
|
||||
slug: "hanmac-family",
|
||||
type: "COMPANY",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: ["samaneng.com"],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
]}
|
||||
currentTenantId="tenant-2"
|
||||
confirmedConflicts={[]}
|
||||
onConfirmedConflictsChange={onConfirmedConflictsChange}
|
||||
placeholder="example.com"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("example.com"),
|
||||
"samaneng.com ",
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "계속 진행" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["samaneng.com"]);
|
||||
expect(onConfirmedConflictsChange).toHaveBeenCalledWith(["samaneng.com"]);
|
||||
});
|
||||
});
|
||||
187
adminfront/src/features/tenants/components/DomainTagInput.tsx
Normal file
187
adminfront/src/features/tenants/components/DomainTagInput.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
type DomainConflict,
|
||||
findDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
normalizeDomainTokens,
|
||||
} from "../utils/domainTags";
|
||||
|
||||
type DomainTagInputProps = {
|
||||
id?: string;
|
||||
value: string[];
|
||||
onChange: (domains: string[]) => void;
|
||||
tenants?: TenantSummary[];
|
||||
currentTenantId?: string;
|
||||
confirmedConflicts?: string[];
|
||||
onConfirmedConflictsChange?: (domains: string[]) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function DomainTagInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
tenants = [],
|
||||
currentTenantId,
|
||||
confirmedConflicts = [],
|
||||
onConfirmedConflictsChange,
|
||||
placeholder,
|
||||
}: DomainTagInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const addConfirmedConflict = (domain: string) => {
|
||||
if (!confirmedConflicts.includes(domain)) {
|
||||
onConfirmedConflictsChange?.([...confirmedConflicts, domain]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeConfirmedConflict = (domain: string) => {
|
||||
if (confirmedConflicts.includes(domain)) {
|
||||
onConfirmedConflictsChange?.(
|
||||
confirmedConflicts.filter((item) => item !== domain),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const addDomain = (domain: string, confirmed = false) => {
|
||||
if (value.includes(domain)) {
|
||||
return;
|
||||
}
|
||||
onChange([...value, domain]);
|
||||
if (confirmed) {
|
||||
addConfirmedConflict(domain);
|
||||
}
|
||||
};
|
||||
|
||||
const tokenizeInput = () => {
|
||||
const tokens = normalizeDomainTokens(input);
|
||||
if (tokens.length === 0) {
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
if (value.includes(token)) {
|
||||
continue;
|
||||
}
|
||||
const conflict = findDomainConflict(token, tenants, currentTenantId);
|
||||
if (conflict && !confirmedConflicts.includes(token)) {
|
||||
setPendingConflict(conflict);
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
addDomain(token, confirmedConflicts.includes(token));
|
||||
}
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const removeDomain = (domain: string) => {
|
||||
onChange(value.filter((item) => item !== domain));
|
||||
removeConfirmedConflict(domain);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-10 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-within:ring-1 focus-within:ring-ring">
|
||||
{value.map((domain) => (
|
||||
<Badge
|
||||
key={domain}
|
||||
variant={confirmedConflicts.includes(domain) ? "warning" : "muted"}
|
||||
className="gap-1 rounded-md"
|
||||
>
|
||||
<span>{domain}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
||||
onClick={() => removeDomain(domain)}
|
||||
aria-label={t("ui.common.remove", "삭제")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<Input
|
||||
id={id}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onBlur={tokenizeInput}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === " " ||
|
||||
event.key === "Enter" ||
|
||||
event.key === "," ||
|
||||
event.key === ";"
|
||||
) {
|
||||
event.preventDefault();
|
||||
tokenizeInput();
|
||||
}
|
||||
}}
|
||||
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0"
|
||||
placeholder={value.length === 0 ? placeholder : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={pendingConflict !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingConflict(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.domain_conflict.title", "도메인 충돌")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingConflict
|
||||
? t(
|
||||
"ui.admin.tenants.domain_conflict.description",
|
||||
formatDomainConflictMessage(pendingConflict),
|
||||
)
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPendingConflict(null)}
|
||||
>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (pendingConflict) {
|
||||
addDomain(pendingConflict.domain, true);
|
||||
}
|
||||
setPendingConflict(null);
|
||||
}}
|
||||
>
|
||||
{t("ui.common.continue", "계속 진행")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type ImportResult,
|
||||
fetchImportProgress,
|
||||
importOrgChart,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
interface OrgChartUploadModalProps {
|
||||
tenantId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function OrgChartUploadModal({
|
||||
tenantId,
|
||||
onSuccess,
|
||||
}: OrgChartUploadModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [result, setResult] = React.useState<ImportResult | null>(null);
|
||||
const [progressId, setProgressId] = React.useState<string | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ file, pid }: { file: File; pid: string }) =>
|
||||
importOrgChart(tenantId, file, pid),
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
setProgressId(null);
|
||||
if (data.errors.length === 0) {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.org.import_success",
|
||||
"조직도가 성공적으로 업로드되었습니다.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t(
|
||||
"msg.admin.org.import_partial_success",
|
||||
"일부 데이터 업로드 중 오류가 발생했습니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
setProgressId(null);
|
||||
toast.error(t("msg.admin.org.import_error", "조직도 업로드 실패"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data: progressData } = useQuery({
|
||||
queryKey: ["importProgress", progressId],
|
||||
queryFn: () =>
|
||||
progressId ? fetchImportProgress(tenantId, progressId) : null,
|
||||
enabled: !!progressId && mutation.isPending,
|
||||
refetchInterval: 500,
|
||||
});
|
||||
const percent =
|
||||
progressData && progressData.total > 0
|
||||
? Math.round((progressData.current / progressData.total) * 100)
|
||||
: 0;
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
setResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (file) {
|
||||
const pid = Math.random().toString(36).substring(2, 15);
|
||||
setProgressId(pid);
|
||||
mutation.mutate({ file, pid });
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = "이메일,이름,소속,직급,직무,구분,그룹,디비젼,팀,셀";
|
||||
const example = `test1@example.com,홍길동,한맥,수석,기획,팀장,전략그룹,기획실,인사팀,-
|
||||
test2@example.com,이몽룡,삼안,선임,개발,팀원,기술본부,개발실,개발1팀,A셀`;
|
||||
const blob = new Blob([`\uFEFF${headers}\n${example}`], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "org_chart_template.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) {
|
||||
setFile(null);
|
||||
setResult(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Upload size={14} />
|
||||
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV/XLSX)")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.org.import_title", "조직도 일괄 등록")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.org.import_description",
|
||||
"CSV 또는 XLSX 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!result ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="gap-2"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
<Download size={14} />
|
||||
{t("ui.admin.org.download_template", "템플릿 다운로드")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv, .xlsx, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{file
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
</Button>
|
||||
</div>
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="text-primary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{file.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mutation.isPending && progressId && (
|
||||
<div className="w-full mt-2 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<div className="flex justify-between text-xs mb-1 font-medium text-muted-foreground">
|
||||
<span>데이터 처리 중...</span>
|
||||
<span>
|
||||
{percent}% ({progressData?.current || 0} /{" "}
|
||||
{progressData?.total || 0})
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden relative">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-all duration-300 ease-out absolute top-0 left-0"
|
||||
style={{ width: `${Math.max(5, percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-primary/5 border border-primary/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
전체 행
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{result.totalRows}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
처리 완료
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{result.processed}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
사용자 생성/업데이트
|
||||
</div>
|
||||
<div className="text-xl font-bold text-blue-600">
|
||||
{result.userCreated} / {result.userUpdated}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-orange-500/5 border border-orange-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
조직(테넌트) 생성
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{result.tenantCreated}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
|
||||
<AlertCircle size={16} />
|
||||
오류 목록 ({result.errors.length})
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-md p-2 bg-destructive/5 text-xs font-mono space-y-1">
|
||||
{result.errors.map((err, idx) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Errors are a static list returned from the server.
|
||||
key={idx}
|
||||
className="text-destructive border-b border-destructive/10 pb-1 last:border-0"
|
||||
>
|
||||
{" "}
|
||||
{err}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!result ? (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || mutation.isPending}
|
||||
className="w-full sm:w-auto relative"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
처리 중 ({percent}%)
|
||||
</>
|
||||
) : (
|
||||
t("ui.admin.org.start_import", "임포트 시작")
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setOpen(false)} className="w-full sm:w-auto">
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { ParentTenantSelector } from "./ParentTenantSelector";
|
||||
|
||||
const tenants: TenantSummary[] = [
|
||||
{
|
||||
id: "company-1",
|
||||
type: "COMPANY",
|
||||
name: "Saman Engineering",
|
||||
slug: "saman",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "group-1",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "Hanmac Family",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
describe("ParentTenantSelector picker", () => {
|
||||
it("opens an org-chart picker modal and applies tenant selection messages", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label="상위 테넌트"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
tenants={tenants}
|
||||
noneLabel="없음"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src");
|
||||
expect(pickerSrc).toContain("/login");
|
||||
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
|
||||
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
type: "tenant",
|
||||
id: "company-1",
|
||||
name: "Saman Engineering",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
|
||||
});
|
||||
|
||||
it("keeps the current tenant out of picker message selections", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label="상위 테넌트"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
tenants={tenants}
|
||||
noneLabel="없음"
|
||||
excludeTenantId="company-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
type: "tenant",
|
||||
id: "company-1",
|
||||
name: "Saman Engineering",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onChange).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("selects a non-hanmac parent from the local tenant picker", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label="상위 테넌트"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
tenants={tenants}
|
||||
noneLabel="없음"
|
||||
orgChartPickerLabel="한맥가족에서 선택"
|
||||
localPickerLabel="다른 테넌트 선택"
|
||||
localTenantFilter={(tenant) => tenant.slug !== "hanmac-family"}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "다른 테넌트 선택" }));
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("테넌트 이름 또는 슬러그 검색"),
|
||||
{ target: { value: "saman" } },
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Saman Engineering/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("company-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
const tenants: TenantSummary[] = [
|
||||
{
|
||||
id: "company-1",
|
||||
type: "COMPANY",
|
||||
name: "Saman Engineering",
|
||||
slug: "saman",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "group-1",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "Hanmac Family",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "org-1",
|
||||
type: "ORGANIZATION",
|
||||
name: "기획부",
|
||||
slug: "planning",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
describe("filterParentTenants", () => {
|
||||
it("searches parent candidates by name and slug", () => {
|
||||
expect(
|
||||
filterParentTenants(tenants, "saman", false).map((t) => t.id),
|
||||
).toEqual(["company-1"]);
|
||||
expect(
|
||||
filterParentTenants(tenants, "family", false).map((t) => t.id),
|
||||
).toEqual(["group-1"]);
|
||||
});
|
||||
|
||||
it("can limit parent candidates to company and company group tenants", () => {
|
||||
expect(filterParentTenants(tenants, "", true).map((t) => t.id)).toEqual([
|
||||
"company-1",
|
||||
"group-1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Building2, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "../../users/orgChartPicker";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
type ParentTenantSelectorProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
tenants: TenantSummary[];
|
||||
noneLabel: string;
|
||||
helpText?: string;
|
||||
excludeTenantId?: string;
|
||||
labelAction?: ReactNode;
|
||||
contextLabel?: string;
|
||||
orgChartPickerLabel?: string;
|
||||
localPickerLabel?: string;
|
||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||
};
|
||||
|
||||
export function ParentTenantSelector({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
tenants,
|
||||
noneLabel,
|
||||
helpText,
|
||||
excludeTenantId,
|
||||
labelAction,
|
||||
contextLabel,
|
||||
orgChartPickerLabel,
|
||||
localPickerLabel,
|
||||
localTenantFilter,
|
||||
}: ParentTenantSelectorProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
||||
const [localSearch, setLocalSearch] = useState("");
|
||||
const selectedTenant = tenants.find((tenant) => tenant.id === value);
|
||||
const localCandidates = filterParentTenants(
|
||||
localTenantFilter ? tenants.filter(localTenantFilter) : tenants,
|
||||
localSearch,
|
||||
false,
|
||||
excludeTenantId,
|
||||
);
|
||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
import.meta.env.ORGFRONT_URL,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const selection = parseOrgChartTenantSelection(event.data);
|
||||
if (!selection) return;
|
||||
if (excludeTenantId && selection.id === excludeTenantId) return;
|
||||
|
||||
onChange(selection.id);
|
||||
setPickerOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [excludeTenantId, onChange, pickerOpen]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
|
||||
<Label className="text-sm font-semibold">{label}</Label>
|
||||
{labelAction}
|
||||
</div>
|
||||
<input id={id} name={id} type="hidden" value={value} readOnly />
|
||||
<div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
|
||||
<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 && (
|
||||
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{localPickerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{localPickerLabel ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_description",
|
||||
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={localSearch}
|
||||
onChange={(event) => setLocalSearch(event.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.parent.local_search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색",
|
||||
)}
|
||||
/>
|
||||
<div className="max-h-[360px] space-y-2 overflow-y-auto">
|
||||
{localCandidates.map((tenant) => (
|
||||
<Button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-start px-3 py-2 text-left"
|
||||
onClick={() => {
|
||||
onChange(tenant.id);
|
||||
setLocalPickerOpen(false);
|
||||
setLocalSearch("");
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">
|
||||
{tenant.name}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{tenant.slug} · {tenant.type}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{localCandidates.length === 0 && (
|
||||
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_empty",
|
||||
"선택할 수 있는 테넌트가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{selectedTenant ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedTenant.slug} · {selectedTenant.type}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onChange("")}
|
||||
aria-label={noneLabel}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{noneLabel}</span>
|
||||
)}
|
||||
{contextLabel && (
|
||||
<span className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{contextLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{helpText && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
|
||||
)}
|
||||
</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,9 +1,8 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Building2, Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -15,26 +14,92 @@ 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";
|
||||
import {
|
||||
type ServerDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
} from "../utils/domainTags";
|
||||
import {
|
||||
ORG_UNIT_TYPE_OPTIONS,
|
||||
TENANT_VISIBILITY_OPTIONS,
|
||||
type TenantVisibility,
|
||||
mergeTenantOrgConfig,
|
||||
shouldAllowHanmacOrgConfig,
|
||||
} from "../utils/orgConfig";
|
||||
|
||||
type AdminFrontTestHooks = {
|
||||
selectTenantParent?: (tenantId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function TenantCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("COMPANY");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [parentId, setParentId] = useState(
|
||||
() => searchParams.get("parentId") ?? "",
|
||||
);
|
||||
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
|
||||
const [orgUnitType, setOrgUnitType] = useState("");
|
||||
const [visibility, setVisibility] = useState<TenantVisibility>("public");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
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);
|
||||
const canConfigureHanmacOrg = useMemo(() => {
|
||||
if (!selectedParentTenant) return false;
|
||||
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
|
||||
return true;
|
||||
}
|
||||
return shouldAllowHanmacOrgConfig(selectedParentTenant, tenants);
|
||||
}, [selectedParentTenant, tenants]);
|
||||
const canEditTenantDetails =
|
||||
parentStepConfirmed || Boolean(selectedParentTenant);
|
||||
const parentContextLabel = selectedParentTenant
|
||||
? canConfigureHanmacOrg
|
||||
? t(
|
||||
"ui.admin.tenants.create.parent_context.hanmac",
|
||||
"한맥가족 하위 테넌트",
|
||||
)
|
||||
: t("ui.admin.tenants.create.parent_context.general", "일반 하위 테넌트")
|
||||
: parentStepConfirmed
|
||||
? t("ui.admin.tenants.create.parent_context.root", "최상위 테넌트")
|
||||
: t(
|
||||
"ui.admin.tenants.create.parent_context.pick_required",
|
||||
"상위 테넌트 선택 필요",
|
||||
);
|
||||
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: () =>
|
||||
mutationFn: (overrideForceDomains?: string[]) =>
|
||||
createTenant({
|
||||
name,
|
||||
type,
|
||||
@@ -42,14 +107,37 @@ function TenantCreatePage() {
|
||||
parentId: parentId || undefined,
|
||||
description: description || undefined,
|
||||
status,
|
||||
domains: domains
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d !== ""),
|
||||
domains,
|
||||
config: canConfigureHanmacOrg
|
||||
? mergeTenantOrgConfig(undefined, { orgUnitType, visibility })
|
||||
: undefined,
|
||||
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
navigate("/tenants");
|
||||
},
|
||||
onError: (
|
||||
err: AxiosError<{
|
||||
code?: string;
|
||||
error?: string;
|
||||
conflicts?: ServerDomainConflict[];
|
||||
}>,
|
||||
) => {
|
||||
const conflicts = err.response?.data?.conflicts ?? [];
|
||||
if (
|
||||
err.response?.data?.code === "tenant_domain_conflict" &&
|
||||
conflicts.length > 0
|
||||
) {
|
||||
const nextForceDomains = Array.from(
|
||||
new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]),
|
||||
);
|
||||
const message = conflicts.map(formatDomainConflictMessage).join("\n");
|
||||
if (window.confirm(message)) {
|
||||
setForceDomainConflicts(nextForceDomains);
|
||||
mutation.mutate(nextForceDomains);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
|
||||
@@ -87,152 +175,271 @@ function TenantCreatePage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-name" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.name_placeholder",
|
||||
"테넌트 이름을 입력하세요",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-type" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
|
||||
</Label>
|
||||
<select
|
||||
id="tenant-type"
|
||||
name="type"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
<option value="COMPANY">
|
||||
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
||||
</option>
|
||||
<option value="COMPANY_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.company_group",
|
||||
"COMPANY_GROUP (그룹사/지주사)",
|
||||
)}
|
||||
</option>
|
||||
<option value="USER_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.user_group",
|
||||
"USER_GROUP (내부 부서/팀)",
|
||||
)}
|
||||
</option>
|
||||
<option value="PERSONAL">
|
||||
{t(
|
||||
"domain.tenant_type.personal",
|
||||
"PERSONAL (개인 워크스페이스)",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parentId" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
name="parentId"
|
||||
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={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{parentQuery.data?.items?.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
name="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.slug_placeholder",
|
||||
"tenant-slug",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="tenant-description"
|
||||
className="text-sm font-semibold"
|
||||
<div
|
||||
data-testid="tenant-parent-org-config-layout"
|
||||
className="grid gap-4 md:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
data-testid="tenant-parent-picker-slot"
|
||||
className={
|
||||
canConfigureHanmacOrg ? "md:col-span-2" : "md:col-span-4"
|
||||
}
|
||||
>
|
||||
{t("ui.admin.tenants.create.form.description", "설명")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="tenant-description"
|
||||
name="description"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-domains" className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.tenants.create.form.domains_label",
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="tenant-domains"
|
||||
name="domains"
|
||||
value={domains}
|
||||
onChange={(e) => setDomains(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.domains_placeholder",
|
||||
"example.com, example.kr",
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.create.form.domains_help",
|
||||
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.status", "상태")}
|
||||
</Label>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label={t(
|
||||
"ui.admin.tenants.create.form.parent",
|
||||
"상위 테넌트 (선택)",
|
||||
)}
|
||||
value={parentId}
|
||||
onChange={handleParentChange}
|
||||
tenants={tenants}
|
||||
noneLabel={t("ui.common.none", "없음")}
|
||||
contextLabel={parentContextLabel}
|
||||
orgChartPickerLabel={t(
|
||||
"ui.admin.tenants.create.form.pick_hanmac_parent",
|
||||
"한맥가족에서 선택",
|
||||
)}
|
||||
localPickerLabel={t(
|
||||
"ui.admin.tenants.create.form.pick_other_parent",
|
||||
"다른 테넌트 선택",
|
||||
)}
|
||||
localTenantFilter={(tenant) =>
|
||||
tenant.slug.toLowerCase() !== "hanmac-family" &&
|
||||
!shouldAllowHanmacOrgConfig(tenant, tenants)
|
||||
}
|
||||
labelAction={
|
||||
!selectedParentTenant ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={parentStepConfirmed ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setParentStepConfirmed(true)}
|
||||
>
|
||||
{t(
|
||||
"ui.admin.tenants.create.form.root_tenant",
|
||||
"최상위 테넌트로 생성",
|
||||
)}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => setStatus("active")}
|
||||
data-testid="tenant-test-select-hanmac-parent"
|
||||
hidden
|
||||
onClick={() => handleParentChange("family-1")}
|
||||
>
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => setStatus("inactive")}
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
test-select-hanmac-parent
|
||||
</button>
|
||||
</div>
|
||||
{canConfigureHanmacOrg && (
|
||||
<>
|
||||
<div
|
||||
data-testid="tenant-org-unit-type-slot"
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label
|
||||
htmlFor="tenant-org-unit-type"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.tenants.profile.org_unit_type",
|
||||
"조직 세부타입",
|
||||
)}
|
||||
</Label>
|
||||
<select
|
||||
id="tenant-org-unit-type"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={orgUnitType}
|
||||
onChange={(event) => setOrgUnitType(event.target.value)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div data-testid="tenant-visibility-slot" className="space-y-2">
|
||||
<Label
|
||||
htmlFor="tenant-visibility"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
|
||||
</Label>
|
||||
<select
|
||||
id="tenant-visibility"
|
||||
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={visibility}
|
||||
onChange={(event) =>
|
||||
setVisibility(event.target.value as TenantVisibility)
|
||||
}
|
||||
>
|
||||
{TENANT_VISIBILITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{canEditTenantDetails && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-name" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.name_placeholder",
|
||||
"테넌트 이름을 입력하세요",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="tenant-type"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
|
||||
</Label>
|
||||
<select
|
||||
id="tenant-type"
|
||||
name="type"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
<option value="COMPANY">
|
||||
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
||||
</option>
|
||||
<option value="COMPANY_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.company_group",
|
||||
"COMPANY_GROUP (그룹사/지주사)",
|
||||
)}
|
||||
</option>
|
||||
<option value="ORGANIZATION">
|
||||
{t(
|
||||
"domain.tenant_type.organization",
|
||||
"ORGANIZATION (정규 조직)",
|
||||
)}
|
||||
</option>
|
||||
<option value="USER_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.user_group",
|
||||
"USER_GROUP (내부 부서/팀)",
|
||||
)}
|
||||
</option>
|
||||
<option value="PERSONAL">
|
||||
{t(
|
||||
"domain.tenant_type.personal",
|
||||
"PERSONAL (개인 워크스페이스)",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
name="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.slug_placeholder",
|
||||
"tenant-slug",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="tenant-description"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t("ui.admin.tenants.create.form.description", "설명")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="tenant-description"
|
||||
name="description"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="tenant-domains"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.tenants.create.form.domains_label",
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<DomainTagInput
|
||||
id="tenant-domains"
|
||||
value={domains}
|
||||
onChange={setDomains}
|
||||
tenants={tenants}
|
||||
confirmedConflicts={forceDomainConflicts}
|
||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.domains_placeholder",
|
||||
"example.com, example.kr",
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.create.form.domains_help",
|
||||
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.status", "상태")}
|
||||
</Label>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => setStatus("active")}
|
||||
>
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => setStatus("inactive")}
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!canEditTenantDetails && (
|
||||
<div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.create.pick_parent_first",
|
||||
"상위 테넌트를 먼저 선택하세요.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
@@ -268,7 +475,7 @@ function TenantCreatePage() {
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
onClick={() => mutation.mutate(undefined)}
|
||||
disabled={mutation.isPending || name.trim() === ""}
|
||||
>
|
||||
{t("ui.common.create", "생성")}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function canShowWorksmobileEntry(tenant?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
parentId?: string | null;
|
||||
}) {
|
||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||
|
||||
describe("TenantDetailPage Worksmobile entry visibility", () => {
|
||||
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
|
||||
expect(
|
||||
canShowWorksmobileEntry({
|
||||
id: "hanmac-family-id",
|
||||
slug: "hanmac-family",
|
||||
parentId: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canShowWorksmobileEntry({
|
||||
id: "hanmac-child-id",
|
||||
slug: "hanmac-family",
|
||||
parentId: "root-id",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
canShowWorksmobileEntry({
|
||||
id: "other-id",
|
||||
slug: "other",
|
||||
parentId: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||
|
||||
function TenantDetailPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
@@ -21,20 +24,52 @@ 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");
|
||||
const isOrganizationTab = location.pathname.includes("/organization");
|
||||
const isWorksmobileTab = location.pathname.includes("/worksmobile");
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{tenantQuery.data?.name ??
|
||||
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
|
||||
</h2>
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-3"
|
||||
data-testid="tenant-detail-title-row"
|
||||
>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{tenantQuery.data?.name ??
|
||||
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
|
||||
</h2>
|
||||
{tenantQuery.data?.id && (
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
data-testid="tenant-detail-uuid"
|
||||
>
|
||||
<code className="select-all rounded-md border border-border bg-muted/40 px-2 py-1 font-mono text-xs text-foreground">
|
||||
{tenantQuery.data.id}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(tenantQuery.data.id);
|
||||
}}
|
||||
aria-label="테넌트 UUID 복사"
|
||||
title="테넌트 UUID 복사"
|
||||
data-testid="tenant-detail-copy-uuid"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"ui.admin.tenants.detail.header_subtitle",
|
||||
@@ -51,6 +86,7 @@ function TenantDetailPage() {
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
!isPermissionsTab &&
|
||||
!location.pathname.includes("/schema") &&
|
||||
!isWorksmobileTab &&
|
||||
!isOrganizationTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -90,6 +126,18 @@ function TenantDetailPage() {
|
||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||
</Link>
|
||||
)}
|
||||
{showWorksmobileEntry && (
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/worksmobile`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
isWorksmobileTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outlet for nested routes */}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import TenantDetailPage from "./TenantDetailPage";
|
||||
|
||||
vi.mock("../../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
|
||||
fetchTenant: vi.fn(async () => ({
|
||||
id: "hanmac-family-id",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
parentId: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderTenantDetailPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["/tenants/hanmac-family-id"]}>
|
||||
<Routes>
|
||||
<Route path="/tenants/:tenantId/*" element={<TenantDetailPage />}>
|
||||
<Route index element={<div>profile</div>} />
|
||||
<Route path="worksmobile" element={<div>worksmobile</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TenantDetailPage Worksmobile navigation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens Worksmobile management in the current admin route", async () => {
|
||||
renderTenantDetailPage();
|
||||
|
||||
const link = await screen.findByRole("link", { name: /Worksmobile/i });
|
||||
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"/tenants/hanmac-family-id/worksmobile",
|
||||
);
|
||||
expect(link).not.toHaveAttribute("target");
|
||||
expect(link).not.toHaveAttribute("rel");
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,16 @@ import {
|
||||
type UseMutationResult,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Shield,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
@@ -18,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 {
|
||||
@@ -27,8 +31,18 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -44,10 +58,11 @@ import {
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
fetchGroups,
|
||||
fetchTenant,
|
||||
fetchUsers,
|
||||
removeGroupMember,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
|
||||
type UserGroupNode = GroupSummary & {
|
||||
children: UserGroupNode[];
|
||||
@@ -224,6 +239,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
function TenantGroupsPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||
@@ -232,13 +248,37 @@ function TenantGroupsPage() {
|
||||
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
|
||||
// Modal States
|
||||
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
|
||||
const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false);
|
||||
const [memberActionTargetUserId, setMemberActionTargetUserId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [userSearchTerm, setUserSearchTerm] = useState("");
|
||||
const [groupSearchTerm, setGroupSearchTerm] = useState("");
|
||||
|
||||
// 테넌트 정보 조회 (slug 획득)
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
const tenantSlug = tenantQuery.data?.slug;
|
||||
|
||||
// 해당 테넌트의 사용자 목록 조회
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["users", { tenantSlug }],
|
||||
queryFn: () => fetchUsers(1000, 0, undefined, tenantSlug),
|
||||
enabled: !!tenantSlug,
|
||||
});
|
||||
const users = usersQuery.data?.items ?? [];
|
||||
|
||||
// 그룹 목록 조회
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ["groups", tenantId],
|
||||
queryFn: () => fetchGroups(tenantId),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
// 그룹 생성
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -319,306 +359,545 @@ function TenantGroupsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// 멤버 이동 (Remove -> Add)
|
||||
const moveMemberMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
sourceGroupId,
|
||||
targetGroupId,
|
||||
userId,
|
||||
}: {
|
||||
sourceGroupId: string;
|
||||
targetGroupId: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
await removeGroupMember(tenantId, sourceGroupId, userId);
|
||||
await addGroupMember(tenantId, targetGroupId, userId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t("msg.admin.groups.members.move_success", "멤버가 이동되었습니다."),
|
||||
);
|
||||
groupsQuery.refetch();
|
||||
setIsMoveMemberModalOpen(false);
|
||||
setMemberActionTargetUserId(null);
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(t("msg.common.error", "오류 발생"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const groupTree = groupsQuery.data
|
||||
? buildGroupTree(groupsQuery.data, tenantId)
|
||||
: [];
|
||||
|
||||
const handleAddSubGroup = (parentId: string) => {
|
||||
setNewGroupParentId(parentId);
|
||||
// Optionally scroll to the create form or highlight it
|
||||
};
|
||||
|
||||
const handleAddMember = (groupId: string) => {
|
||||
const userId = window.prompt(
|
||||
t(
|
||||
"msg.admin.groups.prompt.user_id",
|
||||
"추가할 사용자의 UUID를 입력하세요:",
|
||||
),
|
||||
);
|
||||
if (userId) {
|
||||
addMemberMutation.mutate({ groupId, userId });
|
||||
}
|
||||
};
|
||||
|
||||
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
|
||||
{/* 그룹 생성 폼 */}
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Plus size={16} />{" "}
|
||||
{t("ui.admin.groups.create.title", "새 그룹 생성")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.admin.groups.create.description",
|
||||
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1 overflow-auto">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">
|
||||
{t("ui.admin.groups.form.name_label", "그룹 이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.name_placeholder",
|
||||
"예: 개발팀, 인사팀",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="unitType">
|
||||
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
|
||||
</Label>
|
||||
<Input
|
||||
id="unitType"
|
||||
value={newGroupUnitType}
|
||||
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.unit_level_placeholder",
|
||||
"예: 본부, 팀, 셀",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="parentId">
|
||||
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={newGroupParentId || ""}
|
||||
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{groupsQuery.data?.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="desc">
|
||||
{t("ui.admin.groups.form.desc_label", "설명")}
|
||||
</Label>
|
||||
<Input
|
||||
id="desc"
|
||||
value={newGroupDesc}
|
||||
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.desc_placeholder",
|
||||
"그룹 용도 설명",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!newGroupName || createMutation.isPending}
|
||||
>
|
||||
{t("ui.admin.groups.form.submit", "생성하기")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 그룹 목록 (트리 뷰) */}
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.groups.list.title", "User Groups")}
|
||||
<>
|
||||
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
|
||||
{/* 그룹 생성 폼 */}
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Plus size={16} />{" "}
|
||||
{t("ui.admin.groups.create.title", "새 그룹 생성")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.groups.list.subtitle",
|
||||
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
|
||||
"ui.admin.groups.create.description",
|
||||
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<OrgChartUploadModal
|
||||
tenantId={tenantId}
|
||||
onSuccess={() => groupsQuery.refetch()}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => groupsQuery.refetch()}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupsQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
{t("msg.admin.groups.list.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!groupsQuery.isLoading && groupTree.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.list.empty",
|
||||
"아직 등록된 그룹이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{groupTree.map((node) => (
|
||||
<UserGroupTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
onSelect={setSelectedGroupId}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onDelete={(id) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.groups.list.delete_confirm",
|
||||
"그룹을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
}}
|
||||
onAddSubGroup={handleAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1 overflow-auto">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">
|
||||
{t("ui.admin.groups.form.name_label", "그룹 이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.name_placeholder",
|
||||
"예: 개발팀, 인사팀",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="unitType">
|
||||
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
|
||||
</Label>
|
||||
<Input
|
||||
id="unitType"
|
||||
value={newGroupUnitType}
|
||||
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.unit_level_placeholder",
|
||||
"예: 본부, 팀, 셀",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="parentId">
|
||||
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={newGroupParentId || ""}
|
||||
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{groupsQuery.data?.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="desc">
|
||||
{t("ui.admin.groups.form.desc_label", "설명")}
|
||||
</Label>
|
||||
<Input
|
||||
id="desc"
|
||||
value={newGroupDesc}
|
||||
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.desc_placeholder",
|
||||
"그룹 용도 설명",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!newGroupName || createMutation.isPending}
|
||||
>
|
||||
{t("ui.admin.groups.form.submit", "생성하기")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 그룹 목록 (트리 뷰) */}
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.groups.list.title", "User Groups")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.groups.list.subtitle",
|
||||
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => groupsQuery.refetch()}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
<Table>
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupsQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
{t("msg.admin.groups.list.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!groupsQuery.isLoading && groupTree.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.list.empty",
|
||||
"아직 등록된 그룹이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{groupTree.map((node) => (
|
||||
<UserGroupTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
onSelect={setSelectedGroupId}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onDelete={(id) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.groups.list.delete_confirm",
|
||||
"그룹을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
}}
|
||||
onAddSubGroup={handleAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
|
||||
{currentGroup && (
|
||||
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield size={18} className="text-primary" />
|
||||
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
|
||||
name: currentGroup.name,
|
||||
})}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.admin.groups.detail.members_subtitle",
|
||||
"그룹에 속한 멤버들을 확인하고 관리합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex justify-end mb-4 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setIsAddMemberModalOpen(true)}
|
||||
disabled={addMemberMutation.isPending}
|
||||
>
|
||||
<UserPlus size={14} className="mr-1" />
|
||||
{t("ui.common.add", "멤버 추가")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.members.table.actions", "관리")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentGroup.members?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-4 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.members.empty",
|
||||
"멤버가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{currentGroup.members?.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMemberActionTargetUserId(user.id);
|
||||
setIsMoveMemberModalOpen(true);
|
||||
}}
|
||||
disabled={moveMemberMutation.isPending}
|
||||
title={t("ui.common.move", "이동")}
|
||||
>
|
||||
<ArrowRightLeft
|
||||
size={14}
|
||||
className="text-primary"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.groups.members.remove_confirm",
|
||||
"'{{name}}' 님을 이 그룹에서 제외하시겠습니까?",
|
||||
{ name: user.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeMemberMutation.mutate({
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={removeMemberMutation.isPending}
|
||||
title={t("ui.common.remove", "제거")}
|
||||
>
|
||||
<UserMinus
|
||||
size={14}
|
||||
className="text-destructive"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
|
||||
{currentGroup && (
|
||||
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield size={18} className="text-primary" />
|
||||
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
|
||||
name: currentGroup.name,
|
||||
})}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{/* Add Member Modal */}
|
||||
<Dialog
|
||||
open={isAddMemberModalOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsAddMemberModalOpen(val);
|
||||
if (!val) setUserSearchTerm("");
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"ui.admin.groups.detail.members_subtitle",
|
||||
"그룹에 속한 멤버들을 확인하고 관리합니다.",
|
||||
"msg.admin.groups.members.add_modal_desc",
|
||||
"이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex justify-end mb-4 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAddMember(currentGroup.id)}
|
||||
disabled={addMemberMutation.isPending}
|
||||
>
|
||||
<UserPlus size={14} className="mr-1" />
|
||||
{t("ui.common.add", "멤버 추가")}
|
||||
</Button>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("ui.common.search", "검색...")}
|
||||
className="pl-9 h-9"
|
||||
value={userSearchTerm}
|
||||
onChange={(e) => setUserSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.members.table.remove", "제거")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentGroup.members?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-4 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.members.empty",
|
||||
"멤버가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{currentGroup.members?.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
removeMemberMutation.mutate({
|
||||
<ScrollArea className="h-[250px] rounded-md border p-2">
|
||||
<div className="space-y-1">
|
||||
{usersQuery.isLoading ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t("ui.common.loading", "로딩 중...")}
|
||||
</div>
|
||||
) : (
|
||||
users
|
||||
.filter((u) => {
|
||||
const term = userSearchTerm.toLowerCase();
|
||||
return (
|
||||
u.name.toLowerCase().includes(term) ||
|
||||
u.email.toLowerCase().includes(term)
|
||||
);
|
||||
})
|
||||
.filter(
|
||||
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
|
||||
) // Exclude existing members
|
||||
.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (currentGroup) {
|
||||
addMemberMutation.mutate({
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
})
|
||||
});
|
||||
}
|
||||
disabled={removeMemberMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
}}
|
||||
disabled={addMemberMutation.isPending}
|
||||
>
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{users.length > 0 &&
|
||||
users.filter(
|
||||
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
|
||||
).length === 0 && (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.groups.members.all_added",
|
||||
"모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsAddMemberModalOpen(false)}
|
||||
>
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Move Member Modal */}
|
||||
<Dialog
|
||||
open={isMoveMemberModalOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsMoveMemberModalOpen(val);
|
||||
if (!val) {
|
||||
setMemberActionTargetUserId(null);
|
||||
setGroupSearchTerm("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.groups.members.move_modal_title", "부서 이동")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.groups.members.move_modal_desc",
|
||||
"선택한 멤버를 이동할 대상 그룹을 선택하세요.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("ui.common.search_group", "그룹 검색...")}
|
||||
className="pl-9 h-9"
|
||||
value={groupSearchTerm}
|
||||
onChange={(e) => setGroupSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="h-[250px] rounded-md border p-2">
|
||||
<div className="space-y-1">
|
||||
{groupsQuery.isLoading ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t("ui.common.loading", "로딩 중...")}
|
||||
</div>
|
||||
) : groupsQuery.data && groupsQuery.data.length > 0 ? (
|
||||
groupsQuery.data
|
||||
.filter((g) =>
|
||||
g.name
|
||||
.toLowerCase()
|
||||
.includes(groupSearchTerm.toLowerCase()),
|
||||
)
|
||||
.filter((g) => g.id !== currentGroup?.id) // Exclude current group
|
||||
.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={14} className="text-muted-foreground" />
|
||||
<span className="font-medium">{group.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (currentGroup && memberActionTargetUserId) {
|
||||
moveMemberMutation.mutate({
|
||||
sourceGroupId: currentGroup.id,
|
||||
targetGroupId: group.id,
|
||||
userId: memberActionTargetUserId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={moveMemberMutation.isPending}
|
||||
>
|
||||
{t("ui.common.move", "이동")}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t("msg.admin.groups.list.no_results", "그룹이 없습니다.")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsMoveMemberModalOpen(false)}
|
||||
>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,11 +18,27 @@ import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
approveTenant,
|
||||
deleteTenant,
|
||||
fetchAllTenants,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
updateTenant,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||
import {
|
||||
type ServerDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
} from "../utils/domainTags";
|
||||
import {
|
||||
ORG_UNIT_TYPE_OPTIONS,
|
||||
TENANT_VISIBILITY_OPTIONS,
|
||||
type TenantVisibility,
|
||||
mergeTenantOrgConfig,
|
||||
readTenantOrgConfig,
|
||||
removeTenantOrgConfig,
|
||||
shouldAllowHanmacOrgConfig,
|
||||
} from "../utils/orgConfig";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
|
||||
export function TenantProfilePage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
@@ -42,52 +58,103 @@ export function TenantProfilePage() {
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
const availableParents =
|
||||
parentQuery.data?.items?.filter((t) => t.id !== tenantId) || [];
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("COMPANY");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [orgUnitType, setOrgUnitType] = useState("");
|
||||
const [tenantVisibility, setTenantVisibility] =
|
||||
useState<TenantVisibility>("public");
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantQuery.data) {
|
||||
const orgConfig = readTenantOrgConfig(tenantQuery.data.config);
|
||||
setName(tenantQuery.data.name);
|
||||
setType(tenantQuery.data.type || "COMPANY");
|
||||
setSlug(tenantQuery.data.slug);
|
||||
setDescription(tenantQuery.data.description ?? "");
|
||||
setStatus(tenantQuery.data.status);
|
||||
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
||||
setDomains(tenantQuery.data.domains ?? []);
|
||||
setForceDomainConflicts([]);
|
||||
setParentId(tenantQuery.data.parentId ?? "");
|
||||
setOrgUnitType(orgConfig.orgUnitType);
|
||||
setTenantVisibility(orgConfig.visibility);
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
const allTenants = parentQuery.data?.items ?? [];
|
||||
const orgConfigCandidate = tenantQuery.data
|
||||
? {
|
||||
...tenantQuery.data,
|
||||
parentId: parentId || undefined,
|
||||
slug,
|
||||
}
|
||||
: undefined;
|
||||
const canEditOrgConfig = orgConfigCandidate
|
||||
? shouldAllowHanmacOrgConfig(orgConfigCandidate, [
|
||||
...allTenants,
|
||||
orgConfigCandidate,
|
||||
])
|
||||
: false;
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateTenant(tenantId, {
|
||||
mutationFn: (overrideForceDomains?: string[]) => {
|
||||
const baseConfig = tenantQuery.data?.config;
|
||||
const config = canEditOrgConfig
|
||||
? mergeTenantOrgConfig(baseConfig, {
|
||||
orgUnitType,
|
||||
visibility: tenantVisibility,
|
||||
})
|
||||
: removeTenantOrgConfig(baseConfig);
|
||||
|
||||
return updateTenant(tenantId, {
|
||||
name,
|
||||
type,
|
||||
slug,
|
||||
description: description || undefined,
|
||||
status,
|
||||
parentId: parentId || undefined,
|
||||
domains: domains
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d !== ""),
|
||||
}),
|
||||
domains,
|
||||
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||
config,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
onError: (
|
||||
err: AxiosError<{
|
||||
code?: string;
|
||||
error?: string;
|
||||
conflicts?: ServerDomainConflict[];
|
||||
}>,
|
||||
) => {
|
||||
const conflicts = err.response?.data?.conflicts ?? [];
|
||||
if (
|
||||
err.response?.data?.code === "tenant_domain_conflict" &&
|
||||
conflicts.length > 0
|
||||
) {
|
||||
const nextForceDomains = Array.from(
|
||||
new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]),
|
||||
);
|
||||
const message = conflicts.map(formatDomainConflictMessage).join("\n");
|
||||
if (window.confirm(message)) {
|
||||
setForceDomainConflicts(nextForceDomains);
|
||||
updateMutation.mutate(nextForceDomains);
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("err.common.unknown", "오류가 발생했습니다."),
|
||||
@@ -126,8 +193,14 @@ export function TenantProfilePage() {
|
||||
?.response?.data?.error;
|
||||
const loadError = (tenantQuery.error as AxiosError<{ error?: string }>)
|
||||
?.response?.data?.error;
|
||||
const isProtectedSeedTenant = tenantQuery.data
|
||||
? isSeedTenant(tenantQuery.data)
|
||||
: false;
|
||||
|
||||
const handleDelete = () => {
|
||||
if (isProtectedSeedTenant) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
window.confirm(
|
||||
t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", {
|
||||
@@ -195,6 +268,12 @@ export function TenantProfilePage() {
|
||||
"COMPANY_GROUP (그룹사/지주사)",
|
||||
)}
|
||||
</option>
|
||||
<option value="ORGANIZATION">
|
||||
{t(
|
||||
"domain.tenant_type.organization",
|
||||
"ORGANIZATION (정규 조직)",
|
||||
)}
|
||||
</option>
|
||||
<option value="USER_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.user_group",
|
||||
@@ -209,30 +288,78 @@ export function TenantProfilePage() {
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parentId" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.form.parent", "상위 테넌트 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
name="parentId"
|
||||
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={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
<div
|
||||
data-testid="tenant-parent-org-config-layout"
|
||||
className="grid gap-4 md:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
data-testid="tenant-parent-picker-slot"
|
||||
className={canEditOrgConfig ? "md:col-span-2" : "md:col-span-4"}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음 (최상위)")}</option>
|
||||
{availableParents.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t(
|
||||
"ui.admin.tenants.profile.form.parent_help",
|
||||
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
|
||||
)}
|
||||
</p>
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label={t(
|
||||
"ui.admin.tenants.profile.form.parent",
|
||||
"상위 테넌트 (선택)",
|
||||
)}
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
tenants={parentQuery.data?.items ?? []}
|
||||
noneLabel={t("ui.common.none", "없음 (최상위)")}
|
||||
helpText={t(
|
||||
"ui.admin.tenants.profile.form.parent_help",
|
||||
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
|
||||
)}
|
||||
excludeTenantId={tenantId}
|
||||
/>
|
||||
</div>
|
||||
{canEditOrgConfig && (
|
||||
<>
|
||||
<div
|
||||
data-testid="tenant-org-unit-type-slot"
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.tenants.profile.org_unit_type",
|
||||
"조직 세부타입",
|
||||
)}
|
||||
</Label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={orgUnitType}
|
||||
onChange={(event) => setOrgUnitType(event.target.value)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div data-testid="tenant-visibility-slot" className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
|
||||
</Label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={tenantVisibility}
|
||||
onChange={(event) =>
|
||||
setTenantVisibility(
|
||||
event.target.value as TenantVisibility,
|
||||
)
|
||||
}
|
||||
>
|
||||
{TENANT_VISIBILITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
@@ -257,9 +384,14 @@ export function TenantProfilePage() {
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
<DomainTagInput
|
||||
id="tenant-domains"
|
||||
value={domains}
|
||||
onChange={(e) => setDomains(e.target.value)}
|
||||
onChange={setDomains}
|
||||
tenants={parentQuery.data?.items ?? []}
|
||||
currentTenantId={tenantId}
|
||||
confirmedConflicts={forceDomainConflicts}
|
||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||
placeholder="example.com, example.kr"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -302,7 +434,15 @@ export function TenantProfilePage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
disabled={deleteMutation.isPending || isProtectedSeedTenant}
|
||||
title={
|
||||
isProtectedSeedTenant
|
||||
? t(
|
||||
"msg.admin.tenants.seed_delete_blocked",
|
||||
"초기 설정 테넌트는 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
@@ -322,7 +462,7 @@ export function TenantProfilePage() {
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate()}
|
||||
onClick={() => updateMutation.mutate(undefined)}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
tenantQuery.isLoading ||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSchemaField, normalizeSchemaField } from "./tenantSchemaFields";
|
||||
|
||||
describe("TenantSchemaPage schema field helpers", () => {
|
||||
it("creates text fields without varchar maxLength policy", () => {
|
||||
const field = createSchemaField();
|
||||
|
||||
expect(field.type).toBe("text");
|
||||
expect("maxLength" in field).toBe(false);
|
||||
expect(field.indexed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not add maxLength to legacy text schema fields", () => {
|
||||
const field = normalizeSchemaField({
|
||||
key: "emp_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
});
|
||||
|
||||
expect("maxLength" in field).toBe(false);
|
||||
});
|
||||
|
||||
it("forces indexed when a field can be used as login ID", () => {
|
||||
const field = normalizeSchemaField({
|
||||
key: "emp_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
indexed: false,
|
||||
isLoginId: true,
|
||||
});
|
||||
|
||||
expect(field.indexed).toBe(true);
|
||||
expect(field.isLoginId).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -16,33 +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";
|
||||
|
||||
type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
type SchemaField = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: SchemaFieldType;
|
||||
required: boolean;
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import {
|
||||
type SchemaField,
|
||||
createSchemaField,
|
||||
isSchemaFieldType,
|
||||
normalizeSchemaField,
|
||||
} from "./tenantSchemaFields";
|
||||
|
||||
export function TenantSchemaPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
@@ -53,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],
|
||||
@@ -71,27 +52,7 @@ export function TenantSchemaPage() {
|
||||
const rawSchema = tenantQuery.data?.config?.userSchema;
|
||||
|
||||
if (Array.isArray(rawSchema)) {
|
||||
setFields(
|
||||
rawSchema.map((field) => ({
|
||||
id: typeof field?.id === "string" ? field.id : createFieldId(),
|
||||
key: typeof field?.key === "string" ? field.key : "",
|
||||
label: typeof field?.label === "string" ? field.label : "",
|
||||
type:
|
||||
field?.type === "number" ||
|
||||
field?.type === "boolean" ||
|
||||
field?.type === "date" ||
|
||||
field?.type === "float" ||
|
||||
field?.type === "datetime"
|
||||
? field.type
|
||||
: "text",
|
||||
required: Boolean(field?.required),
|
||||
adminOnly: Boolean(field?.adminOnly),
|
||||
validation:
|
||||
typeof field?.validation === "string" ? field.validation : "",
|
||||
unsigned: Boolean(field?.unsigned),
|
||||
isLoginId: Boolean(field?.isLoginId),
|
||||
})),
|
||||
);
|
||||
setFields(rawSchema.map(normalizeSchemaField));
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
@@ -158,19 +119,7 @@ export function TenantSchemaPage() {
|
||||
}
|
||||
|
||||
const addField = () => {
|
||||
setFields([
|
||||
...fields,
|
||||
{
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
},
|
||||
]);
|
||||
setFields([...fields, createSchemaField()]);
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
@@ -261,16 +210,15 @@ export function TenantSchemaPage() {
|
||||
value={field.type}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
if (
|
||||
nextType === "text" ||
|
||||
nextType === "number" ||
|
||||
nextType === "boolean" ||
|
||||
nextType === "date" ||
|
||||
nextType === "float" ||
|
||||
nextType === "datetime"
|
||||
) {
|
||||
if (isSchemaFieldType(nextType)) {
|
||||
updateField(index, {
|
||||
type: nextType as SchemaFieldType,
|
||||
type: nextType,
|
||||
isLoginId:
|
||||
nextType === "text" ? field.isLoginId : false,
|
||||
indexed:
|
||||
nextType === "text"
|
||||
? field.indexed || field.isLoginId || false
|
||||
: field.indexed,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -351,7 +299,11 @@ export function TenantSchemaPage() {
|
||||
type="checkbox"
|
||||
checked={field.isLoginId || false}
|
||||
onChange={(e) =>
|
||||
updateField(index, { isLoginId: e.target.checked })
|
||||
updateField(index, {
|
||||
isLoginId: e.target.checked,
|
||||
indexed: e.target.checked ? true : field.indexed,
|
||||
type: e.target.checked ? "text" : field.type,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
@@ -362,6 +314,23 @@ export function TenantSchemaPage() {
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.indexed || field.isLoginId || false}
|
||||
disabled={field.isLoginId}
|
||||
onChange={(e) =>
|
||||
updateField(index, { indexed: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.indexed",
|
||||
"검색 인덱스 필요",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{(field.type === "number" || field.type === "float") && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Mail, User } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
Loader2,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
User,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { Link, 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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -16,12 +33,15 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { fetchTenant, fetchUsers } from "../../../lib/adminApi";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantUsersPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
|
||||
const tenantQuery = useQuery({
|
||||
@@ -39,23 +59,73 @@ function TenantUsersPage() {
|
||||
enabled: !!tenantSlug,
|
||||
});
|
||||
|
||||
const removeTenantMutation = useMutation({
|
||||
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
|
||||
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.tenants.members.remove_success",
|
||||
"조직에서 제외되었습니다.",
|
||||
),
|
||||
);
|
||||
usersQuery.refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.admin.tenants.members.remove_error", "제외 실패"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemoveMember = (userId: string, userName: string) => {
|
||||
if (!tenantSlug) return;
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.tenants.members.remove_confirm",
|
||||
"'{{name}}'님을 이 조직에서 제외하시겠습니까?",
|
||||
{ name: userName },
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeTenantMutation.mutate({ userId, slug: tenantSlug });
|
||||
}
|
||||
};
|
||||
|
||||
const users = usersQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User size={18} className="text-primary" />
|
||||
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
|
||||
count: users.length,
|
||||
})}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Link to={`/users?addTenant=${tenantSlug}`}>
|
||||
<UserPlus size={16} />
|
||||
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" asChild className="gap-2">
|
||||
<Link to={`/users/new?tenantSlug=${tenantSlug}`}>
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.members.create_new", "신규 멤버 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
<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")}
|
||||
@@ -72,7 +142,21 @@ function TenantUsersPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
{usersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2
|
||||
className="animate-spin text-muted-foreground"
|
||||
size={24}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("ui.common.loading", "Loading...")}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
@@ -84,33 +168,42 @@ function TenantUsersPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<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>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(
|
||||
`ui.common.role.${user.role}`,
|
||||
user.role.replace("_", " "),
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.status === "active" ? "default" : "muted"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(
|
||||
`ui.common.role.${user.role}`,
|
||||
user.role.replace("_", " "),
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={user.status === "active" ? "default" : "muted"}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorksmobilePasswordManageUrl,
|
||||
canCreateWorksmobileRow,
|
||||
canOpenWorksmobilePasswordManage,
|
||||
canSelectWorksmobileRow,
|
||||
comparisonFilterOptions,
|
||||
filterVisibleWorksmobileComparisonRows,
|
||||
filterWorksmobileComparisonRows,
|
||||
filterWorksmobileComparisonRowsBySearch,
|
||||
formatWorksmobileOrgDetails,
|
||||
formatWorksmobilePersonName,
|
||||
formatWorksmobileUpdateDetails,
|
||||
getDefaultGroupComparisonFilters,
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
isImmutableWorksmobileAccount,
|
||||
summarizeWorksmobileComparison,
|
||||
userFilterOptions,
|
||||
} 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" },
|
||||
{ resourceType: "USER", status: "missing_in_baron" },
|
||||
]);
|
||||
|
||||
expect(summary).toEqual({
|
||||
total: 6,
|
||||
matched: 1,
|
||||
needsUpdate: 1,
|
||||
missingInWorksmobile: 1,
|
||||
missingInBaron: 2,
|
||||
missingExternalKey: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns Korean labels for known comparison statuses", () => {
|
||||
expect(getWorksmobileComparisonStatusLabel("matched")).toBe("일치");
|
||||
expect(getWorksmobileComparisonStatusLabel("missing_in_worksmobile")).toBe(
|
||||
"WORKS 없음",
|
||||
);
|
||||
expect(getWorksmobileComparisonStatusLabel("missing_in_baron")).toBe(
|
||||
"Baron 없음",
|
||||
);
|
||||
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
|
||||
"ex_key 없음",
|
||||
);
|
||||
expect(getWorksmobileComparisonStatusLabel("needs_update")).toBe(
|
||||
"업데이트 필요",
|
||||
);
|
||||
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
|
||||
"unknown_status",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows WORKS creation only for Baron rows missing in WORKS", () => {
|
||||
expect(
|
||||
canCreateWorksmobileRow({
|
||||
resourceType: "USER",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "user-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canCreateWorksmobileRow({
|
||||
resourceType: "USER",
|
||||
status: "missing_in_worksmobile",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
canCreateWorksmobileRow({
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-user-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows selection for Baron-only, WORKS-only, and matched rows", () => {
|
||||
expect(
|
||||
canSelectWorksmobileRow({
|
||||
resourceType: "USER",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "user-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canSelectWorksmobileRow({
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-user-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canSelectWorksmobileRow({
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronId: "user-2",
|
||||
worksmobileId: "works-user-2",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not allow selection for immutable WORKS accounts", () => {
|
||||
expect(
|
||||
isImmutableWorksmobileAccount({
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "cyhan@samaneng.com",
|
||||
worksmobileId: "works-cyhan",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canSelectWorksmobileRow({
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "CYHAN1@HANMACENG.CO.KR",
|
||||
worksmobileId: "works-cyhan1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canSelectWorksmobileRow({
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "normal@samaneng.com",
|
||||
worksmobileId: "works-normal",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not allow password management for immutable WORKS accounts", () => {
|
||||
expect(
|
||||
canOpenWorksmobilePasswordManage(
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "su-@samaneng.com",
|
||||
worksmobileDomainId: 300285955,
|
||||
worksmobileId: "works-su",
|
||||
},
|
||||
"works-tenant-1",
|
||||
),
|
||||
).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 = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-only",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronId: "matched-baron",
|
||||
worksmobileId: "matched-works",
|
||||
},
|
||||
];
|
||||
|
||||
const selectedKeys = rows.map(getWorksmobileRowSelectionKey);
|
||||
|
||||
expect(selectedKeys).toEqual([
|
||||
"USER:baron:baron-only",
|
||||
"USER:works:works-only",
|
||||
"USER:baron:matched-baron",
|
||||
]);
|
||||
expect(getWorksmobileSelectedActionIds(rows, selectedKeys)).toEqual([
|
||||
"baron-only",
|
||||
"matched-baron",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses compact comparison columns by default", () => {
|
||||
expect(getDefaultWorksmobileComparisonColumns()).toEqual({
|
||||
status: true,
|
||||
baronId: false,
|
||||
baron: true,
|
||||
baronOrg: true,
|
||||
worksmobileId: false,
|
||||
externalKey: false,
|
||||
worksmobileDomain: true,
|
||||
worksmobile: true,
|
||||
worksmobileOrg: true,
|
||||
manage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("filters user comparison rows by selected relationship", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-only",
|
||||
baronName: "Baron only",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
worksmobileName: "WORKS only",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronId: "matched",
|
||||
worksmobileId: "works-matched",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "missing-external-key",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterWorksmobileComparisonRows(rows, ["baron_only"])).toEqual([
|
||||
rows[0],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["works_only"])).toEqual([
|
||||
rows[1],
|
||||
rows[3],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["matched"])).toEqual([
|
||||
rows[2],
|
||||
]);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
|
||||
).toEqual([rows[0], rows[1], rows[3]]);
|
||||
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
|
||||
expect(filterWorksmobileComparisonRows(rows, [])).toEqual([]);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, [
|
||||
"baron_only",
|
||||
"works_only",
|
||||
"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({
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
worksmobileName: "홍길동",
|
||||
worksmobileLevelName: "책임",
|
||||
}),
|
||||
).toBe("홍길동 책임");
|
||||
});
|
||||
|
||||
it("formats WORKS organization details with task and manager status", () => {
|
||||
expect(
|
||||
formatWorksmobileOrgDetails({
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
worksmobileTask: "기술검토",
|
||||
worksmobilePrimaryOrgPositionName: "팀장",
|
||||
worksmobilePrimaryOrgIsManager: true,
|
||||
}),
|
||||
).toEqual(["직책 팀장", "직무 기술검토", "조직장"]);
|
||||
});
|
||||
|
||||
it("builds the WORKS admin password management URL from remote user identifiers", () => {
|
||||
const url = buildWorksmobilePasswordManageUrl({
|
||||
tenantId: " works-tenant-1 ",
|
||||
domainId: 300285955,
|
||||
userIdNo: " works-user-1 ",
|
||||
});
|
||||
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
"https://auth.worksmobile.com/integrate/password/manage",
|
||||
);
|
||||
expect(parsed.searchParams.get("usage")).toBe("admin");
|
||||
expect(parsed.searchParams.get("targetUserTenantId")).toBe(
|
||||
"works-tenant-1",
|
||||
);
|
||||
expect(parsed.searchParams.get("targetUserDomainId")).toBe("300285955");
|
||||
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
|
||||
expect(parsed.searchParams.get("accessUrl")).toBe(
|
||||
"https://admin.worksmobile.com/assets/self-close.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not open WORKS password management without required identifiers", () => {
|
||||
const row = {
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
worksmobileDomainId: 300285955,
|
||||
worksmobileId: "works-user-1",
|
||||
};
|
||||
|
||||
expect(canOpenWorksmobilePasswordManage(row, "works-tenant-1")).toBe(true);
|
||||
expect(canOpenWorksmobilePasswordManage(row, undefined)).toBe(false);
|
||||
expect(
|
||||
canOpenWorksmobilePasswordManage(
|
||||
{ ...row, worksmobileDomainId: undefined },
|
||||
"works-tenant-1",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canOpenWorksmobilePasswordManage(
|
||||
{ ...row, worksmobileId: undefined },
|
||||
"works-tenant-1",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canOpenWorksmobilePasswordManage(
|
||||
{ ...row, resourceType: "GROUP" },
|
||||
"works-tenant-1",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
buildWorksmobilePasswordManageUrl({
|
||||
tenantId: "works-tenant-1",
|
||||
domainId: 0,
|
||||
userIdNo: "works-user-1",
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("allows WORKS password management for WORKS-only user rows", () => {
|
||||
expect(
|
||||
canOpenWorksmobilePasswordManage(
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileDomainId: 300285955,
|
||||
worksmobileId: "works-user-1",
|
||||
},
|
||||
"works-tenant-1",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
1345
adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
Normal file
1345
adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
Normal file
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"],
|
||||
};
|
||||
67
adminfront/src/features/tenants/utils/domainTags.test.ts
Normal file
67
adminfront/src/features/tenants/utils/domainTags.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
normalizeDomainTokens,
|
||||
} from "./domainTags";
|
||||
|
||||
describe("domainTags", () => {
|
||||
it("splits domains by comma and whitespace", () => {
|
||||
expect(
|
||||
normalizeDomainTokens("samaneng.com, hanmaceng.co.kr login.hmac.kr"),
|
||||
).toEqual(["samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"]);
|
||||
});
|
||||
|
||||
it("finds a domain already assigned to another tenant", () => {
|
||||
const conflict = findDomainConflict("hanmaceng.co.kr", [
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
type: "COMPANY",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: ["hanmaceng.co.kr"],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(conflict?.tenant.name).toBe("한맥기술");
|
||||
});
|
||||
|
||||
it("ignores the current tenant when checking domain conflicts", () => {
|
||||
const conflict = findDomainConflict(
|
||||
"hanmaceng.co.kr",
|
||||
[
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
type: "COMPANY",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: ["hanmaceng.co.kr"],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
],
|
||||
"tenant-1",
|
||||
);
|
||||
|
||||
expect(conflict).toBeNull();
|
||||
});
|
||||
|
||||
it("formats a duplicate domain message with tenant context", () => {
|
||||
expect(
|
||||
formatDomainConflictMessage({
|
||||
domain: "samaneng.com",
|
||||
tenantName: "한맥가족",
|
||||
}),
|
||||
).toBe(
|
||||
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
|
||||
);
|
||||
});
|
||||
});
|
||||
62
adminfront/src/features/tenants/utils/domainTags.ts
Normal file
62
adminfront/src/features/tenants/utils/domainTags.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
|
||||
export type DomainConflict = {
|
||||
domain: string;
|
||||
tenant: TenantSummary;
|
||||
};
|
||||
|
||||
export type ServerDomainConflict = {
|
||||
domain: string;
|
||||
tenantId?: string;
|
||||
tenantName?: string;
|
||||
tenantSlug?: string;
|
||||
};
|
||||
|
||||
export function normalizeDomainTokens(value: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const tokens: string[] = [];
|
||||
for (const raw of value.split(/[,\s;]+/)) {
|
||||
const token = raw.trim().toLowerCase();
|
||||
if (!token || seen.has(token)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(token);
|
||||
tokens.push(token);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function findDomainConflict(
|
||||
domain: string,
|
||||
tenants: TenantSummary[] = [],
|
||||
currentTenantId?: string,
|
||||
): DomainConflict | null {
|
||||
const normalized = domain.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const tenant of tenants) {
|
||||
if (tenant.id === currentTenantId) {
|
||||
continue;
|
||||
}
|
||||
const domains = tenant.domains ?? [];
|
||||
if (domains.some((item) => item.trim().toLowerCase() === normalized)) {
|
||||
return { domain: normalized, tenant };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatDomainConflictMessage(
|
||||
conflict: DomainConflict | ServerDomainConflict,
|
||||
): string {
|
||||
const tenantName =
|
||||
"tenant" in conflict
|
||||
? conflict.tenant.name
|
||||
: conflict.tenantName ||
|
||||
conflict.tenantSlug ||
|
||||
conflict.tenantId ||
|
||||
"다른";
|
||||
return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
|
||||
}
|
||||
77
adminfront/src/features/tenants/utils/orgConfig.test.ts
Normal file
77
adminfront/src/features/tenants/utils/orgConfig.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
ORG_UNIT_TYPE_OPTIONS,
|
||||
mergeTenantOrgConfig,
|
||||
readTenantOrgConfig,
|
||||
shouldAllowHanmacOrgConfig,
|
||||
} from "./orgConfig";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
slug: string,
|
||||
parentId?: string,
|
||||
): TenantSummary {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
slug,
|
||||
description: "",
|
||||
status: "active",
|
||||
parentId,
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("tenant org config", () => {
|
||||
it("allows org config only for hanmac-family descendants", () => {
|
||||
const family = tenant(
|
||||
"family",
|
||||
"COMPANY_GROUP",
|
||||
"한맥가족",
|
||||
"hanmac-family",
|
||||
);
|
||||
const saman = tenant("saman", "COMPANY", "삼안", "saman", "family");
|
||||
const team = tenant("team", "USER_GROUP", "기획팀", "planning", "saman");
|
||||
const outsider = tenant("outsider", "COMPANY", "외부", "outsider");
|
||||
const tenants = [family, saman, team, outsider];
|
||||
|
||||
expect(shouldAllowHanmacOrgConfig(team, tenants)).toBe(true);
|
||||
expect(shouldAllowHanmacOrgConfig(family, tenants)).toBe(false);
|
||||
expect(shouldAllowHanmacOrgConfig(outsider, tenants)).toBe(false);
|
||||
});
|
||||
|
||||
it("reads and writes tenant visibility and org unit type", () => {
|
||||
expect(
|
||||
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
|
||||
).toEqual({ orgUnitType: "팀", visibility: "private" });
|
||||
expect(
|
||||
readTenantOrgConfig({ visibility: "internal", orgUnitType: "센터" }),
|
||||
).toEqual({ orgUnitType: "센터", visibility: "internal" });
|
||||
|
||||
expect(
|
||||
mergeTenantOrgConfig(
|
||||
{ userSchema: [], visibility: "private", orgUnitType: "팀" },
|
||||
{ orgUnitType: "", visibility: "internal" },
|
||||
),
|
||||
).toEqual({ userSchema: [], visibility: "internal" });
|
||||
});
|
||||
|
||||
it("includes task-force and executive-direct org unit types", () => {
|
||||
expect(ORG_UNIT_TYPE_OPTIONS).toEqual(
|
||||
expect.arrayContaining(["TF", "TF팀", "임원직속"]),
|
||||
);
|
||||
expect(readTenantOrgConfig({ orgUnitType: "TF" }).orgUnitType).toBe("TF");
|
||||
expect(readTenantOrgConfig({ orgUnitType: "TF팀" }).orgUnitType).toBe(
|
||||
"TF팀",
|
||||
);
|
||||
expect(readTenantOrgConfig({ orgUnitType: "임원직속" }).orgUnitType).toBe(
|
||||
"임원직속",
|
||||
);
|
||||
});
|
||||
});
|
||||
96
adminfront/src/features/tenants/utils/orgConfig.ts
Normal file
96
adminfront/src/features/tenants/utils/orgConfig.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
|
||||
export const ORG_UNIT_TYPE_OPTIONS = [
|
||||
"실",
|
||||
"팀",
|
||||
"TF",
|
||||
"TF팀",
|
||||
"센터",
|
||||
"디비전",
|
||||
"셀",
|
||||
"본부",
|
||||
"지역본부",
|
||||
"부",
|
||||
"임원직속",
|
||||
] as const;
|
||||
|
||||
export const TENANT_VISIBILITY_OPTIONS = [
|
||||
{ label: "공개", value: "public" },
|
||||
{ label: "내부", value: "internal" },
|
||||
{ label: "비공개", value: "private" },
|
||||
] as const;
|
||||
|
||||
export type TenantVisibility =
|
||||
(typeof TENANT_VISIBILITY_OPTIONS)[number]["value"];
|
||||
|
||||
export type TenantOrgConfig = {
|
||||
orgUnitType: string;
|
||||
visibility: TenantVisibility;
|
||||
};
|
||||
|
||||
const ORG_UNIT_TYPE_SET = new Set<string>(ORG_UNIT_TYPE_OPTIONS);
|
||||
const TENANT_VISIBILITY_SET = new Set<string>(
|
||||
TENANT_VISIBILITY_OPTIONS.map((option) => option.value),
|
||||
);
|
||||
|
||||
export function shouldAllowHanmacOrgConfig(
|
||||
tenant: Pick<TenantSummary, "id" | "parentId" | "slug">,
|
||||
tenants: Array<Pick<TenantSummary, "id" | "parentId" | "slug">>,
|
||||
) {
|
||||
if (tenant.slug.toLowerCase() === "hanmac-family") return false;
|
||||
|
||||
const byId = new Map(tenants.map((item) => [item.id, item]));
|
||||
let parentId = tenant.parentId;
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (parentId) {
|
||||
if (visited.has(parentId)) return false;
|
||||
visited.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (!parent) return false;
|
||||
if (parent.slug.toLowerCase() === "hanmac-family") return true;
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function readTenantOrgConfig(
|
||||
config: Record<string, unknown> | undefined,
|
||||
): TenantOrgConfig {
|
||||
const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
|
||||
const rawOrgUnitType = String(config?.orgUnitType ?? "");
|
||||
|
||||
return {
|
||||
orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
|
||||
visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
|
||||
? (rawVisibility as TenantVisibility)
|
||||
: "public",
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeTenantOrgConfig(
|
||||
config: Record<string, unknown> | undefined,
|
||||
next: TenantOrgConfig,
|
||||
) {
|
||||
const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
|
||||
const merged = { ...rest };
|
||||
merged.visibility = next.visibility;
|
||||
|
||||
if (next.orgUnitType) {
|
||||
merged.orgUnitType = next.orgUnitType;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function removeTenantOrgConfig(
|
||||
config: Record<string, unknown> | undefined,
|
||||
) {
|
||||
const {
|
||||
orgUnitType: _orgUnitType,
|
||||
visibility: _visibility,
|
||||
...rest
|
||||
} = config ?? {};
|
||||
return rest;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants";
|
||||
|
||||
describe("protectedTenants", () => {
|
||||
it("marks tenants from seed-tenant.csv as protected", () => {
|
||||
expect(getSeedTenantSlugs()).toEqual(
|
||||
expect.arrayContaining(["hanmac-family", "personal"]),
|
||||
);
|
||||
expect(isSeedTenant({ slug: "hanmac-family" })).toBe(true);
|
||||
expect(isSeedTenant({ slug: "normal-tenant" })).toBe(false);
|
||||
});
|
||||
});
|
||||
19
adminfront/src/features/tenants/utils/protectedTenants.ts
Normal file
19
adminfront/src/features/tenants/utils/protectedTenants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Vite ?raw import는 seed CSV를 빌드 타임 상수로 번들합니다.
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { parseTenantCSV } from "./tenantCsvImport";
|
||||
|
||||
const seedTenantSlugs = new Set(
|
||||
parseTenantCSV(seedTenantCSVRaw)
|
||||
.map((row) => row.slug.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
export function isSeedTenant(tenant: Pick<TenantSummary, "slug">): boolean {
|
||||
return seedTenantSlugs.has(tenant.slug.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function getSeedTenantSlugs(): string[] {
|
||||
return Array.from(seedTenantSlugs);
|
||||
}
|
||||
362
adminfront/src/features/tenants/utils/tenantCsvImport.test.ts
Normal file
362
adminfront/src/features/tenants/utils/tenantCsvImport.test.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
buildTenantImportParentOptionGroups,
|
||||
buildTenantImportPreview,
|
||||
inferTenantImportRootParentSlug,
|
||||
parseTenantCSV,
|
||||
serializeTenantImportCSV,
|
||||
} from "./tenantCsvImport";
|
||||
|
||||
const tenants: TenantSummary[] = [
|
||||
{
|
||||
id: "tenant-1",
|
||||
type: "COMPANY",
|
||||
name: "Hanmac Technology",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: ["hanmac.example.com"],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "tenant-2",
|
||||
type: "COMPANY",
|
||||
name: "Saman Engineering",
|
||||
slug: "saman",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "tenant-3",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "Hanmac Family",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "tenant-4",
|
||||
type: "ORGANIZATION",
|
||||
name: "기획부",
|
||||
slug: "planning",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
describe("tenantCsvImport", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("parses tenant CSV rows with the supported import columns", () => {
|
||||
const rows = parseTenantCSV(
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터\n",
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
rowNumber: 2,
|
||||
tenantId: "",
|
||||
name: "Hanmac Tech",
|
||||
type: "COMPANY",
|
||||
parentTenantId: "",
|
||||
parentTenantSlug: "",
|
||||
slug: "hanmac-tech",
|
||||
memo: "Memo",
|
||||
emailDomain: "hanmac-tech.example.com",
|
||||
visibility: "internal",
|
||||
orgUnitType: "센터",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts tenant_id-less rows with exact or similar matches first", () => {
|
||||
const rows = parseTenantCSV(
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,New Tenant,COMPANY,,new-tenant,,\n,Hanmac Tech,COMPANY,,hanmac-tech,,\n,Saman Engineering,COMPANY,,saman-copy,,\n",
|
||||
);
|
||||
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
|
||||
expect(preview.map((row) => row.row.name)).toEqual([
|
||||
"Saman Engineering",
|
||||
"Hanmac Tech",
|
||||
"New Tenant",
|
||||
]);
|
||||
expect(preview[0].candidates[0]).toMatchObject({
|
||||
tenantId: "tenant-2",
|
||||
reason: "exact_name",
|
||||
});
|
||||
expect(preview[1].candidates[0]).toMatchObject({
|
||||
tenantId: "tenant-1",
|
||||
reason: "similar_name",
|
||||
});
|
||||
expect(preview[2].candidates).toEqual([]);
|
||||
});
|
||||
|
||||
it("serializes selected matches by filling tenant_id before upload", () => {
|
||||
const rows = parseTenantCSV(
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀\n",
|
||||
);
|
||||
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,private,팀",
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes create resolutions by resetting external tenant id and conflicting slug", () => {
|
||||
const rows = parseTenantCSV(
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\nlocal-tenant-id,Hanmac Technology,COMPANY,,hanmac,Memo,hanmac.example.com\n",
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
|
||||
expect(preview[0].conflicts).toEqual(
|
||||
expect.arrayContaining(["external_tenant_id", "slug_exists"]),
|
||||
);
|
||||
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: {
|
||||
mode: "create",
|
||||
tenantId: "staging-new-tenant-id",
|
||||
slug: "hanmac-imported",
|
||||
},
|
||||
});
|
||||
|
||||
expect(csv).toContain(
|
||||
"staging-new-tenant-id,Hanmac Technology,COMPANY,,,hanmac-imported,Memo,hanmac.example.com",
|
||||
);
|
||||
expect(csv).not.toContain("local-tenant-id");
|
||||
});
|
||||
|
||||
it("remaps child parent_tenant_id from source ids to selected staging ids", () => {
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain",
|
||||
"local-parent-id,Parent Tenant,COMPANY,,parent-local,,",
|
||||
"local-child-id,Child Tenant,ORGANIZATION,local-parent-id,child-local,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: {
|
||||
mode: "create",
|
||||
tenantId: "staging-parent-id",
|
||||
slug: "parent-staging",
|
||||
},
|
||||
3: {
|
||||
mode: "create",
|
||||
tenantId: "staging-child-id",
|
||||
slug: "child-staging",
|
||||
},
|
||||
});
|
||||
|
||||
expect(csv).toContain(
|
||||
"staging-parent-id,Parent Tenant,COMPANY,,,parent-staging,,",
|
||||
);
|
||||
expect(csv).toContain(
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,,child-staging,,",
|
||||
);
|
||||
expect(csv).not.toContain("local-parent-id");
|
||||
expect(csv).not.toContain("local-child-id");
|
||||
});
|
||||
|
||||
it("parses parent_tenant_slug and remaps it to selected staging ids", () => {
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain",
|
||||
"Parent Tenant,COMPANY,,parent-slug,,",
|
||||
"Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: {
|
||||
mode: "create",
|
||||
tenantId: "staging-parent-id",
|
||||
slug: "parent-slug",
|
||||
},
|
||||
3: {
|
||||
mode: "create",
|
||||
tenantId: "staging-child-id",
|
||||
slug: "child-slug",
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows[1].parentTenantSlug).toBe("parent-slug");
|
||||
expect(csv).toContain(
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps parent_tenant_slug in the serialized CSV as a fallback for hierarchy import", () => {
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain",
|
||||
"Parent Tenant,COMPANY,,parent-slug,,",
|
||||
"Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: {
|
||||
mode: "create",
|
||||
tenantId: "staging-parent-id",
|
||||
slug: "parent-slug",
|
||||
},
|
||||
3: {
|
||||
mode: "create",
|
||||
tenantId: "staging-child-id",
|
||||
slug: "child-slug",
|
||||
},
|
||||
});
|
||||
|
||||
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(
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses Naver Works organization CSV columns into tenant import rows", () => {
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
'"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","상위 조직"',
|
||||
'"기술개발센터","1","","","","tdc@samaneng.com",""',
|
||||
'"기획부","1","","","","planning@samaneng.com","기술개발센터(tdc@samaneng.com)"',
|
||||
'"업무팀","0","","","","t_226wn@samaneng.com","기획부(planning@samaneng.com)"',
|
||||
].join("\n"),
|
||||
{ rootParentSlug: "saman" },
|
||||
);
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{
|
||||
name: "기술개발센터",
|
||||
type: "ORGANIZATION",
|
||||
slug: "tdc",
|
||||
parentTenantSlug: "saman",
|
||||
},
|
||||
{
|
||||
name: "기획부",
|
||||
type: "ORGANIZATION",
|
||||
slug: "planning",
|
||||
parentTenantSlug: "tdc",
|
||||
},
|
||||
{
|
||||
name: "업무팀",
|
||||
type: "ORGANIZATION",
|
||||
slug: "t-226wn",
|
||||
parentTenantSlug: "planning",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("infers root parent slug from an organization CSV file prefix that matches an existing slug", () => {
|
||||
expect(inferTenantImportRootParentSlug("saman_org.csv", tenants)).toBe(
|
||||
"saman",
|
||||
);
|
||||
expect(
|
||||
inferTenantImportRootParentSlug("/tmp/hanmac-family_org.csv", tenants),
|
||||
).toBe("hanmac-family");
|
||||
expect(
|
||||
inferTenantImportRootParentSlug("saman_org_slugged.csv", tenants),
|
||||
).toBe("saman");
|
||||
expect(inferTenantImportRootParentSlug("unknown_org.csv", tenants)).toBe(
|
||||
"",
|
||||
);
|
||||
expect(inferTenantImportRootParentSlug("tenant-import.csv", tenants)).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("groups existing parent candidates by company group, company, and organization", () => {
|
||||
const groups = buildTenantImportParentOptionGroups(tenants);
|
||||
|
||||
expect(groups.map((group) => group.type)).toEqual([
|
||||
"COMPANY_GROUP",
|
||||
"COMPANY",
|
||||
"ORGANIZATION",
|
||||
]);
|
||||
expect(
|
||||
groups.map((group) => group.tenants.map((tenant) => tenant.id)),
|
||||
).toEqual([["tenant-3"], ["tenant-1", "tenant-2"], ["tenant-4"]]);
|
||||
});
|
||||
|
||||
it("keeps generated ids stable and follows edited parent slugs for child rows", () => {
|
||||
const randomUUID = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("parent-generated-id")
|
||||
.mockReturnValueOnce("child-generated-id");
|
||||
vi.stubGlobal("crypto", { randomUUID });
|
||||
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain",
|
||||
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
|
||||
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: { mode: "create", slug: "tech-center" },
|
||||
3: { mode: "create", slug: "structure-div" },
|
||||
});
|
||||
|
||||
expect(csv).toContain(
|
||||
"parent-generated-id,기술개발센터,ORGANIZATION,,saman,tech-center,,",
|
||||
);
|
||||
expect(csv).toContain(
|
||||
"child-generated-id,일반구조물 div,ORGANIZATION,parent-generated-id,tech-center,structure-div,,",
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes explicit parent tenant selections from the import preview", () => {
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain",
|
||||
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
|
||||
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: {
|
||||
mode: "create",
|
||||
slug: "tech-center",
|
||||
parentTenantId: "tenant-2",
|
||||
parentTenantSlug: "",
|
||||
},
|
||||
3: {
|
||||
mode: "create",
|
||||
slug: "structure-div",
|
||||
parentTenantSlug: "tech-center",
|
||||
},
|
||||
});
|
||||
|
||||
expect(csv).toContain("기술개발센터,ORGANIZATION,tenant-2,,tech-center,,");
|
||||
expect(csv).toContain(",일반구조물 div,ORGANIZATION,");
|
||||
expect(csv).toContain(",tech-center,structure-div,,");
|
||||
});
|
||||
});
|
||||
610
adminfront/src/features/tenants/utils/tenantCsvImport.ts
Normal file
610
adminfront/src/features/tenants/utils/tenantCsvImport.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
|
||||
export type TenantCSVRow = {
|
||||
rowNumber: number;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
parentTenantId: string;
|
||||
parentTenantSlug: string;
|
||||
slug: string;
|
||||
memo: string;
|
||||
emailDomain: string;
|
||||
visibility: string;
|
||||
orgUnitType: string;
|
||||
};
|
||||
|
||||
export type TenantCSVParseOptions = {
|
||||
rootParentSlug?: string;
|
||||
};
|
||||
|
||||
type TenantCSVSourceKey = keyof TenantCSVRow | "mailingList" | "parentOrg";
|
||||
|
||||
export type TenantImportCandidate = {
|
||||
tenantId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
score: number;
|
||||
reason: "exact_name" | "exact_slug" | "similar_name";
|
||||
};
|
||||
|
||||
export type TenantImportPreviewRow = {
|
||||
row: TenantCSVRow;
|
||||
candidates: TenantImportCandidate[];
|
||||
defaultTenantId: string;
|
||||
defaultCreateSlug: string;
|
||||
conflicts: TenantImportConflict[];
|
||||
};
|
||||
|
||||
export type TenantImportParentOptionGroupType =
|
||||
| "COMPANY_GROUP"
|
||||
| "COMPANY"
|
||||
| "ORGANIZATION";
|
||||
|
||||
export type TenantImportParentOptionGroup = {
|
||||
type: TenantImportParentOptionGroupType;
|
||||
tenants: TenantSummary[];
|
||||
};
|
||||
|
||||
export type TenantImportConflict =
|
||||
| "external_tenant_id"
|
||||
| "slug_exists"
|
||||
| "parent_tenant_id_unresolved";
|
||||
|
||||
export type TenantImportResolution =
|
||||
| {
|
||||
mode: "existing";
|
||||
tenantId: string;
|
||||
parentTenantId?: string;
|
||||
parentTenantSlug?: string;
|
||||
}
|
||||
| {
|
||||
mode: "create";
|
||||
tenantId?: string;
|
||||
slug?: string;
|
||||
parentTenantId?: string;
|
||||
parentTenantSlug?: string;
|
||||
}
|
||||
| {
|
||||
mode: "skip";
|
||||
};
|
||||
|
||||
const importHeaders = [
|
||||
"tenant_id",
|
||||
"name",
|
||||
"type",
|
||||
"parent_tenant_id",
|
||||
"parent_tenant_slug",
|
||||
"slug",
|
||||
"memo",
|
||||
"email_domain",
|
||||
"visibility",
|
||||
"org_unit_type",
|
||||
];
|
||||
|
||||
const headerAliases: Record<string, TenantCSVSourceKey> = {
|
||||
id: "tenantId",
|
||||
tenantid: "tenantId",
|
||||
tenant_id: "tenantId",
|
||||
name: "name",
|
||||
조직명: "name",
|
||||
type: "type",
|
||||
parentid: "parentTenantId",
|
||||
parent_id: "parentTenantId",
|
||||
parenttenantid: "parentTenantId",
|
||||
parent_tenant_id: "parentTenantId",
|
||||
parenttenantslug: "parentTenantSlug",
|
||||
parent_tenant_slug: "parentTenantSlug",
|
||||
상위_조직: "parentOrg",
|
||||
slug: "slug",
|
||||
memo: "memo",
|
||||
description: "memo",
|
||||
설명: "memo",
|
||||
메일링_리스트: "mailingList",
|
||||
"email-domain": "emailDomain",
|
||||
emaildomain: "emailDomain",
|
||||
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(
|
||||
text: string,
|
||||
options: TenantCSVParseOptions = {},
|
||||
): TenantCSVRow[] {
|
||||
const records = parseCSV(text.replace(/^\uFEFF/, ""));
|
||||
if (records.length === 0) return [];
|
||||
|
||||
const header = new Map<TenantCSVSourceKey, number>();
|
||||
records[0].forEach((column, index) => {
|
||||
const normalized = normalizeHeader(column);
|
||||
const key = headerAliases[normalized];
|
||||
if (key) header.set(key, index);
|
||||
});
|
||||
|
||||
const isOrgChartCSV = header.has("mailingList") || header.has("parentOrg");
|
||||
const sourceRows = records.slice(1).flatMap((record, index) => {
|
||||
if (record.every((value) => value.trim() === "")) return [];
|
||||
const value = (key: TenantCSVSourceKey) => {
|
||||
const columnIndex = header.get(key);
|
||||
if (columnIndex === undefined) return "";
|
||||
return (record[columnIndex] ?? "").trim();
|
||||
};
|
||||
|
||||
return {
|
||||
raw: record,
|
||||
rowNumber: index + 2,
|
||||
name: value("name"),
|
||||
slug: value("slug") || slugFromMailingList(value("mailingList")),
|
||||
mailingList: value("mailingList"),
|
||||
parentOrg: value("parentOrg"),
|
||||
value,
|
||||
};
|
||||
});
|
||||
const slugByName = new Map(
|
||||
sourceRows
|
||||
.filter((row) => row.name && row.slug)
|
||||
.map((row) => [row.name, row.slug] as const),
|
||||
);
|
||||
|
||||
return sourceRows.map(({ rowNumber, name, slug, parentOrg, value }) => {
|
||||
const parentTenantSlug =
|
||||
value("parentTenantSlug") ||
|
||||
slugFromParentOrg(parentOrg, slugByName) ||
|
||||
(isOrgChartCSV ? options.rootParentSlug || "" : "");
|
||||
|
||||
return {
|
||||
rowNumber,
|
||||
tenantId: value("tenantId"),
|
||||
name,
|
||||
type: value("type") || (isOrgChartCSV ? "ORGANIZATION" : ""),
|
||||
parentTenantId: value("parentTenantId"),
|
||||
parentTenantSlug,
|
||||
slug,
|
||||
memo: value("memo"),
|
||||
emailDomain: value("emailDomain"),
|
||||
visibility: value("visibility"),
|
||||
orgUnitType: value("orgUnitType"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function inferTenantImportRootParentSlug(
|
||||
fileName: string,
|
||||
tenants: TenantSummary[] = [],
|
||||
) {
|
||||
const baseName = fileName.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
|
||||
const [prefix = ""] = baseName.split("_");
|
||||
if (!prefix) return "";
|
||||
|
||||
const existingTenant = tenants.find(
|
||||
(tenant) => tenant.slug.toLowerCase() === prefix,
|
||||
);
|
||||
return existingTenant ? prefix : "";
|
||||
}
|
||||
|
||||
export function buildTenantImportParentOptionGroups(
|
||||
tenants: TenantSummary[],
|
||||
): TenantImportParentOptionGroup[] {
|
||||
const orderedTypes: TenantImportParentOptionGroupType[] = [
|
||||
"COMPANY_GROUP",
|
||||
"COMPANY",
|
||||
"ORGANIZATION",
|
||||
];
|
||||
|
||||
return orderedTypes
|
||||
.map((type) => ({
|
||||
type,
|
||||
tenants: tenants.filter((tenant) => tenant.type?.toUpperCase() === type),
|
||||
}))
|
||||
.filter((group) => group.tenants.length > 0);
|
||||
}
|
||||
|
||||
export function buildTenantImportPreview(
|
||||
rows: TenantCSVRow[],
|
||||
tenants: TenantSummary[],
|
||||
): TenantImportPreviewRow[] {
|
||||
return rows
|
||||
.map((row) => {
|
||||
const candidates = findTenantCandidates(row, tenants);
|
||||
const conflicts = findTenantImportConflicts(row, tenants);
|
||||
return {
|
||||
row,
|
||||
candidates,
|
||||
conflicts,
|
||||
defaultTenantId:
|
||||
candidates[0] && candidates[0].score >= 0.95
|
||||
? candidates[0].tenantId
|
||||
: "",
|
||||
defaultCreateSlug: suggestUniqueTenantSlug(
|
||||
row.slug || row.name,
|
||||
tenants,
|
||||
),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aScore = a.candidates[0]?.score ?? 0;
|
||||
const bScore = b.candidates[0]?.score ?? 0;
|
||||
if (bScore !== aScore) return bScore - aScore;
|
||||
return a.row.rowNumber - b.row.rowNumber;
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeTenantImportCSV(
|
||||
previewRows: TenantImportPreviewRow[],
|
||||
selectedTenantIds: Record<number, string | TenantImportResolution>,
|
||||
) {
|
||||
const lines = [importHeaders];
|
||||
const sortedRows = [...previewRows].sort(
|
||||
(a, b) => a.row.rowNumber - b.row.rowNumber,
|
||||
);
|
||||
const targetTenantIds = buildTargetTenantIds(sortedRows, selectedTenantIds);
|
||||
|
||||
for (const preview of sortedRows) {
|
||||
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
||||
if (typeof resolution === "object" && resolution.mode === "skip") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const selectedTenantId =
|
||||
typeof resolution === "string"
|
||||
? resolution
|
||||
: resolution.mode === "existing"
|
||||
? resolution.tenantId
|
||||
: "";
|
||||
const slug =
|
||||
typeof resolution === "object" && resolution.mode === "create"
|
||||
? resolution.slug || preview.defaultCreateSlug
|
||||
: preview.row.slug;
|
||||
const hasParentTenantIdOverride =
|
||||
typeof resolution === "object" &&
|
||||
Object.hasOwn(resolution, "parentTenantId");
|
||||
const hasParentTenantSlugOverride =
|
||||
typeof resolution === "object" &&
|
||||
Object.hasOwn(resolution, "parentTenantSlug");
|
||||
const sourceParentTenantSlug = hasParentTenantSlugOverride
|
||||
? resolution.parentTenantSlug || ""
|
||||
: preview.row.parentTenantSlug;
|
||||
const parentTenantId =
|
||||
typeof resolution === "object"
|
||||
? hasParentTenantIdOverride
|
||||
? resolution.parentTenantId || ""
|
||||
: remapParentTenantId(
|
||||
preview.row.parentTenantId,
|
||||
sourceParentTenantSlug,
|
||||
targetTenantIds,
|
||||
)
|
||||
: preview.row.parentTenantId;
|
||||
const parentTenantSlug = remapParentTenantSlug(
|
||||
sourceParentTenantSlug,
|
||||
targetTenantIds,
|
||||
);
|
||||
const tenantId =
|
||||
targetTenantIds.byRowNumber.get(preview.row.rowNumber) ??
|
||||
selectedTenantId ??
|
||||
preview.row.tenantId;
|
||||
|
||||
lines.push([
|
||||
tenantId,
|
||||
preview.row.name,
|
||||
preview.row.type,
|
||||
parentTenantId,
|
||||
parentTenantSlug,
|
||||
slug,
|
||||
preview.row.memo,
|
||||
preview.row.emailDomain,
|
||||
preview.row.visibility,
|
||||
preview.row.orgUnitType,
|
||||
]);
|
||||
}
|
||||
return `${lines.map(formatCSVRecord).join("\n")}\n`;
|
||||
}
|
||||
|
||||
function buildTargetTenantIds(
|
||||
previewRows: TenantImportPreviewRow[],
|
||||
selectedTenantIds: Record<number, string | TenantImportResolution>,
|
||||
) {
|
||||
const byRowNumber = new Map<number, string>();
|
||||
const bySourceId = new Map<string, string>();
|
||||
const bySourceSlug = new Map<string, string>();
|
||||
const bySourceSlugToTargetSlug = new Map<string, string>();
|
||||
|
||||
for (const preview of previewRows) {
|
||||
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
||||
if (typeof resolution === "object" && resolution.mode === "skip") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetTenantId =
|
||||
typeof resolution === "string"
|
||||
? resolution || preview.row.tenantId
|
||||
: resolution.mode === "existing"
|
||||
? resolution.tenantId
|
||||
: resolution.tenantId || createTenantImportId();
|
||||
const targetSlug =
|
||||
typeof resolution === "object" && resolution.mode === "create"
|
||||
? resolution.slug || preview.defaultCreateSlug
|
||||
: preview.row.slug;
|
||||
|
||||
if (targetTenantId) {
|
||||
byRowNumber.set(preview.row.rowNumber, targetTenantId);
|
||||
}
|
||||
if (preview.row.tenantId) {
|
||||
bySourceId.set(preview.row.tenantId, targetTenantId);
|
||||
}
|
||||
if (preview.row.slug) {
|
||||
bySourceSlug.set(preview.row.slug.toLowerCase(), targetTenantId);
|
||||
bySourceSlugToTargetSlug.set(preview.row.slug.toLowerCase(), targetSlug);
|
||||
}
|
||||
if (targetSlug) {
|
||||
bySourceSlug.set(targetSlug.toLowerCase(), targetTenantId);
|
||||
bySourceSlugToTargetSlug.set(targetSlug.toLowerCase(), targetSlug);
|
||||
}
|
||||
}
|
||||
|
||||
return { byRowNumber, bySourceId, bySourceSlug, bySourceSlugToTargetSlug };
|
||||
}
|
||||
|
||||
function remapParentTenantId(
|
||||
parentTenantId: string,
|
||||
parentTenantSlug: string,
|
||||
targetTenantIds: {
|
||||
byRowNumber: Map<number, string>;
|
||||
bySourceId: Map<string, string>;
|
||||
bySourceSlug: Map<string, string>;
|
||||
bySourceSlugToTargetSlug: Map<string, string>;
|
||||
},
|
||||
) {
|
||||
if (parentTenantId) {
|
||||
return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId;
|
||||
}
|
||||
if (parentTenantSlug) {
|
||||
return (
|
||||
targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? ""
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function remapParentTenantSlug(
|
||||
parentTenantSlug: string,
|
||||
targetTenantIds: {
|
||||
bySourceSlugToTargetSlug: Map<string, string>;
|
||||
},
|
||||
) {
|
||||
if (!parentTenantSlug) return "";
|
||||
return (
|
||||
targetTenantIds.bySourceSlugToTargetSlug.get(
|
||||
parentTenantSlug.toLowerCase(),
|
||||
) ?? parentTenantSlug
|
||||
);
|
||||
}
|
||||
|
||||
function createTenantImportId() {
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `00000000-0000-4000-8000-${Math.random()
|
||||
.toString(16)
|
||||
.slice(2, 14)
|
||||
.padEnd(12, "0")}`;
|
||||
}
|
||||
|
||||
function findTenantImportConflicts(
|
||||
row: TenantCSVRow,
|
||||
tenants: TenantSummary[],
|
||||
): TenantImportConflict[] {
|
||||
const conflicts: TenantImportConflict[] = [];
|
||||
const matchingId = row.tenantId
|
||||
? tenants.find((tenant) => tenant.id === row.tenantId)
|
||||
: undefined;
|
||||
const matchingSlug = row.slug
|
||||
? tenants.find(
|
||||
(tenant) => normalizeToken(tenant.slug) === normalizeToken(row.slug),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (row.tenantId && !matchingId) {
|
||||
conflicts.push("external_tenant_id");
|
||||
}
|
||||
if (matchingSlug && matchingSlug.id !== row.tenantId) {
|
||||
conflicts.push("slug_exists");
|
||||
}
|
||||
if (
|
||||
row.parentTenantId &&
|
||||
!tenants.some((tenant) => tenant.id === row.parentTenantId)
|
||||
) {
|
||||
conflicts.push("parent_tenant_id_unresolved");
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function findTenantCandidates(
|
||||
row: TenantCSVRow,
|
||||
tenants: TenantSummary[],
|
||||
): TenantImportCandidate[] {
|
||||
return tenants
|
||||
.map((tenant) => {
|
||||
const nameScore = similarity(row.name, tenant.name);
|
||||
const slugScore =
|
||||
normalizeToken(row.slug) &&
|
||||
normalizeToken(row.slug) === normalizeToken(tenant.slug)
|
||||
? 0.98
|
||||
: 0;
|
||||
const exactName =
|
||||
normalizeToken(row.name) === normalizeToken(tenant.name);
|
||||
const score = exactName ? 1 : Math.max(slugScore, nameScore);
|
||||
const reason: TenantImportCandidate["reason"] = exactName
|
||||
? "exact_name"
|
||||
: slugScore >= 0.98
|
||||
? "exact_slug"
|
||||
: "similar_name";
|
||||
return {
|
||||
tenantId: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
score,
|
||||
reason,
|
||||
};
|
||||
})
|
||||
.filter((candidate) => candidate.score >= 0.45)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function parseCSV(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let current = "";
|
||||
let row: string[] = [];
|
||||
let quoted = false;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (char === '"' && quoted && next === '"') {
|
||||
current += '"';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if (char === "," && !quoted) {
|
||||
row.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
if ((char === "\n" || char === "\r") && !quoted) {
|
||||
if (char === "\r" && next === "\n") i += 1;
|
||||
row.push(current);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current !== "" || row.length > 0) {
|
||||
row.push(current);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function formatCSVRecord(record: string[]) {
|
||||
return record
|
||||
.map((value) => {
|
||||
if (!/[",\r\n]/.test(value)) return value;
|
||||
return `"${value.replaceAll('"', '""')}"`;
|
||||
})
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string) {
|
||||
return value.trim().toLowerCase().replaceAll(" ", "_");
|
||||
}
|
||||
|
||||
function slugFromMailingList(value: string) {
|
||||
if (!value) return "";
|
||||
return normalizeTenantSlug(value.split("@")[0] ?? value);
|
||||
}
|
||||
|
||||
function slugFromParentOrg(value: string, slugByName: Map<string, string>) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
const match = trimmed.match(/\(([^)]+)\)/);
|
||||
if (match?.[1]) {
|
||||
return slugFromMailingList(match[1]);
|
||||
}
|
||||
return slugByName.get(trimmed) ?? normalizeTenantSlug(trimmed);
|
||||
}
|
||||
|
||||
function normalizeTenantSlug(value: string) {
|
||||
let slug = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-");
|
||||
slug = slug.replace(/^-+|-+$/g, "");
|
||||
if (slug.length > 25) {
|
||||
slug = slug.slice(0, 25).replace(/-+$/g, "");
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
function normalizeToken(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, "")
|
||||
.replace(/[^\p{L}\p{N}]/gu, "");
|
||||
}
|
||||
|
||||
function suggestUniqueTenantSlug(value: string, tenants: TenantSummary[]) {
|
||||
const base = slugify(value) || "tenant";
|
||||
const used = new Set(tenants.map((tenant) => tenant.slug.toLowerCase()));
|
||||
if (!used.has(base)) {
|
||||
return base;
|
||||
}
|
||||
|
||||
let index = 2;
|
||||
while (used.has(`${base}-${index}`)) {
|
||||
index += 1;
|
||||
}
|
||||
return `${base}-${index}`;
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function similarity(left: string, right: string) {
|
||||
const a = normalizeToken(left);
|
||||
const b = normalizeToken(right);
|
||||
if (!a || !b) return 0;
|
||||
if (a === b) return 1;
|
||||
if (a.includes(b) || b.includes(a)) {
|
||||
return Math.min(a.length, b.length) / Math.max(a.length, b.length);
|
||||
}
|
||||
|
||||
const distance = levenshtein(a, b);
|
||||
return 1 - distance / Math.max(a.length, b.length);
|
||||
}
|
||||
|
||||
function levenshtein(left: string, right: string) {
|
||||
const previous = Array.from({ length: right.length + 1 }, (_, i) => i);
|
||||
const current = Array.from({ length: right.length + 1 }, () => 0);
|
||||
|
||||
for (let i = 1; i <= left.length; i += 1) {
|
||||
current[0] = i;
|
||||
for (let j = 1; j <= right.length; j += 1) {
|
||||
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
|
||||
current[j] = Math.min(
|
||||
current[j - 1] + 1,
|
||||
previous[j] + 1,
|
||||
previous[j - 1] + cost,
|
||||
);
|
||||
}
|
||||
previous.splice(0, previous.length, ...current);
|
||||
}
|
||||
|
||||
return previous[right.length];
|
||||
}
|
||||
@@ -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,
|
||||
@@ -87,6 +89,7 @@ const getTenantIcon = (type?: string) => {
|
||||
return Briefcase;
|
||||
case "PERSONAL":
|
||||
return UserCircle;
|
||||
case "ORGANIZATION":
|
||||
case "USER_GROUP":
|
||||
return Network;
|
||||
default:
|
||||
@@ -194,7 +197,9 @@ const SidebarNode: React.FC<{
|
||||
const MemberTable: React.FC<{
|
||||
tenantSlug: string;
|
||||
onRefreshTrigger?: number;
|
||||
}> = ({ tenantSlug, onRefreshTrigger }) => {
|
||||
allTenants?: TenantSummary[];
|
||||
}> = ({ tenantSlug, onRefreshTrigger, allTenants }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
|
||||
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
|
||||
@@ -203,6 +208,55 @@ const MemberTable: React.FC<{
|
||||
|
||||
const members = data?.items ?? [];
|
||||
|
||||
const [isMoveOpen, setIsMoveOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null);
|
||||
const [targetTenantSlug, setTargetTenantSlug] = useState("");
|
||||
const [searchTenant, setSearchTenant] = useState("");
|
||||
|
||||
const moveMutation = useMutation({
|
||||
mutationFn: (newSlug: string) => {
|
||||
if (!selectedUser) throw new Error("No user selected");
|
||||
return updateUser(selectedUser.id, { tenantSlug: newSlug });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
||||
toast.success(
|
||||
t("msg.info.saved_success", "사용자 조직이 변경되었습니다."),
|
||||
);
|
||||
setIsMoveOpen(false);
|
||||
setSelectedUser(null);
|
||||
refetch();
|
||||
},
|
||||
onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
updateUser(userId, { tenantSlug, isRemoveTenant: true }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
||||
toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
|
||||
refetch();
|
||||
},
|
||||
onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
|
||||
});
|
||||
|
||||
const handleMoveClick = (user: UserSummary) => {
|
||||
setSelectedUser(user);
|
||||
setTargetTenantSlug("");
|
||||
setIsMoveOpen(true);
|
||||
};
|
||||
|
||||
const filteredTenants = React.useMemo(() => {
|
||||
if (!allTenants) return [];
|
||||
if (!searchTenant) return allTenants;
|
||||
return allTenants.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(searchTenant.toLowerCase()) ||
|
||||
t.slug.toLowerCase().includes(searchTenant.toLowerCase()),
|
||||
);
|
||||
}, [allTenants, searchTenant]);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="py-20 text-center text-muted-foreground animate-pulse">
|
||||
@@ -263,6 +317,28 @@ const MemberTable: React.FC<{
|
||||
{t("ui.common.detail", "상세보기")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleMoveClick(user)}>
|
||||
<ArrowRight size={14} className="mr-2" />
|
||||
{t("ui.common.move_org", "타 조직으로 이동")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.users.confirm_remove_org",
|
||||
"이 조직에서 사용자를 제외하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeMutation.mutate(user.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("ui.common.remove_org", "조직에서 제외")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
@@ -270,6 +346,65 @@ const MemberTable: React.FC<{
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Dialog open={isMoveOpen} onOpenChange={setIsMoveOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.common.move_org", "타 조직으로 이동")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedUser?.name} 사용자를 이동할 타 조직을 선택하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Input
|
||||
placeholder={t("ui.common.search", "조직 검색...")}
|
||||
value={searchTenant}
|
||||
onChange={(e) => setSearchTenant(e.target.value)}
|
||||
/>
|
||||
<ScrollArea className="h-48 border rounded-md p-2">
|
||||
<div className="space-y-1">
|
||||
{filteredTenants.map((tItem) => (
|
||||
<Button
|
||||
key={tItem.id}
|
||||
variant={
|
||||
targetTenantSlug === tItem.slug ? "secondary" : "ghost"
|
||||
}
|
||||
className="w-full justify-start text-sm"
|
||||
onClick={() => setTargetTenantSlug(tItem.slug)}
|
||||
>
|
||||
{React.createElement(getTenantIcon(tItem.type), {
|
||||
size: 14,
|
||||
className: "mr-2 opacity-70",
|
||||
})}
|
||||
<span>{tItem.name}</span>
|
||||
<span className="ml-2 text-[10px] text-muted-foreground opacity-50">
|
||||
{tItem.slug}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{filteredTenants.length === 0 && (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t("msg.common.no_results", "검색 결과가 없습니다.")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsMoveOpen(false)}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => moveMutation.mutate(targetTenantSlug)}
|
||||
disabled={!targetTenantSlug || moveMutation.isPending}
|
||||
>
|
||||
{moveMutation.isPending ? "..." : t("ui.common.move", "이동")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -289,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,
|
||||
@@ -296,7 +449,7 @@ function TenantUserGroupsTab() {
|
||||
refetch: refetchTree,
|
||||
} = useQuery({
|
||||
queryKey: ["tenants-full-tree-v2"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
const { currentBase, subTree } = useMemo(() => {
|
||||
@@ -478,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>
|
||||
@@ -573,6 +736,7 @@ function TenantUserGroupsTab() {
|
||||
<MemberTable
|
||||
tenantSlug={selectedNode.slug}
|
||||
onRefreshTrigger={refreshMembersCount}
|
||||
allTenants={allTenantsData?.items ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -701,6 +865,7 @@ const UserAddDialog: React.FC<{
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updateUser(selectedUserId, { tenantSlug });
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
||||
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다."));
|
||||
onOpenChange(false);
|
||||
resetFields();
|
||||
|
||||
@@ -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", "대상 테넌트")}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
ClipboardCopy,
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -12,32 +20,101 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Checkbox } from "../../components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../components/ui/tabs";
|
||||
import {
|
||||
type TenantSummary,
|
||||
type UserAppointment,
|
||||
type UserCreateRequest,
|
||||
type UserCreateResponse,
|
||||
createUser,
|
||||
fetchAllTenants,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label?: string;
|
||||
type?: "text" | "number" | "boolean" | "date";
|
||||
required?: boolean;
|
||||
adminOnly?: boolean;
|
||||
validation?: string;
|
||||
isLoginId?: boolean;
|
||||
};
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
type OrgChartTenantSelection,
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
|
||||
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
||||
type UserCategory = "hanmac" | "external" | "personal";
|
||||
|
||||
type PickerTarget = { kind: "appointment"; index: number };
|
||||
|
||||
type AppointmentDraft = UserAppointment & {
|
||||
draftId: string;
|
||||
};
|
||||
|
||||
type AdminFrontTestHooks = {
|
||||
selectUserAppointmentTenant?: (
|
||||
selection: OrgChartTenantSelection,
|
||||
index?: number,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
function createDraftId() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
|
||||
}
|
||||
|
||||
async function resolveTenantSelection(
|
||||
selection: OrgChartTenantSelection,
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
const cached = tenants.find((tenant) => tenant.id === selection.id);
|
||||
if (cached) {
|
||||
return {
|
||||
id: cached.id,
|
||||
name: cached.name,
|
||||
slug: cached.slug,
|
||||
};
|
||||
}
|
||||
|
||||
const tenant = await fetchTenant(selection.id);
|
||||
return {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyAppointment(): AppointmentDraft {
|
||||
return {
|
||||
draftId: createDraftId(),
|
||||
tenantId: "",
|
||||
tenantName: "",
|
||||
tenantSlug: "",
|
||||
isOwner: false,
|
||||
grade: "",
|
||||
jobTitle: "",
|
||||
position: "",
|
||||
};
|
||||
}
|
||||
|
||||
function UserCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [generatedPassword, setGeneratedPassword] = React.useState<
|
||||
@@ -45,10 +122,19 @@ function UserCreatePage() {
|
||||
>(null);
|
||||
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
||||
const [autoPassword, setAutoPassword] = React.useState(true);
|
||||
const [userCategory, setUserCategory] =
|
||||
React.useState<UserCategory>("hanmac");
|
||||
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
||||
AppointmentDraft[]
|
||||
>([]);
|
||||
const [pickerTarget, setPickerTarget] = React.useState<PickerTarget | null>(
|
||||
null,
|
||||
);
|
||||
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 ?? [];
|
||||
|
||||
@@ -69,11 +155,12 @@ function UserCreatePage() {
|
||||
password: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
role: "user",
|
||||
tenantSlug: "",
|
||||
tenantSlug: searchParams.get("tenantSlug") || "",
|
||||
department: "",
|
||||
grade: "",
|
||||
position: "",
|
||||
jobTitle: "",
|
||||
role: "user",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
@@ -85,8 +172,27 @@ function UserCreatePage() {
|
||||
}
|
||||
}, [profile, setValue]);
|
||||
|
||||
const hanmacFamilyTenantId = React.useMemo(() => {
|
||||
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
||||
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
||||
return envTenantId.trim();
|
||||
}
|
||||
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
||||
}, [tenants]);
|
||||
const nonHanmacFamilyTenants = React.useMemo(
|
||||
() => filterNonHanmacFamilyTenants(tenants, hanmacFamilyTenantId),
|
||||
[tenants, hanmacFamilyTenantId],
|
||||
);
|
||||
|
||||
const selectedTenantSlug = watch("tenantSlug");
|
||||
const selectedTenant = tenants.find((t) => t.slug === selectedTenantSlug);
|
||||
const personalTenant = React.useMemo(
|
||||
() => resolvePersonalTenant(tenants),
|
||||
[tenants],
|
||||
);
|
||||
const selectedTenant =
|
||||
userCategory !== "external"
|
||||
? undefined
|
||||
: nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug);
|
||||
|
||||
const selectedTenantId = selectedTenant?.id ?? "";
|
||||
|
||||
@@ -125,6 +231,115 @@ function UserCreatePage() {
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
import.meta.env.ORGFRONT_URL,
|
||||
{
|
||||
tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const applyTenantSelection = React.useCallback(
|
||||
async (selection: OrgChartTenantSelection, target: PickerTarget) => {
|
||||
setIsResolvingTenant(true);
|
||||
try {
|
||||
const tenant = await resolveTenantSelection(selection, tenants);
|
||||
setAdditionalAppointments((current) =>
|
||||
current.map((appointment, index) =>
|
||||
index === target.index
|
||||
? {
|
||||
...appointment,
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
}
|
||||
: appointment,
|
||||
),
|
||||
);
|
||||
setPickerTarget(null);
|
||||
} catch (_) {
|
||||
setError(
|
||||
t(
|
||||
"msg.admin.users.create.tenant_resolve_failed",
|
||||
"선택한 테넌트 정보를 불러오지 못했습니다.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsResolvingTenant(false);
|
||||
}
|
||||
},
|
||||
[tenants],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pickerTarget) return;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const selection = parseOrgChartTenantSelection(event.data);
|
||||
if (!selection) return;
|
||||
void applyTenantSelection(selection, pickerTarget);
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
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,
|
||||
createEmptyAppointment(),
|
||||
]);
|
||||
};
|
||||
|
||||
const updateAppointment = (
|
||||
index: number,
|
||||
patch: Partial<UserAppointment>,
|
||||
) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
current.map((appointment, currentIndex) => {
|
||||
if (currentIndex === index) {
|
||||
return { ...appointment, ...patch };
|
||||
}
|
||||
if (patch.isOwner === true) {
|
||||
return { ...appointment, isOwner: false };
|
||||
}
|
||||
return appointment;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeAppointment = (index: number) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
current.filter((_, currentIndex) => currentIndex !== index),
|
||||
);
|
||||
};
|
||||
|
||||
const handleUserCategoryChange = (value: string) => {
|
||||
const nextCategory = value as UserCategory;
|
||||
setUserCategory(nextCategory);
|
||||
if (nextCategory !== "hanmac") {
|
||||
setAdditionalAppointments([]);
|
||||
}
|
||||
};
|
||||
|
||||
const ensurePersonalTenant = async () => {
|
||||
return personalTenant;
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createUser,
|
||||
onSuccess: (data: UserCreateResponse) => {
|
||||
@@ -144,12 +359,103 @@ function UserCreatePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: UserFormValues) => {
|
||||
const onSubmit = async (data: UserFormValues) => {
|
||||
setError(null);
|
||||
setGeneratedPassword(null);
|
||||
setCreatedEmail(null);
|
||||
|
||||
const payload = { ...data };
|
||||
const {
|
||||
hanmacFamily: _hanmacFamily,
|
||||
userType: _userType,
|
||||
...formMetadata
|
||||
} = data.metadata ?? {};
|
||||
const metadata: Record<string, unknown> = {
|
||||
...formMetadata,
|
||||
};
|
||||
|
||||
const payload: UserCreateRequest = {
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
role: data.role,
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (userCategory === "external") {
|
||||
if (!data.tenantSlug) {
|
||||
setError(
|
||||
t(
|
||||
"msg.admin.users.create.external_tenant_required",
|
||||
"외부 사용자는 대표소속을 선택해 주세요.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
payload.tenantSlug = data.tenantSlug;
|
||||
payload.department = data.department;
|
||||
payload.grade = data.grade;
|
||||
payload.position = data.position;
|
||||
payload.jobTitle = data.jobTitle;
|
||||
}
|
||||
|
||||
if (userCategory === "personal") {
|
||||
try {
|
||||
const tenant = await ensurePersonalTenant();
|
||||
payload.tenantSlug = tenant.slug;
|
||||
payload.metadata = {
|
||||
...metadata,
|
||||
personalTenantId: tenant.id,
|
||||
};
|
||||
} catch (_) {
|
||||
setError(
|
||||
t(
|
||||
"msg.admin.users.create.personal_tenant_failed",
|
||||
"Personal 테넌트를 준비하지 못했습니다.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (userCategory === "hanmac") {
|
||||
const appointments = additionalAppointments
|
||||
.filter((appointment) => appointment.tenantId)
|
||||
.map((appointment) => ({
|
||||
tenantId: appointment.tenantId,
|
||||
tenantSlug: appointment.tenantSlug,
|
||||
tenantName: appointment.tenantName,
|
||||
isPrimary: appointment.isOwner,
|
||||
isOwner: appointment.isOwner,
|
||||
grade: appointment.grade,
|
||||
jobTitle: appointment.jobTitle,
|
||||
position: appointment.position,
|
||||
}));
|
||||
|
||||
if (appointments.length === 0) {
|
||||
setError(
|
||||
t(
|
||||
"msg.admin.users.create.appointment_required",
|
||||
"한맥 가족 구성원은 소속 테넌트를 하나 이상 선택해 주세요.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const primary = appointments.find((a) => a.isOwner);
|
||||
if (primary) {
|
||||
metadata.primaryTenantId = primary.tenantId;
|
||||
metadata.primaryTenantSlug = primary.tenantSlug;
|
||||
metadata.primaryTenantName = primary.tenantName;
|
||||
metadata.primaryTenantIsOwner = true;
|
||||
}
|
||||
|
||||
payload.additionalAppointments = appointments;
|
||||
payload.metadata = {
|
||||
...metadata,
|
||||
additionalAppointments: appointments,
|
||||
};
|
||||
}
|
||||
|
||||
if (autoPassword) {
|
||||
payload.password = "";
|
||||
@@ -351,82 +657,253 @@ function UserCreatePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenantSlug">
|
||||
{t("ui.admin.users.create.form.tenant", "테넌트 (Tenant)")}
|
||||
</Label>
|
||||
<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>
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
id="tenantSlug"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("tenantSlug")}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
"ui.admin.users.create.form.tenant_global",
|
||||
"시스템 전역 (소속 없음)",
|
||||
)}
|
||||
</option>
|
||||
<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"
|
||||
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
한맥가족 구성원
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="external"
|
||||
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
외부 기업 회원
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="personal"
|
||||
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
개인 회원
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<TabsContent value="external" className="mt-4 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenantSlug">대표소속</Label>
|
||||
<select
|
||||
id="tenantSlug"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("tenantSlug")}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
{nonHanmacFamilyTenants.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.slug}>
|
||||
{tenant.name} ({tenant.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">부서</Label>
|
||||
<Input id="department" {...register("department")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">
|
||||
{t("ui.admin.users.create.form.department", "부서")}
|
||||
</Label>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="grade">직급</Label>
|
||||
<Input
|
||||
id="grade"
|
||||
placeholder="수석/책임/선임"
|
||||
{...register("grade")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="position">직책</Label>
|
||||
<Input
|
||||
id="position"
|
||||
placeholder="팀장/센터장"
|
||||
{...register("position")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="jobTitle">직무</Label>
|
||||
<Input
|
||||
id="jobTitle"
|
||||
placeholder="프론트엔드 개발"
|
||||
{...register("jobTitle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<Input
|
||||
id="department"
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.department_placeholder",
|
||||
"개발팀",
|
||||
)}
|
||||
{...register("department")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="hanmac" className="mt-4 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
소속별 직급/직책/직무
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
테넌트별 조직장 여부, 직급, 직책, 직무를 입력합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addAppointment}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="position">
|
||||
{t("ui.admin.users.create.form.position", "직급")}
|
||||
</Label>
|
||||
{additionalAppointments.map((appointment, index) => (
|
||||
<div
|
||||
key={appointment.draftId}
|
||||
data-testid={`appointment-row-${index}`}
|
||||
className="grid gap-3 rounded-md border p-3 lg:grid-cols-[minmax(280px,1.2fr)_minmax(280px,1fr)_auto] lg:items-end"
|
||||
>
|
||||
<div
|
||||
className="space-y-2"
|
||||
data-testid={`appointment-tenant-owner-line-${index}`}
|
||||
>
|
||||
<Label>소속 테넌트</Label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setPickerTarget({
|
||||
kind: "appointment",
|
||||
index,
|
||||
})
|
||||
}
|
||||
disabled={isResolvingTenant}
|
||||
data-testid={`appointment-tenant-picker-${index}`}
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
{appointment.tenantName || "테넌트 선택"}
|
||||
</Button>
|
||||
{appointment.tenantSlug && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{appointment.tenantSlug}
|
||||
</span>
|
||||
)}
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Switch
|
||||
checked={appointment.isOwner}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isOwner: checked === true,
|
||||
})
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.users.detail.form.appointment_owner",
|
||||
"대표 조직",
|
||||
)}
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.appointment_owner",
|
||||
"대표 조직",
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="position"
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.position_placeholder",
|
||||
"수석/책임/선임",
|
||||
)}
|
||||
{...register("position")}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-3 sm:grid-cols-3"
|
||||
data-testid={`appointment-position-line-${index}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`appointment-grade-${index}`}>
|
||||
직급
|
||||
</Label>
|
||||
<Input
|
||||
id={`appointment-grade-${index}`}
|
||||
value={appointment.grade ?? ""}
|
||||
onChange={(event) =>
|
||||
updateAppointment(index, {
|
||||
grade: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`appointment-job-title-${index}`}>
|
||||
직무
|
||||
</Label>
|
||||
<Input
|
||||
id={`appointment-job-title-${index}`}
|
||||
value={appointment.jobTitle ?? ""}
|
||||
onChange={(event) =>
|
||||
updateAppointment(index, {
|
||||
jobTitle: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`appointment-position-${index}`}>
|
||||
직책
|
||||
</Label>
|
||||
<Input
|
||||
id={`appointment-position-${index}`}
|
||||
value={appointment.position ?? ""}
|
||||
onChange={(event) =>
|
||||
updateAppointment(index, {
|
||||
position: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeAppointment(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="jobTitle">
|
||||
{t("ui.admin.users.create.form.job_title", "직무")}
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="jobTitle"
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.job_title_placeholder",
|
||||
"프론트엔드 개발",
|
||||
)}
|
||||
{...register("jobTitle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="personal" className="mt-4">
|
||||
<div
|
||||
className="rounded-md border bg-muted/30 p-4 text-sm"
|
||||
data-testid="personal-tenant-summary"
|
||||
>
|
||||
{personalTenant
|
||||
? `Personal (${personalTenant.slug})`
|
||||
: "Personal 테넌트로 생성됩니다."}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{userSchema.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
@@ -479,8 +956,11 @@ function UserCreatePage() {
|
||||
{errors.metadata?.[field.key] && (
|
||||
<p className="text-xs text-destructive">
|
||||
{
|
||||
(errors.metadata[field.key] as { message?: string })
|
||||
?.message
|
||||
(
|
||||
errors.metadata[field.key] as {
|
||||
message?: string;
|
||||
}
|
||||
)?.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
@@ -490,38 +970,6 @@ function UserCreatePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">
|
||||
{t("ui.admin.users.create.form.role", "역할 (Role)")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<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 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("role")}
|
||||
>
|
||||
<option value="user">
|
||||
{t("ui.admin.role.user", "TENANT MEMBER")}
|
||||
</option>
|
||||
<option value="tenant_admin">
|
||||
{t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
|
||||
</option>
|
||||
<option value="rp_admin">
|
||||
{t("ui.admin.role.rp_admin", "RP ADMIN")}
|
||||
</option>
|
||||
<option value="super_admin">
|
||||
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.users.create.form.role_help",
|
||||
"시스템 접근 권한을 결정합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -541,6 +989,32 @@ function UserCreatePage() {
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog
|
||||
open={pickerTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPickerTarget(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.users.create.form.picker_description",
|
||||
"org-chart에서 테넌트를 선택하면 사용자 소속에 반영됩니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<iframe
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user