1
0
forked from baron/baron-sso

3 Commits

Author SHA1 Message Date
ai-cell-a100-1
7ad1743758 모바일 승인 완료 화면에서 verify_failed 오류 노출 개선 2026-05-19 17:40:27 +09:00
ai-cell-a100-1
5376baf6d8 rebase 전 변경된 파일 추가 2026-04-24 15:22:46 +09:00
ai-cell-a100-1
7ecb19e397 fc 2026-04-24 15:22:45 +09:00
407 changed files with 9052 additions and 71202 deletions

View File

@@ -7,6 +7,7 @@ APP_ENV=stage # 애플리케이션 실행 환경 (dev, stage, production)
TZ=Asia/Seoul
# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업)
IDP_PROVIDER=ory
# --- Infrastructure Ports ---
@@ -76,20 +77,20 @@ HYDRA_DB=ory_hydra
KETO_DB=ory_keto
# Ory Kratos Configuration
KRATOS_VERSION=v26.2.0-distroless
KRATOS_VERSION=v25.4.0-distroless
# KRATOS_PUBLIC_PORT=4433 # Internal only
# KRATOS_ADMINFRONT_PORT=4434 # Internal only
KRATOS_UI_NODE_VERSION=v26.2.0
KRATOS_UI_NODE_VERSION=v25.4.0
# KRATOS_UI_PORT=4455 # Internal only
# Ory Hydra Configuration
HYDRA_VERSION=v26.2.0-distroless
HYDRA_VERSION=v25.4.0-distroless
# HYDRA_PUBLIC_PORT=4441 # Internal only
# HYDRA_ADMINFRONT_PORT=4445 # Internal only
# Ory Keto Configuration
KETO_VERSION=v26.2.0-distroless
KETO_VERSION=v25.4.0-distroless
# KETO_READ_PORT=4466 # Internal only
# KETO_WRITE_PORT=4467 # Internal only
KETO_READ_URL=http://keto:4466
@@ -109,21 +110,16 @@ 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=v26.2.0
OATHKEEPER_VERSION=v25.4.0
OATHKEEPER_UID=1001
OATHKEEPER_GID=1001
OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready
@@ -145,5 +141,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,https://sso.hmac.kr/orgfront/auth/callback
VITE_ORGCHART_URL=
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=

1886
.env.test2 Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -106,16 +106,6 @@ jobs:
provenance: false
sbom: false
- name: Build and push orgfront RC image
uses: docker/build-push-action@v5
with:
context: ./orgfront
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:

View File

@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
run_lint:
description: "Run lint/format checks for Go, Flutter, adminfront, devfront, orgfront"
description: "Run lint/format checks for Go, Flutter, adminfront, devfront"
required: true
type: boolean
default: true
@@ -39,11 +39,6 @@ on:
required: true
type: boolean
default: true
run_orgfront_tests:
description: "Run orgfront Playwright tests"
required: true
type: boolean
default: true
jobs:
lint:
@@ -61,7 +56,6 @@ jobs:
cache-dependency-path: |
adminfront/package-lock.json
devfront/package-lock.json
orgfront/package-lock.json
- name: i18n resource check
run: |
@@ -110,17 +104,6 @@ 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 ci
- 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 \
@@ -826,186 +809,3 @@ 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"
cache: "npm"
cache-dependency-path: orgfront/package-lock.json
- name: Get Playwright version
id: playwright-version
run: |
cd orgfront
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
- 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 ci 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
npx 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 && npx 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
npm 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. \`npx playwright install --with-deps\`"
echo "4. \`npm 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

View File

@@ -89,7 +89,7 @@ jobs:
ssh-keyscan -H "${PROD_HOST}" >> ~/.ssh/known_hosts
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}/adminfront'"
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}'"
# Create the main .env file for Baron SSO on the remote server
# Note: All values are pulled from Gitea secrets and variables
@@ -122,7 +122,6 @@ 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"

View File

@@ -63,7 +63,6 @@ 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 }}
@@ -78,6 +77,7 @@ 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 +91,7 @@ jobs:
USERFRONT_URL=${{ vars.USERFRONT_URL }}
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
VITE_ORGCHART_URL=${{ vars.VITE_ORGCHART_URL }}
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
BACKEND_URL=${{ vars.BACKEND_URL }}
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
@@ -124,13 +124,11 @@ jobs:
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
# 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 }}
# 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
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
EOF
@@ -163,9 +161,8 @@ jobs:
done
# Ory 컨테이너가 직접 읽는 설정은 env 기반으로 완성한 뒤 mount합니다.
bash scripts/render_ory_config.sh
chmod -R 777 config/.generated/ory || true
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
chmod -R 777 docker/ory || true
cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml
@@ -174,36 +171,11 @@ 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 --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 --remove-orphans
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

View File

@@ -27,7 +27,6 @@ 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 }}
@@ -73,7 +72,6 @@ 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 }}
@@ -88,6 +86,7 @@ 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 }}
@@ -99,7 +98,6 @@ 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 }}
@@ -135,14 +133,12 @@ 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/"
@@ -155,7 +151,6 @@ 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"
@@ -167,7 +162,6 @@ 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}'; \

8
.gitignore vendored
View File

@@ -8,7 +8,6 @@
.codex/
.serena/
.generated/
config/.generated/
*.swp
*.log
*.out
@@ -16,7 +15,6 @@ config/.generated/
.npm-cache/
reports
reports/*
config/*.pem
# Docker Services Data (Volumes)
postgres_data/
@@ -41,12 +39,6 @@ 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/

View File

@@ -1,23 +0,0 @@
- generic [ref=e4]:
- generic [ref=e5]:
- img [ref=e7]
- generic [ref=e9]:
- heading "Baron SSO" [level=1] [ref=e10]
- paragraph [ref=e11]: Developer Control Plane
- generic [ref=e12]:
- generic [ref=e13]:
- heading "개발자 포털 로그인" [level=3] [ref=e14]:
- img [ref=e15]
- text: 개발자 포털 로그인
- paragraph [ref=e18]: Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
- generic [ref=e19]:
- button "SSO 계정으로 로그인" [ref=e20] [cursor=pointer]:
- img [ref=e21]
- text: SSO 계정으로 로그인
- img [ref=e23]
- paragraph [ref=e27]:
- text: 개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
- text: 민감한 작업 시 재인증을 요구할 수 있습니다.
- paragraph [ref=e32]:
- text: 인증 정보가 없거나 로그인이 되지 않는 경우
- text: 시스템 관리자에게 문의하세요.

136
Makefile
View File

@@ -10,13 +10,7 @@ endif
COMPOSE_INFRA := compose.infra.yaml
COMPOSE_ORY := compose.ory.yaml
COMPOSE_APP := docker-compose.yaml
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
AUTH_CONFIG_ENV := .generated/auth-config.env
COMPOSE_CLI_ENV_ARGS :=
ifneq (,$(wildcard ./.env))
@@ -24,17 +18,10 @@ 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 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 config/.generated
@mkdir -p .generated
@bash scripts/auth_config.sh build
validate-auth-config: build-auth-config
@@ -45,102 +32,40 @@ 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: up-all
up-all: ensure-networks render-ory-config
up-all: validate-auth-config
@echo "Starting ALL stacks (infra + ory + app)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d
# --- 개별 스택 실행 ---
up-infra: ensure-networks
up-infra:
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
docker compose -f $(COMPOSE_INFRA) up -d
up-ory: ensure-networks render-ory-config
up-ory: validate-auth-config
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) 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-app: validate-auth-config
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d
up-backend: ensure-networks render-ory-config
up-backend: validate-auth-config
@echo "Starting Backend only..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d backend
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d backend
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."; \
fi
up-dev: ensure-infra ensure-ory
up-dev: up-infra up-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..."
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
@@ -187,10 +112,15 @@ 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-orgfront-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-userfront-e2e-tests
CODE_CHECK_TEST_JOBS ?= 1
PLAYWRIGHT_WORKERS ?= 1
@@ -208,8 +138,7 @@ code-check-test-jobs:
code-check-userfront-tests \
code-check-userfront-e2e-tests \
code-check-adminfront-tests \
code-check-devfront-tests \
code-check-orgfront-tests
code-check-devfront-tests
code-check-i18n:
@echo "==> i18n resource check"
@@ -256,19 +185,14 @@ code-check-userfront-lint:
code-check-front-lint:
@echo "==> adminfront biome lint/format check"
rm -rf adminfront/playwright-report adminfront/test-results
cd adminfront && npm ci --ignore-scripts
cd adminfront && npm ci
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 --ignore-scripts
cd devfront && npm ci
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"
@@ -305,7 +229,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 --ignore-scripts) || status=$$?; \
(cd devfront && npm ci) || status=$$?; \
if [ $$status -eq 0 ]; then \
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
@@ -316,22 +240,6 @@ 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

View File

@@ -66,7 +66,7 @@ flowchart
```
### 1. Backend (Go Fiber)
- **Language**: Go 1.26.2+
- **Language**: Go 1.25+
- **Framework**: Fiber v2.25+
- **Database**:
- **ClickHouse**: 감사 로그 (고성능 데이터 수집)
@@ -95,82 +95,6 @@ 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입니다.
- 한맥가족 테넌트 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 규칙을 검증합니다.
### 4. 주요 시나리오 (Core Scenarios)
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
@@ -395,13 +319,11 @@ 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/auth/callback`)
- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/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`만 수집
@@ -436,7 +358,7 @@ USERFRONT_URL=https://sso.example.com
```bash
make validate-auth-config
```
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `config/.generated/auth-config.env`를 생성합니다.
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `.generated/auth-config.env`를 생성합니다.
### 전체 스택 실행 (Running the Stack)
@@ -478,7 +400,7 @@ make validate-auth-config
make verify-auth-config
```
- 생성 파일: `config/.generated/auth-config.env` (compose 실행 시 자동 주입)
- 생성 파일: `.generated/auth-config.env` (compose 실행 시 자동 주입)
- 게이트웨이 경유 환경은 URL 문자열 완전일치 대신 매핑 유효성(`direct_match` / `mapped_match`) 기준으로 검증합니다.
- 관련 정책 문서: `docs/oidc_redirect_mapping_validation_policy.md`
@@ -493,8 +415,8 @@ make up-app
직접 Compose를 사용하려면 다음처럼 env 파일을 함께 주입하세요.
```bash
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
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
```
- **gateway (UserFront 프록시)**: http://localhost:5000 접속

View File

@@ -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.26.2+
- **Language**: Go 1.25+
- **Framework**: Fiber v2.25+
- **Database**:
- **ClickHouse**: Audit Logs (High performance ingestion)

View File

@@ -1,3 +0,0 @@
"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time"
"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"
"Doe","Eric","eric.doe","eric@naver.com","eric2@company.com","Eric","Contract Employee","Manager","org.1|org.2|org.3|org.4|myteam","Manager","02-1234-0000","+1","9765412345","Japanese","General affairs","New York","Facebook","Eric","lunar","19840704","20240704","AB002","20240704 14:00"
1 LastName FirstName ID Personal email Sub email Nickname User type Level Organization Position CompanyMainPhone Mobile/Country code Mobile/Numbers Language Responsibilities Workplace SNS SNS_ID Birthday (solar, lunar) Birthday Entry Date Employee number Account activation time
2 Doe John john.doe john@naver.com john1@company.com; john2@company.com John Permanent Employee Manager org.1|org.2|org.3|myteam Manager 02-0000-0000 +1 9144812222 English Sales management New York Facebook john solar 19830415 20230415 AB001 20230415 08:00
3 Doe Eric eric.doe eric@naver.com eric2@company.com Eric Contract Employee Manager org.1|org.2|org.3|org.4|myteam Manager 02-1234-0000 +1 9765412345 Japanese General affairs New York Facebook Eric lunar 19840704 20240704 AB002 20240704 14:00

View File

@@ -21,11 +21,9 @@
"files": {
"ignore": [
"dist",
".vite",
"node_modules",
"tsconfig*.json",
"test-results",
"test-results.nobody-backup",
"playwright-report"
]
}

View File

@@ -1,50 +0,0 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","",""
"인재성장","2","","","","hr@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"전산관리TF","4","한치영(cyhan@samaneng.com)","","","it@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"기술기획","8","김원기(ba.56669@baroncs.co.kr)","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"경영기획","0","","","","t_266py@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"ERP기획","0","","","","t_136ud@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"디자인기획","0","","","","t_618gm@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"협업증진","0","","","","t_752rp@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"솔루션통합","0","","","","t_683tq@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"네이버웍스관리용","2","슈퍼관리자(su-@samaneng.com)","","","su3@baroncs.co.kr","N","N","N","Y","","",""
"기술개발센터","0","","","","t_536fc@baroncs.co.kr","Y","N","Y","Y","","",""
"일반구조물 div","0","","","","t_568cz@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"DfMA","0","","","","t_538ub@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"일반구조물","0","","","","t_601cu@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"구조물계획","0","","","","t_388gh@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"하부구조","0","","","","t_131xd@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"CM기획","0","","","","t_349dy@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"터널","0","","","","t_068jk@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"CC","0","","","","t_116me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"공정관리","0","","","","t_628of@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
"단가산출","0","","","","t_002sq@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
"상하수도","0","","","","t_323pd@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"천지인","0","","","","t_859sx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"천지인셀","0","","","","t_827ax@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
"용지도셀","0","","","","t_896yy@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
"단지설계 개발","0","","","","t_602uo@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
"인프라솔루션 개발","0","","","","t_566mk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"비탈면/구조물","0","","","","t_726dh@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"Way Draw","0","","","","t_504jn@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"Primal 평면","0","","","","t_284vk@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"Watch BIM","0","","","","t_170el@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"구조물S/W","0","","","","t_019ge@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"Strana","0","","","","t_595rj@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"그래픽스","0","","","","t_934zk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"Modeler","0","","","","t_932vs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"HmEG","0","","","","t_614xb@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"EG-BIM Draw","0","","","","t_563cv@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"Abut&시공통합관제","0","","","","t_762fs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"웹솔루션","0","","","","t_797wn@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"솔루션개발","0","","","","t_923oe@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
"ERP","0","","","","t_481sa@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
"웹디자인","0","","","","t_587ef@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
"GSIM개발","0","","","","t_929kx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"bCMf","0","","","","t_833jy@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
"GSIM","0","","","","t_263tv@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
"PM","0","","","","t_335nb@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
"수자원","0","","","","t_233cs@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"스마트건설","0","","","","t_842me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"시공BIM","0","","","","t_942jh@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 총괄기획실 0 gpd@baroncs.co.kr Y N Y Y
3 인재성장 2 hr@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
4 전산관리TF 4 한치영(cyhan@samaneng.com) it@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
5 기술기획 8 김원기(ba.56669@baroncs.co.kr) tech-planning@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
6 경영기획 0 t_266py@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
7 ERP기획 0 t_136ud@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
8 디자인기획 0 t_618gm@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
9 협업증진 0 t_752rp@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
10 솔루션통합 0 t_683tq@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
11 네이버웍스관리용 2 슈퍼관리자(su-@samaneng.com) su3@baroncs.co.kr N N N Y
12 기술개발센터 0 t_536fc@baroncs.co.kr Y N Y Y
13 일반구조물 div 0 t_568cz@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
14 DfMA 0 t_538ub@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
15 일반구조물 0 t_601cu@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
16 구조물계획 0 t_388gh@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
17 하부구조 0 t_131xd@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
18 CM기획 0 t_349dy@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
19 터널 0 t_068jk@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
20 CC 0 t_116me@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
21 공정관리 0 t_628of@baroncs.co.kr Y N Y Y CC(t_116me@baroncs.co.kr)
22 단가산출 0 t_002sq@baroncs.co.kr Y N Y Y CC(t_116me@baroncs.co.kr)
23 상하수도 0 t_323pd@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
24 천지인 0 t_859sx@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
25 천지인셀 0 t_827ax@baroncs.co.kr Y N Y Y 천지인(t_859sx@baroncs.co.kr)
26 용지도셀 0 t_896yy@baroncs.co.kr Y N Y Y 천지인(t_859sx@baroncs.co.kr)
27 단지설계 개발 0 t_602uo@baroncs.co.kr Y N Y Y 천지인(t_859sx@baroncs.co.kr)
28 인프라솔루션 개발 0 t_566mk@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
29 비탈면/구조물 0 t_726dh@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
30 Way Draw 0 t_504jn@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
31 Primal 평면 0 t_284vk@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
32 Watch BIM 0 t_170el@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
33 구조물S/W 0 t_019ge@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
34 Strana 0 t_595rj@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
35 그래픽스 0 t_934zk@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
36 Modeler 0 t_932vs@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
37 HmEG 0 t_614xb@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
38 EG-BIM Draw 0 t_563cv@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
39 Abut&시공통합관제 0 t_762fs@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
40 웹솔루션 0 t_797wn@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
41 솔루션개발 0 t_923oe@baroncs.co.kr Y N Y Y 웹솔루션(t_797wn@baroncs.co.kr)
42 ERP 0 t_481sa@baroncs.co.kr Y N Y Y 웹솔루션(t_797wn@baroncs.co.kr)
43 웹디자인 0 t_587ef@baroncs.co.kr Y N Y Y 웹솔루션(t_797wn@baroncs.co.kr)
44 GSIM개발 0 t_929kx@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
45 bCMf 0 t_833jy@baroncs.co.kr Y N Y Y GSIM개발(t_929kx@baroncs.co.kr)
46 GSIM 0 t_263tv@baroncs.co.kr Y N Y Y GSIM개발(t_929kx@baroncs.co.kr)
47 PM 0 t_335nb@baroncs.co.kr Y N Y Y GSIM개발(t_929kx@baroncs.co.kr)
48 수자원 0 t_233cs@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
49 스마트건설 0 t_842me@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
50 시공BIM 0 t_942jh@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)

View File

@@ -1,50 +0,0 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","",""
"인재성장","2","","","","talent-growth@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"전산관리TF","4","","","","it-admin-tf@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"기술기획","8","","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"경영기획","0","","","","management-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"ERP기획","0","","","","erp-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"디자인기획","0","","","","design-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"협업증진","0","","","","collaboration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"솔루션통합","0","","","","solution-integration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"네이버웍스관리용","2","","","","su2@baroncs.co.kr","N","N","N","Y","","",""
"기술개발센터","0","","","","tdc@baroncs.co.kr","Y","N","Y","Y","","",""
"일반구조물 div","0","","","","structural-division@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"DfMA","0","","","","dfma@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"일반구조물","0","","","","structural-design@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"구조물계획","0","","","","structure-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"하부구조","0","","","","substructure@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"CM기획","0","","","","cm-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"터널","0","","","","tunnel@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"CC","0","","","","cost-control@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"공정관리","0","","","","schedule-control@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
"단가산출","0","","","","cost-estimate@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
"상하수도","0","","","","water-sewer@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"천지인","0","","","","cheonjijin@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"천지인셀","0","","","","cheonjijin-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"용지도셀","0","","","","land-map-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"단지설계 개발","0","","","","site-design-dev@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"인프라솔루션 개발","0","","","","infra-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"비탈면/구조물","0","","","","slope-structures@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Way Draw","0","","","","way-draw@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Primal 평면","0","","","","primal-plan@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Watch BIM","0","","","","watch-bim@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"구조물S/W","0","","","","structural-software@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"Strana","0","","","","strana@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"그래픽스","0","","","","graphics@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"Modeler","0","","","","modeler@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"HmEG","0","","","","hmeg@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"EG-BIM Draw","0","","","","eg-bim-draw@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"Abut&시공통합관제","0","","","","abut-control@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"웹솔루션","0","","","","web-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"솔루션개발","0","","","","solution-dev@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"ERP","0","","","","erp@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"웹디자인","0","","","","web-design@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"GSIM개발","0","","","","gsim-dev@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"bCMf","0","","","","bcmf@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"GSIM","0","","","","gsim@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"PM","0","","","","project-management@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"수자원","0","","","","water-resources@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"스마트건설","0","","","","smart-construction@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"시공BIM","0","","","","construction-bim@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 총괄기획실 0 gpd@baroncs.co.kr Y N Y Y
3 인재성장 2 talent-growth@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
4 전산관리TF 4 it-admin-tf@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
5 기술기획 8 tech-planning@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
6 경영기획 0 management-planning@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
7 ERP기획 0 erp-planning@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
8 디자인기획 0 design-planning@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
9 협업증진 0 collaboration@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
10 솔루션통합 0 solution-integration@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
11 네이버웍스관리용 2 su2@baroncs.co.kr N N N Y
12 기술개발센터 0 tdc@baroncs.co.kr Y N Y Y
13 일반구조물 div 0 structural-division@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
14 DfMA 0 dfma@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
15 일반구조물 0 structural-design@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
16 구조물계획 0 structure-planning@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
17 하부구조 0 substructure@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
18 CM기획 0 cm-planning@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
19 터널 0 tunnel@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
20 CC 0 cost-control@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
21 공정관리 0 schedule-control@baroncs.co.kr Y N Y Y CC(cost-control@baroncs.co.kr)
22 단가산출 0 cost-estimate@baroncs.co.kr Y N Y Y CC(cost-control@baroncs.co.kr)
23 상하수도 0 water-sewer@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
24 천지인 0 cheonjijin@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
25 천지인셀 0 cheonjijin-cell@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
26 용지도셀 0 land-map-cell@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
27 단지설계 개발 0 site-design-dev@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
28 인프라솔루션 개발 0 infra-solutions@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
29 비탈면/구조물 0 slope-structures@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
30 Way Draw 0 way-draw@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
31 Primal 평면 0 primal-plan@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
32 Watch BIM 0 watch-bim@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
33 구조물S/W 0 structural-software@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
34 Strana 0 strana@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
35 그래픽스 0 graphics@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
36 Modeler 0 modeler@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
37 HmEG 0 hmeg@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
38 EG-BIM Draw 0 eg-bim-draw@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
39 Abut&시공통합관제 0 abut-control@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
40 웹솔루션 0 web-solutions@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
41 솔루션개발 0 solution-dev@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
42 ERP 0 erp@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
43 웹디자인 0 web-design@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
44 GSIM개발 0 gsim-dev@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
45 bCMf 0 bcmf@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
46 GSIM 0 gsim@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
47 PM 0 project-management@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
48 수자원 0 water-resources@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
49 스마트건설 0 smart-construction@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
50 시공BIM 0 construction-bim@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)

View File

@@ -13,9 +13,9 @@
"lint:fix": "biome check . --write",
"format": "biome format . --write",
"preview": "vite preview",
"test": "node ./node_modules/playwright/cli.js test",
"test": "npx playwright test",
"test:unit": "vitest run",
"test:ui": "node ./node_modules/playwright/cli.js test --ui",
"test:ui": "npx playwright test --ui",
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
},
"dependencies": {

View File

@@ -1,12 +1,5 @@
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;
@@ -64,14 +57,10 @@ export default defineConfig({
use: { ...devices["Desktop Firefox"] },
},
...(shouldIncludeWebKit()
? [
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
]
: []),
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
/* Run your local dev server before starting the tests */

View File

@@ -1,44 +0,0 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"기술개발센터","1","","","","tdc@samaneng.com","N","N","N","Y","","",""
"경영전략본부","0","","","","business-strategy@samaneng.com","Y","N","Y","Y","","",""
"기획부","1","변역근(ykbyun@samaneng.com)","","","planning@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"업무팀","0","","","","t_226wn@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"PQ팀","0","","","","t_978bl@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"재무회계팀","0","","","","t_186qz@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"대외협력팀","0","","","","t_466et@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"인사총무부","0","","","","t_784bn@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"네이버웍스관리용","1","슈퍼관리자(su-@samaneng.com)","","","su1@samaneng.com","N","N","N","Y","","",""
"자산경영실","0","","","","t_563wl@samaneng.com","Y","N","Y","Y","","",""
"안전품질관리실","0","","","","t_793co@samaneng.com","Y","N","Y","Y","","",""
"사업개발실","0","","","","t_468yk@samaneng.com","Y","N","Y","Y","","",""
"CM본부","0","","","","t_838vr@samaneng.com","Y","N","Y","Y","","",""
"CM사업부","0","","","","t_205ud@samaneng.com","Y","N","Y","Y","","","CM본부(t_838vr@samaneng.com)"
"호남지역총괄본부","0","","","","t_143ep@samaneng.com","Y","N","Y","Y","","","CM사업부(t_205ud@samaneng.com)"
"플랜트본부","0","","","","t_009bl@samaneng.com","Y","N","Y","Y","","",""
"플랜트1부","0","","","","t_595bv@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
"플랜트2부","0","","","","t_677ei@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
"항만부","0","","","","t_446wi@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
"국토개발본부","0","","","","t_405cl@samaneng.com","Y","N","Y","Y","","",""
"도시계획부","0","","","","t_403or@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
"도시개발부","0","","","","t_733kg@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
"조경레저부","0","","","","t_931rr@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
"도로본부","0","","","","t_402qv@samaneng.com","Y","N","Y","Y","","",""
"도로부","0","","","","t_560mk@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"지반터널부","0","","","","t_918nd@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"교통계획부","0","","","","t_879qs@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"구조부","0","","","","t_772wv@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"안전진단팀","0","","","","t_875hr@samaneng.com","Y","N","Y","Y","","","구조부(t_772wv@samaneng.com)"
"철도본부","0","","","","t_772tf@samaneng.com","Y","N","Y","Y","","",""
"철도1부","0","","","","t_879yn@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
"철도2부","0","","","","t_025sm@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
"환경평가부","0","","","","t_974cd@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
"물환경본부","0","","","","t_857zu@samaneng.com","Y","N","Y","Y","","",""
"물환경1부","0","","","","t_881eq@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
"물환경2부","0","","","","t_308je@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
"물환경3부","0","","","","t_187qk@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
"수자원본부","0","","","","t_415tw@samaneng.com","Y","N","Y","Y","","",""
"수자원1부","0","","","","t_237op@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
"수자원2부","0","","","","t_989os@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
"수력부","0","","","","t_175zq@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
"해외사업본부","0","","","","t_436jd@samaneng.com","Y","N","Y","Y","","",""
"해외사업부","0","","","","t_099um@samaneng.com","Y","N","Y","Y","","","해외사업본부(t_436jd@samaneng.com)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 기술개발센터 1 tdc@samaneng.com N N N Y
3 경영전략본부 0 business-strategy@samaneng.com Y N Y Y
4 기획부 1 변역근(ykbyun@samaneng.com) planning@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
5 업무팀 0 t_226wn@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
6 PQ팀 0 t_978bl@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
7 재무회계팀 0 t_186qz@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
8 대외협력팀 0 t_466et@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
9 인사총무부 0 t_784bn@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
10 네이버웍스관리용 1 슈퍼관리자(su-@samaneng.com) su1@samaneng.com N N N Y
11 자산경영실 0 t_563wl@samaneng.com Y N Y Y
12 안전품질관리실 0 t_793co@samaneng.com Y N Y Y
13 사업개발실 0 t_468yk@samaneng.com Y N Y Y
14 CM본부 0 t_838vr@samaneng.com Y N Y Y
15 CM사업부 0 t_205ud@samaneng.com Y N Y Y CM본부(t_838vr@samaneng.com)
16 호남지역총괄본부 0 t_143ep@samaneng.com Y N Y Y CM사업부(t_205ud@samaneng.com)
17 플랜트본부 0 t_009bl@samaneng.com Y N Y Y
18 플랜트1부 0 t_595bv@samaneng.com Y N Y Y 플랜트본부(t_009bl@samaneng.com)
19 플랜트2부 0 t_677ei@samaneng.com Y N Y Y 플랜트본부(t_009bl@samaneng.com)
20 항만부 0 t_446wi@samaneng.com Y N Y Y 플랜트본부(t_009bl@samaneng.com)
21 국토개발본부 0 t_405cl@samaneng.com Y N Y Y
22 도시계획부 0 t_403or@samaneng.com Y N Y Y 국토개발본부(t_405cl@samaneng.com)
23 도시개발부 0 t_733kg@samaneng.com Y N Y Y 국토개발본부(t_405cl@samaneng.com)
24 조경레저부 0 t_931rr@samaneng.com Y N Y Y 국토개발본부(t_405cl@samaneng.com)
25 도로본부 0 t_402qv@samaneng.com Y N Y Y
26 도로부 0 t_560mk@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
27 지반터널부 0 t_918nd@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
28 교통계획부 0 t_879qs@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
29 구조부 0 t_772wv@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
30 안전진단팀 0 t_875hr@samaneng.com Y N Y Y 구조부(t_772wv@samaneng.com)
31 철도본부 0 t_772tf@samaneng.com Y N Y Y
32 철도1부 0 t_879yn@samaneng.com Y N Y Y 철도본부(t_772tf@samaneng.com)
33 철도2부 0 t_025sm@samaneng.com Y N Y Y 철도본부(t_772tf@samaneng.com)
34 환경평가부 0 t_974cd@samaneng.com Y N Y Y 철도본부(t_772tf@samaneng.com)
35 물환경본부 0 t_857zu@samaneng.com Y N Y Y
36 물환경1부 0 t_881eq@samaneng.com Y N Y Y 물환경본부(t_857zu@samaneng.com)
37 물환경2부 0 t_308je@samaneng.com Y N Y Y 물환경본부(t_857zu@samaneng.com)
38 물환경3부 0 t_187qk@samaneng.com Y N Y Y 물환경본부(t_857zu@samaneng.com)
39 수자원본부 0 t_415tw@samaneng.com Y N Y Y
40 수자원1부 0 t_237op@samaneng.com Y N Y Y 수자원본부(t_415tw@samaneng.com)
41 수자원2부 0 t_989os@samaneng.com Y N Y Y 수자원본부(t_415tw@samaneng.com)
42 수력부 0 t_175zq@samaneng.com Y N Y Y 수자원본부(t_415tw@samaneng.com)
43 해외사업본부 0 t_436jd@samaneng.com Y N Y Y
44 해외사업부 0 t_099um@samaneng.com Y N Y Y 해외사업본부(t_436jd@samaneng.com)

View File

@@ -1,44 +0,0 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"기술개발센터","1","","","","tech-dev-center@samaneng.com","N","N","N","Y","","",""
"경영전략본부","0","","","","business-strategy@samaneng.com","Y","N","Y","Y","","",""
"기획부","1","","","","planning@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"업무팀","0","","","","operations@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"PQ팀","0","","","","pq-team@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"재무회계팀","0","","","","finance@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"대외협력팀","0","","","","external-relations@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"인사총무부","0","","","","hr-admin@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"네이버웍스관리용","1","","","","nw-admin-saman@samaneng.com","N","N","N","Y","","",""
"자산경영실","0","","","","asset-management@samaneng.com","Y","N","Y","Y","","",""
"안전품질관리실","0","","","","safety-quality@samaneng.com","Y","N","Y","Y","","",""
"사업개발실","0","","","","business-development@samaneng.com","Y","N","Y","Y","","",""
"CM본부","0","","","","cm-headquarters@samaneng.com","Y","N","Y","Y","","",""
"CM사업부","0","","","","cm-division@samaneng.com","Y","N","Y","Y","","","CM본부(cm-headquarters@samaneng.com)"
"호남지역총괄본부","0","","","","honam-headquarters@samaneng.com","Y","N","Y","Y","","","CM사업부(cm-division@samaneng.com)"
"플랜트본부","0","","","","plant-headquarters@samaneng.com","Y","N","Y","Y","","",""
"플랜트1부","0","","","","plant-1@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
"플랜트2부","0","","","","plant-2@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
"항만부","0","","","","harbor@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
"국토개발본부","0","","","","land-development@samaneng.com","Y","N","Y","Y","","",""
"도시계획부","0","","","","urban-planning@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
"도시개발부","0","","","","urban-development@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
"조경레저부","0","","","","landscape-leisure@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
"도로본부","0","","","","road-headquarters@samaneng.com","Y","N","Y","Y","","",""
"도로부","0","","","","road@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"지반터널부","0","","","","geotech-tunnel@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"교통계획부","0","","","","transport-planning@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"구조부","0","","","","structures@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"안전진단팀","0","","","","safety-inspection@samaneng.com","Y","N","Y","Y","","","구조부(structures@samaneng.com)"
"철도본부","0","","","","railway-headquarters@samaneng.com","Y","N","Y","Y","","",""
"철도1부","0","","","","railway-1@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
"철도2부","0","","","","railway-2@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
"환경평가부","0","","","","environment-assessment@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
"물환경본부","0","","","","water-environment-hq@samaneng.com","Y","N","Y","Y","","",""
"물환경1부","0","","","","water-environment-1@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
"물환경2부","0","","","","water-environment-2@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
"물환경3부","0","","","","water-environment-3@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
"수자원본부","0","","","","water-resources-hq@samaneng.com","Y","N","Y","Y","","",""
"수자원1부","0","","","","water-resources-1@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
"수자원2부","0","","","","water-resources-2@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
"수력부","0","","","","hydropower@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
"해외사업본부","0","","","","overseas-headquarters@samaneng.com","Y","N","Y","Y","","",""
"해외사업부","0","","","","overseas-business@samaneng.com","Y","N","Y","Y","","","해외사업본부(overseas-headquarters@samaneng.com)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 기술개발센터 1 tech-dev-center@samaneng.com N N N Y
3 경영전략본부 0 business-strategy@samaneng.com Y N Y Y
4 기획부 1 planning@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
5 업무팀 0 operations@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
6 PQ팀 0 pq-team@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
7 재무회계팀 0 finance@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
8 대외협력팀 0 external-relations@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
9 인사총무부 0 hr-admin@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
10 네이버웍스관리용 1 nw-admin-saman@samaneng.com N N N Y
11 자산경영실 0 asset-management@samaneng.com Y N Y Y
12 안전품질관리실 0 safety-quality@samaneng.com Y N Y Y
13 사업개발실 0 business-development@samaneng.com Y N Y Y
14 CM본부 0 cm-headquarters@samaneng.com Y N Y Y
15 CM사업부 0 cm-division@samaneng.com Y N Y Y CM본부(cm-headquarters@samaneng.com)
16 호남지역총괄본부 0 honam-headquarters@samaneng.com Y N Y Y CM사업부(cm-division@samaneng.com)
17 플랜트본부 0 plant-headquarters@samaneng.com Y N Y Y
18 플랜트1부 0 plant-1@samaneng.com Y N Y Y 플랜트본부(plant-headquarters@samaneng.com)
19 플랜트2부 0 plant-2@samaneng.com Y N Y Y 플랜트본부(plant-headquarters@samaneng.com)
20 항만부 0 harbor@samaneng.com Y N Y Y 플랜트본부(plant-headquarters@samaneng.com)
21 국토개발본부 0 land-development@samaneng.com Y N Y Y
22 도시계획부 0 urban-planning@samaneng.com Y N Y Y 국토개발본부(land-development@samaneng.com)
23 도시개발부 0 urban-development@samaneng.com Y N Y Y 국토개발본부(land-development@samaneng.com)
24 조경레저부 0 landscape-leisure@samaneng.com Y N Y Y 국토개발본부(land-development@samaneng.com)
25 도로본부 0 road-headquarters@samaneng.com Y N Y Y
26 도로부 0 road@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
27 지반터널부 0 geotech-tunnel@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
28 교통계획부 0 transport-planning@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
29 구조부 0 structures@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
30 안전진단팀 0 safety-inspection@samaneng.com Y N Y Y 구조부(structures@samaneng.com)
31 철도본부 0 railway-headquarters@samaneng.com Y N Y Y
32 철도1부 0 railway-1@samaneng.com Y N Y Y 철도본부(railway-headquarters@samaneng.com)
33 철도2부 0 railway-2@samaneng.com Y N Y Y 철도본부(railway-headquarters@samaneng.com)
34 환경평가부 0 environment-assessment@samaneng.com Y N Y Y 철도본부(railway-headquarters@samaneng.com)
35 물환경본부 0 water-environment-hq@samaneng.com Y N Y Y
36 물환경1부 0 water-environment-1@samaneng.com Y N Y Y 물환경본부(water-environment-hq@samaneng.com)
37 물환경2부 0 water-environment-2@samaneng.com Y N Y Y 물환경본부(water-environment-hq@samaneng.com)
38 물환경3부 0 water-environment-3@samaneng.com Y N Y Y 물환경본부(water-environment-hq@samaneng.com)
39 수자원본부 0 water-resources-hq@samaneng.com Y N Y Y
40 수자원1부 0 water-resources-1@samaneng.com Y N Y Y 수자원본부(water-resources-hq@samaneng.com)
41 수자원2부 0 water-resources-2@samaneng.com Y N Y Y 수자원본부(water-resources-hq@samaneng.com)
42 수력부 0 hydropower@samaneng.com Y N Y Y 수자원본부(water-resources-hq@samaneng.com)
43 해외사업본부 0 overseas-headquarters@samaneng.com Y N Y Y
44 해외사업부 0 overseas-business@samaneng.com Y N Y Y 해외사업본부(overseas-headquarters@samaneng.com)

View File

@@ -3,19 +3,6 @@ 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"
@@ -25,39 +12,11 @@ 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() {
if [ ! -f package.json ] || [ ! -f package-lock.json ]; then
return 0
fi
if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum package.json package-lock.json | sha256sum | awk '{print $1}')"
else
deps_hash="$(cksum package.json package-lock.json | cksum | awk '{print $1}')"
fi
deps_stamp="node_modules/.baron-deps-hash"
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
if [ "$installed_hash" != "$deps_hash" ]; then
echo "Installing frontend dependencies from package-lock.json..."
npm ci
mkdir -p node_modules
printf '%s\n' "$deps_hash" > "$deps_stamp"
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"

View File

@@ -1,11 +0,0 @@
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 id name type parent_tenant_slug slug memo email_domain
2 038326b6-954a-48a7-a85f-efd83f62b82a 한맥가족 COMPANY_GROUP hanmac-family 한맥가족 기본 루트 테넌트
3 9caf62e1-297d-4e8f-870b-61780998bbeb 삼안 COMPANY hanmac-family saman 네이버웍스 삼안 SAMAN_DOMAIN_ID samaneng.com
4 369c1843-56af-4344-9c21-0e01197ab861 한맥기술 COMPANY hanmac-family hanmac 네이버웍스 한맥 HANMAC_DOMAIN_ID hanmaceng.co.kr
5 5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee 총괄기획&기술개발센터 COMPANY hanmac-family gpdtdc 네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID baroncs.co.kr
6 96369f12-6b66-4b2a-a916-d1c99d326f02 바론그룹 COMPANY_GROUP hanmac-family baron-group 네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID
7 c18a8284-0008-48aa-9cdf-9f47ab79a2a9 (주)장헌 COMPANY baron-group jangheon jangheon.com
8 b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 장헌산업 COMPANY baron-group jangheon-sanup jangheon.co.kr
9 5a03efd2-e62f-4243-800d-58334bf48b2f 한라산업개발 COMPANY baron-group hanlla hanllasanup.co.kr
10 e57cb22c-383e-4489-8c2f-0c5431917e86 (주)피티씨 COMPANY baron-group ptc pre-cast.co.kr
11 9607eb7b-04d2-42ab-80fe-780fe21c7e8f Personal PERSONAL personal 개인 사용자 기본 루트 테넌트

View File

@@ -1,24 +0,0 @@
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");
});
});

View File

@@ -1,5 +1,4 @@
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";
@@ -8,65 +7,58 @@ import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
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: <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 /> },
],
},
];
export const router = createBrowserRouter(
adminRoutes,
[
{
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 /> },
],
},
],
// React Router v7 플래그는 Provider에서 적용합니다.
);

View File

@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
import {
Building2,
ChevronDown,
Database,
Key,
KeyRound,
LayoutDashboard,
@@ -19,7 +18,6 @@ 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 { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
import { fetchMe } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import {
@@ -116,9 +114,6 @@ function AppLayout() {
const isSuperAdmin = isTest || effectiveRole === "super_admin";
const isTenantAdmin = effectiveRole === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0;
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
);
const filteredItems = items.filter((item) => {
if (isTest) return true;
@@ -134,15 +129,10 @@ function AppLayout() {
});
filteredItems.splice(2, 0, {
label: "ui.admin.nav.org_chart",
to: orgfrontUrl,
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
icon: Network,
isExternal: true,
});
filteredItems.splice(4, 0, {
label: "ui.admin.nav.user_projection",
to: "/system/projections/users",
icon: Database,
});
} else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) {
filteredItems.splice(1, 0, {
@@ -162,7 +152,7 @@ function AppLayout() {
0,
{
label: "ui.admin.nav.org_chart",
to: orgfrontUrl,
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
icon: Network,
isExternal: true,
},
@@ -171,7 +161,7 @@ function AppLayout() {
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
filteredItems.splice(1, 0, {
label: "ui.admin.nav.org_chart",
to: orgfrontUrl,
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
icon: Network,
isExternal: true,
});
@@ -540,7 +530,7 @@ function AppLayout() {
</nav>
</aside>
<div className="relative min-w-0">
<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">
@@ -736,7 +726,7 @@ function AppLayout() {
</div>
</div>
</header>
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10">
<main className="px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
<RoleSwitcher />

View File

@@ -1,25 +1,109 @@
import { KeyRound } from "lucide-react";
import PermissionChecker from "./components/PermissionChecker";
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",
},
];
function AuthPage() {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Admin auth
</p>
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<KeyRound size={22} className="text-primary" />
</h2>
<p className="text-sm text-muted-foreground">
ReBAC .
<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>
<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>
<PermissionChecker />
<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>
</div>
);
}

View File

@@ -1,186 +0,0 @@
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 } from "../../lib/adminApi";
import AuthPage from "../auth/AuthPage";
import GlobalOverviewPage from "./GlobalOverviewPage";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
fetchAdminOverviewStats: vi.fn(async () => ({
totalTenants: 10,
oidcClients: 3,
auditEvents24h: 18,
})),
fetchTenants: vi.fn(async () => ({
items: [
{
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: 3,
})),
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,
},
],
})),
}));
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(() => {
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 summary metrics from the admin stats API", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
(await screen.findByText("전체 테넌트 수")).parentElement,
).toHaveTextContent("10");
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
"3",
);
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
"18",
);
});
it("changes the RP usage perspective and targets a permitted organization", async () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
fireEvent.click(screen.getByRole("button", { name: "주" }));
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "월" }));
fireEvent.change(screen.getByLabelText("조직 검색"), {
target: { value: "개발" },
});
fireEvent.change(screen.getByLabelText("대상 조직"), {
target: { value: "org-1" },
});
await waitFor(() => {
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
days: 90,
period: "month",
tenantId: "org-1",
});
});
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
});
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();
});
});

View File

@@ -1,433 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
BarChart3,
ArrowUpRight,
Database,
Key,
PlusCircle,
ShieldCheck,
Users,
} from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { RoleGuard } from "../../components/auth/RoleGuard";
import {
type RPUsageDailyMetric,
type RPUsagePeriod,
type TenantSummary,
fetchAdminOverviewStats,
fetchAdminRPUsageDaily,
fetchTenants,
} from "../../lib/adminApi";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { t } from "../../lib/i18n";
type DailyPoint = {
date: string;
loginRequests: number;
otherRequests: number;
};
type SeriesSummary = {
key: string;
tenantLabel: string;
clientLabel: string;
loginRequests: number;
otherRequests: 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.tenantId}:${row.clientId}`;
const current =
bySeries.get(key) ??
({
key,
tenantLabel: row.tenantName || row.tenantId || "-",
clientLabel: row.clientName || row.clientId,
loginRequests: 0,
otherRequests: 0,
uniqueSubjects: 0,
} satisfies SeriesSummary);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
current.uniqueSubjects = Math.max(
current.uniqueSubjects,
row.uniqueSubjects,
);
bySeries.set(key, current);
}
return Array.from(bySeries.values())
.sort(
(a, b) =>
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
)
.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 OverviewMetric({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
<span className="text-muted-foreground">{icon}</span>
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums">{value}</span>
</span>
);
}
function RPUsageMixedChart({
rows,
filters,
period,
}: {
rows: RPUsageDailyMetric[];
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-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 size={18} className="text-primary" />
<h3 className="text-base font-semibold">
/
</h3>
</div>
{filters}
</div>
{daily.length === 0 ? (
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
RP .
</div>
) : (
<div className="overflow-x-auto">
<svg
role="img"
aria-label="일 단위 RP 요청 현황"
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
className="h-[235px] min-w-[720px] w-full"
>
<title> RP </title>
<g transform="translate(510 10)">
<rect
x="0"
y="3"
width="10"
height="10"
rx="2"
className="fill-sky-500/70"
/>
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
</text>
<line
x1="78"
x2="98"
y1="8"
y2="8"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
/>
<text
x="104"
y="12"
className="fill-muted-foreground text-[11px]"
>
</text>
</g>
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const gridY = padTop + innerHeight * ratio;
const label = Math.round(maxValue * (1 - ratio));
return (
<g key={ratio}>
<line
x1={padX}
x2={chartWidth - padX}
y1={gridY}
y2={gridY}
stroke="currentColor"
className="text-border"
strokeWidth="1"
/>
<text
x={padX - 12}
y={gridY + 4}
textAnchor="end"
className="fill-muted-foreground text-[11px]"
>
{label}
</text>
</g>
);
})}
{daily.map((point, index) => {
const center = x(index);
const otherHeight =
(point.otherRequests / maxValue) * innerHeight;
return (
<g key={point.date}>
<rect
x={center - barWidth / 2}
y={padTop + innerHeight - otherHeight}
width={barWidth}
height={otherHeight}
rx="3"
className="fill-sky-500/70"
/>
<text
x={center}
y={chartHeight - 12}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
{formatPeriodLabel(point.date, period)}
</text>
</g>
);
})}
<polyline
points={linePoints}
fill="none"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{daily.map((point, index) => (
<circle
key={`${point.date}-login`}
cx={x(index)}
cy={y(point.loginRequests)}
r="4"
className="fill-emerald-500 stroke-background"
strokeWidth="2"
/>
))}
</svg>
</div>
)}
{series.length > 0 && (
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
{series.map((item) => (
<div key={item.key} className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{item.clientLabel}</span>
<span className="truncate text-muted-foreground">
{item.tenantLabel}
</span>
<span className="ml-auto whitespace-nowrap tabular-nums">
{item.loginRequests.toLocaleString()} / {" "}
{item.otherRequests.toLocaleString()} / {" "}
{item.uniqueSubjects.toLocaleString()}
</span>
</div>
))}
</div>
)}
</section>
);
}
import PermissionChecker from "./components/PermissionChecker";
function GlobalOverviewPage() {
const [period, setPeriod] = useState<RPUsagePeriod>("day");
const [tenantSearch, setTenantSearch] = useState("");
const [selectedTenantId, setSelectedTenantId] = useState("");
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: () => fetchTenants(1000, 0),
retry: false,
});
const tenantOptions = useMemo(() => {
const term = tenantSearch.trim().toLowerCase();
return (tenantsQuery.data?.items ?? [])
.filter(
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
)
.filter((tenant) => {
if (!term) return true;
return (
tenant.name.toLowerCase().includes(term) ||
tenant.slug.toLowerCase().includes(term) ||
tenant.id.toLowerCase().includes(term)
);
});
}, [tenantSearch, tenantsQuery.data?.items]);
const usageQuery = useQuery({
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
queryFn: () =>
fetchAdminRPUsageDaily({
days: usageDays,
period,
tenantId: selectedTenantId || undefined,
}),
retry: false,
});
const stats = statsQuery.data;
const usageRows = usageQuery.data?.items ?? [];
const metric = (value: number | undefined) =>
value === undefined ? "-" : value.toLocaleString();
const chartFilters = (
<div className="flex flex-wrap items-center gap-2">
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
{[
["day", "일"],
["week", "주"],
["month", "월"],
].map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={period === value}
onClick={() => setPeriod(value as RPUsagePeriod)}
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
period === value
? "bg-primary text-primary-foreground"
: "bg-muted/60 hover:bg-muted"
}`}
>
{label}
</button>
))}
</div>
<input
aria-label="조직 검색"
value={tenantSearch}
onChange={(event) => setTenantSearch(event.target.value)}
placeholder="조직 검색"
className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44"
/>
<select
aria-label="대상 조직"
value={selectedTenantId}
onChange={(event) => setSelectedTenantId(event.target.value)}
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-52"
>
<option value=""> </option>
{tenantOptions.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name} ({tenant.slug})
</option>
))}
</select>
</div>
);
return (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="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-2xl font-semibold tracking-tight">
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.overview.title", "Dashboard")}
</h2>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground">
{t(
"msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
@@ -436,61 +36,166 @@ function GlobalOverviewPage() {
</div>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<RoleGuard roles={["super_admin"]}>
<OverviewMetric
icon={<Users size={14} />}
label={t(
"ui.admin.overview.summary.total_tenants",
"전체 테넌트 수",
)}
value={metric(stats?.totalTenants)}
/>
<OverviewMetric
icon={<ShieldCheck size={14} />}
label={t(
"ui.admin.overview.summary.oidc_clients",
"OIDC 클라이언트",
)}
value={metric(stats?.oidcClients)}
/>
<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>
<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"
/>
<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.audit_events_24h",
"최근 감사 로그 (24h)",
)}
</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>
{usageQuery.isError ? (
<section className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold">
/
</h3>
{chartFilters}
</div>
<div className="text-sm text-muted-foreground">
RP Query API . backend
`rp_usage_daily_aggregate`
.
</div>
</section>
) : (
<RPUsageMixedChart
rows={usageRows}
filters={chartFilters}
period={period}
/>
)}
<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>
<RoleGuard roles={["super_admin"]}>
<div className="pt-4">
<PermissionChecker />
</div>
</RoleGuard>
</div>
);
}

View File

@@ -44,7 +44,7 @@ function PermissionChecker() {
const result = checkMutation.data;
return (
<Card className="border-primary/20 bg-[var(--color-panel)]">
<Card className="bg-[var(--color-panel)] border-primary/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldAlert size={20} className="text-primary" />
@@ -100,7 +100,7 @@ function PermissionChecker() {
<Button
onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending}
className="w-full px-12 md:w-auto"
className="w-full md:w-auto px-12"
>
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
</Button>
@@ -108,17 +108,17 @@ function PermissionChecker() {
{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 ${
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 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"
? "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-center text-sm opacity-80">
<p className="text-sm opacity-80 text-center">
. (
)
</p>
@@ -127,7 +127,7 @@ function PermissionChecker() {
<>
<XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div>
<p className="text-center text-sm opacity-80">
<p className="text-sm opacity-80 text-center">
.
</p>
</>

View File

@@ -1,99 +0,0 @@
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 UserProjectionPage from "./UserProjectionPage";
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);
});
it("renders projection status for super_admin", async () => {
renderPage();
expect(
await screen.findByText("사용자 Projection 관리"),
).toBeInTheDocument();
expect(
await screen.findByText("Kratos users projection"),
).toBeInTheDocument();
expect(screen.getByText("ready")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
expect(fetchUserProjectionStatus).toHaveBeenCalled();
});
it("runs reconcile and reset actions for super_admin", async () => {
renderPage();
await screen.findByText("사용자 Projection 관리");
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("사용자 Projection 관리"),
).not.toBeInTheDocument();
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
});
});

View File

@@ -1,206 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Database, RefreshCw, RotateCcw } 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";
function formatDateTime(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: "medium",
}).format(date);
}
function ProjectionStatusBadge({
ready,
status,
}: {
ready: boolean;
status: string;
}) {
if (ready) {
return <Badge variant="success">ready</Badge>;
}
if (status === "failed") {
return <Badge variant="warning">failed</Badge>;
}
return <Badge variant="secondary">{status || "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(
"사용자 projection을 Kratos 기준으로 다시 구축하시겠습니까?",
);
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 p-6 md:p-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">System</p>
<h2 className="text-2xl font-semibold tracking-tight">
Projection
</h2>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => reconcileMutation.mutate()}
disabled={isWorking}
>
<RefreshCw size={16} />
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isWorking}
>
<RotateCcw size={16} />
</Button>
</div>
</div>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
"projection 상태를 불러오지 못했습니다."}
</section>
) : null}
{actionResult ? (
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{actionResult.syncedUsers} projection을 .
</section>
) : null}
{actionError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(actionError as Error)?.message || "projection 작업에 실패했습니다."}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 border-b border-border pb-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
<Database size={18} />
</div>
<div>
<h3 className="text-base font-semibold">Kratos users projection</h3>
<p className="text-sm text-muted-foreground">
Backend DB read model .
</p>
</div>
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground"> </div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground"></dt>
<dd className="mt-1">
<ProjectionStatusBadge
ready={data?.ready ?? false}
status={data?.status ?? "unknown"}
/>
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
Projection
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.projectedUsers ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.lastSyncedAt)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </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"> </h2>
<p className="mt-2 text-sm text-muted-foreground">
super_admin .
</p>
</section>
</main>
}
>
<UserProjectionContent />
</RoleGuard>
);
}

View File

@@ -1,53 +0,0 @@
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"]);
});
});

View File

@@ -1,187 +0,0 @@
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>
</>
);
}

View File

@@ -0,0 +1,295 @@
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>
);
}

View File

@@ -1,137 +0,0 @@
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");
});
});

View File

@@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import { filterParentTenants } 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: "",
},
{
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",
]);
});
});

View File

@@ -1,240 +0,0 @@
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,
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";
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;
};
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
export function filterParentTenants(
tenants: TenantSummary[],
search: string,
companyOnly: boolean,
excludeTenantId = "",
) {
const normalizedSearch = search.trim().toLowerCase();
return tenants.filter((tenant) => {
if (excludeTenantId && tenant.id === excludeTenantId) return false;
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
if (!normalizedSearch) return true;
return [tenant.name, tenant.slug, tenant.type]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedSearch));
});
}
export function ParentTenantSelector({
id,
label,
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">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
>
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
selectedTenant?.name ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</Button>
{localPickerLabel && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setLocalPickerOpen(true)}
>
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>
)}
{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>
)}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.picker_description",
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
)}
</DialogDescription>
</DialogHeader>
<iframe
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
/>
</DialogContent>
</Dialog>
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{localPickerLabel ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.local_picker_description",
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={localSearch}
onChange={(event) => setLocalSearch(event.target.value)}
placeholder={t(
"ui.admin.tenants.parent.local_search_placeholder",
"테넌트 이름 또는 슬러그 검색",
)}
/>
<div className="max-h-[360px] space-y-2 overflow-y-auto">
{localCandidates.map((tenant) => (
<Button
key={tenant.id}
type="button"
variant="outline"
className="h-auto w-full justify-start px-3 py-2 text-left"
onClick={() => {
onChange(tenant.id);
setLocalPickerOpen(false);
setLocalSearch("");
}}
>
<span>
<span className="block text-sm font-medium">
{tenant.name}
</span>
<span className="block text-xs text-muted-foreground">
{tenant.slug} · {tenant.type}
</span>
</span>
</Button>
))}
{localCandidates.length === 0 && (
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.parent.local_picker_empty",
"선택할 수 있는 테넌트가 없습니다.",
)}
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -16,19 +17,6 @@ import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { createTenant, fetchTenants } 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";
function TenantCreatePage() {
const navigate = useNavigate();
@@ -36,51 +24,17 @@ function TenantCreatePage() {
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [parentId, setParentId] = useState("");
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<string[]>([]);
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
[],
);
const [domains, setDomains] = useState("");
const parentQuery = useQuery({
queryKey: ["tenants", { limit: 1000 }],
queryFn: () => fetchTenants(1000, 0),
});
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 = (nextParentId: string) => {
setParentId(nextParentId);
setParentStepConfirmed(false);
};
const mutation = useMutation({
mutationFn: (overrideForceDomains?: string[]) =>
mutationFn: () =>
createTenant({
name,
type,
@@ -88,37 +42,14 @@ function TenantCreatePage() {
parentId: parentId || undefined,
description: description || undefined,
status,
domains,
config: canConfigureHanmacOrg
? mergeTenantOrgConfig(undefined, { orgUnitType, visibility })
: undefined,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
domains: domains
.split(",")
.map((d) => d.trim())
.filter((d) => d !== ""),
}),
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
@@ -156,263 +87,152 @@ function TenantCreatePage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<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"
}
>
<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
}
/>
</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 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"
>
{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
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>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
@@ -448,7 +268,7 @@ function TenantCreatePage() {
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => mutation.mutate(undefined)}
onClick={() => mutation.mutate()}
disabled={mutation.isPending || name.trim() === ""}
>
{t("ui.common.create", "생성")}

View File

@@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { canShowWorksmobileEntry } from "./TenantDetailPage";
describe("TenantDetailPage Worksmobile entry visibility", () => {
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
expect(
canShowWorksmobileEntry({
id: "hanmac-family-id",
slug: "hanmac-family",
parentId: undefined,
}),
).toBe(true);
expect(
canShowWorksmobileEntry({
id: "hanmac-child-id",
slug: "hanmac-family",
parentId: "root-id",
}),
).toBe(false);
expect(
canShowWorksmobileEntry({
id: "other-id",
slug: "other",
parentId: undefined,
}),
).toBe(false);
});
});

View File

@@ -1,19 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { Copy } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export function canShowWorksmobileEntry(tenant?: {
id?: string;
slug?: string;
parentId?: string | null;
}) {
return tenant?.slug === "hanmac-family" && !tenant.parentId;
}
function TenantDetailPage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
@@ -32,49 +23,18 @@ function TenantDetailPage() {
const canAccessSchema =
profile?.role === "super_admin" || profile?.role === "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">
<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>
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ??
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
{t(
"ui.admin.tenants.detail.header_subtitle",
@@ -91,7 +51,6 @@ 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"
@@ -131,18 +90,6 @@ function TenantDetailPage() {
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
)}
{showWorksmobileEntry && (
<Link
to={`/tenants/${tenantId}/worksmobile`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isWorksmobileTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")}
</Link>
)}
</div>
{/* Outlet for nested routes */}

View File

@@ -1,57 +0,0 @@
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");
});
});

View File

@@ -2,16 +2,13 @@ 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,
@@ -30,18 +27,8 @@ 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,
@@ -57,11 +44,10 @@ 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[];
@@ -238,7 +224,6 @@ 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("");
@@ -247,37 +232,13 @@ 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: () =>
@@ -358,545 +319,306 @@ 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",
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
<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",
"예: 개발팀, 인사팀",
)}
</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}
/>
</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)}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
</CardContent>
</Card>
<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="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>
</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,
})}
{/* 그룹 목록 (트리 뷰) */}
<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(
"ui.admin.groups.detail.members_subtitle",
"그룹에 속한 멤버들을 확인하고 관리합니다.",
"msg.admin.groups.list.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="sticky top-0 z-10 bg-secondary shadow-sm">
</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>
<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>
<TableCell colSpan={3}>
{t("msg.admin.groups.list.loading", "로딩 중...")}
</TableCell>
</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>
)}
{!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>
</CardContent>
</Card>
)}
</div>
</CardContent>
</Card>
</div>
{/* 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>
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
{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(
"msg.admin.groups.members.add_modal_desc",
"이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.",
"ui.admin.groups.detail.members_subtitle",
"그룹에 속한 멤버들을 확인하고 관리합니다.",
)}
</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)}
/>
</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>
</div>
<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({
<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({
groupId: currentGroup.id,
userId: user.id,
});
})
}
}}
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>
)}
disabled={removeMemberMutation.isPending}
>
<UserMinus size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</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>
<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>
</>
</CardContent>
</Card>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,22 +23,6 @@ import {
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 }>();
@@ -61,100 +45,49 @@ export function TenantProfilePage() {
queryFn: () => fetchTenants(1000, 0),
});
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<string[]>([]);
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
[],
);
const [domains, setDomains] = useState("");
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 ?? []);
setForceDomainConflicts([]);
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
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: (overrideForceDomains?: string[]) => {
const baseConfig = tenantQuery.data?.config;
const config = canEditOrgConfig
? mergeTenantOrgConfig(baseConfig, {
orgUnitType,
visibility: tenantVisibility,
})
: removeTenantOrgConfig(baseConfig);
return updateTenant(tenantId, {
mutationFn: () =>
updateTenant(tenantId, {
name,
type,
slug,
description: description || undefined,
status,
parentId: parentId || undefined,
domains,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
config,
});
},
domains: domains
.split(",")
.map((d) => d.trim())
.filter((d) => d !== ""),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
toast.success(t("msg.info.saved_success", "저장되었습니다."));
},
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;
}
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
@@ -193,14 +126,8 @@ 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", "삭제하시겠습니까?", {
@@ -268,12 +195,6 @@ 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",
@@ -288,78 +209,30 @@ export function TenantProfilePage() {
</option>
</select>
</div>
<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"}
<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)}
>
<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>
</>
)}
<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>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
@@ -384,14 +257,9 @@ export function TenantProfilePage() {
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
<Input
value={domains}
onChange={setDomains}
tenants={parentQuery.data?.items ?? []}
currentTenantId={tenantId}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
onChange={(e) => setDomains(e.target.value)}
placeholder="example.com, example.kr"
/>
<p className="text-xs text-muted-foreground">
@@ -434,15 +302,7 @@ export function TenantProfilePage() {
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending || isProtectedSeedTenant}
title={
isProtectedSeedTenant
? t(
"msg.admin.tenants.seed_delete_blocked",
"초기 설정 테넌트는 삭제할 수 없습니다.",
)
: undefined
}
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
{t("ui.common.delete", "삭제")}
@@ -462,7 +322,7 @@ export function TenantProfilePage() {
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => updateMutation.mutate(undefined)}
onClick={() => updateMutation.mutate()}
disabled={
updateMutation.isPending ||
tenantQuery.isLoading ||

View File

@@ -1,35 +0,0 @@
import { describe, expect, it } from "vitest";
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage";
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);
});
});

View File

@@ -17,7 +17,7 @@ import { toast } from "../../../components/ui/use-toast";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export type SchemaFieldType =
type SchemaFieldType =
| "text"
| "number"
| "boolean"
@@ -25,7 +25,7 @@ export type SchemaFieldType =
| "float"
| "datetime";
export type SchemaField = {
type SchemaField = {
id: string;
key: string;
label: string;
@@ -35,7 +35,6 @@ export type SchemaField = {
validation?: string;
unsigned?: boolean;
isLoginId?: boolean;
indexed?: boolean;
};
function createFieldId() {
@@ -45,53 +44,6 @@ function createFieldId() {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function isSchemaFieldType(value: unknown): value is SchemaFieldType {
return (
value === "text" ||
value === "number" ||
value === "boolean" ||
value === "date" ||
value === "float" ||
value === "datetime"
);
}
export function normalizeSchemaField(field: unknown): SchemaField {
const source =
typeof field === "object" && field !== null
? (field as Record<string, unknown>)
: {};
const type = isSchemaFieldType(source.type) ? source.type : "text";
const isLoginId = Boolean(source.isLoginId);
return {
id: typeof source.id === "string" ? source.id : createFieldId(),
key: typeof source.key === "string" ? source.key : "",
label: typeof source.label === "string" ? source.label : "",
type,
required: Boolean(source.required),
adminOnly: Boolean(source.adminOnly),
validation: typeof source.validation === "string" ? source.validation : "",
unsigned: Boolean(source.unsigned),
isLoginId,
indexed: isLoginId || Boolean(source.indexed),
};
}
export function createSchemaField(): SchemaField {
return {
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
unsigned: false,
indexed: false,
};
}
export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
@@ -119,7 +71,27 @@ export function TenantSchemaPage() {
const rawSchema = tenantQuery.data?.config?.userSchema;
if (Array.isArray(rawSchema)) {
setFields(rawSchema.map(normalizeSchemaField));
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),
})),
);
}
}, [tenantQuery.data]);
@@ -186,7 +158,19 @@ export function TenantSchemaPage() {
}
const addField = () => {
setFields([...fields, createSchemaField()]);
setFields([
...fields,
{
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
unsigned: false,
},
]);
};
const removeField = (index: number) => {
@@ -277,15 +261,16 @@ export function TenantSchemaPage() {
value={field.type}
onChange={(e) => {
const nextType = e.target.value;
if (isSchemaFieldType(nextType)) {
if (
nextType === "text" ||
nextType === "number" ||
nextType === "boolean" ||
nextType === "date" ||
nextType === "float" ||
nextType === "datetime"
) {
updateField(index, {
type: nextType,
isLoginId:
nextType === "text" ? field.isLoginId : false,
indexed:
nextType === "text"
? field.indexed || field.isLoginId || false
: field.indexed,
type: nextType as SchemaFieldType,
});
}
}}
@@ -366,11 +351,7 @@ export function TenantSchemaPage() {
type="checkbox"
checked={field.isLoginId || false}
onChange={(e) =>
updateField(index, {
isLoginId: e.target.checked,
indexed: e.target.checked ? true : field.indexed,
type: e.target.checked ? "text" : field.type,
})
updateField(index, { isLoginId: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
@@ -381,23 +362,6 @@ 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

View File

@@ -1,29 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Loader2,
Mail,
MoreHorizontal,
Plus,
User,
UserMinus,
UserPlus,
} from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Mail, User } from "lucide-react";
import { useParams } from "react-router-dom";
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,
@@ -32,14 +16,12 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi";
import { fetchTenant, fetchUsers } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantUsersPage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const queryClient = useQueryClient();
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
const tenantQuery = useQuery({
@@ -57,67 +39,17 @@ 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 flex flex-row items-center justify-between">
<CardHeader className="flex-shrink-0">
<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">
@@ -137,30 +69,13 @@ function TenantUsersPage() {
<TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead>
<TableHead className="w-[80px] text-right">
{t("ui.admin.tenants.members.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-20">
<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 ? (
{users.length === 0 && (
<TableRow>
<TableCell
colSpan={5}
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
{t(
@@ -169,75 +84,33 @@ function TenantUsersPage() {
)}
</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>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreHorizontal size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={`/users/${user.id}`}>
<User size={14} className="mr-2" />
{t(
"ui.admin.tenants.members.view_profile",
"상세 정보",
)}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() =>
handleRemoveMember(user.id, user.name)
}
disabled={removeTenantMutation.isPending}
>
<UserMinus size={14} className="mr-2" />
{t(
"ui.admin.tenants.members.remove",
"조직에서 제외",
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
{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>

View File

@@ -1,347 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildWorksmobilePasswordManageUrl,
canCreateWorksmobileRow,
canOpenWorksmobilePasswordManage,
canSelectWorksmobileRow,
filterWorksmobileComparisonRows,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
getDefaultWorksmobileComparisonColumns,
getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
userFilterOptions,
} from "./TenantWorksmobilePage";
describe("TenantWorksmobilePage comparison helpers", () => {
it("summarizes comparison rows by status", () => {
const summary = summarizeWorksmobileComparison([
{ resourceType: "USER", status: "matched" },
{ 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: 5,
matched: 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("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("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, [])).toEqual(rows);
expect(
filterWorksmobileComparisonRows(rows, [
"baron_only",
"works_only",
"matched",
]),
).toEqual(rows);
});
it("orders user comparison filter options from Baron-only first", () => {
expect(userFilterOptions.map((option) => option.value)).toEqual([
"baron_only",
"works_only",
"matched",
]);
});
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);
});
});

View File

@@ -1,67 +0,0 @@
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 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
);
});
});

View File

@@ -1,62 +0,0 @@
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} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
}

View File

@@ -1,77 +0,0 @@
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(
"임원직속",
);
});
});

View File

@@ -1,96 +0,0 @@
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;
}

View File

@@ -1,12 +0,0 @@
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);
});
});

View File

@@ -1,19 +0,0 @@
// 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);
}

View File

@@ -1,357 +0,0 @@
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\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
);
expect(rows).toEqual([
{
rowNumber: 2,
tenantId: "",
name: "Hanmac Tech",
type: "COMPANY",
parentTenantId: "",
parentTenantSlug: "",
slug: "hanmac-tech",
memo: "Memo",
emailDomain: "hanmac-tech.example.com",
},
]);
});
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\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: "tenant-1",
});
expect(csv).toContain(
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com",
);
});
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",
);
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,,");
});
});

View File

@@ -1,592 +0,0 @@
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;
};
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",
];
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",
};
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"),
};
});
}
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,
]);
}
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];
}

View File

@@ -87,7 +87,6 @@ const getTenantIcon = (type?: string) => {
return Briefcase;
case "PERSONAL":
return UserCircle;
case "ORGANIZATION":
case "USER_GROUP":
return Network;
default:
@@ -195,9 +194,7 @@ const SidebarNode: React.FC<{
const MemberTable: React.FC<{
tenantSlug: string;
onRefreshTrigger?: number;
allTenants?: TenantSummary[];
}> = ({ tenantSlug, onRefreshTrigger, allTenants }) => {
const queryClient = useQueryClient();
}> = ({ tenantSlug, onRefreshTrigger }) => {
const { data, isLoading, refetch } = useQuery({
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
@@ -206,55 +203,6 @@ 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">
@@ -315,28 +263,6 @@ 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>
@@ -344,65 +270,6 @@ 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>
);
};
@@ -706,7 +573,6 @@ function TenantUserGroupsTab() {
<MemberTable
tenantSlug={selectedNode.slug}
onRefreshTrigger={refreshMembersCount}
allTenants={allTenantsData?.items ?? []}
/>
</div>
</div>
@@ -835,7 +701,6 @@ 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();

View File

@@ -1,17 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowLeft,
Building2,
ClipboardCopy,
Loader2,
Plus,
Save,
Trash2,
} from "lucide-react";
import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -20,93 +12,32 @@ 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,
createTenant,
createUser,
fetchMe,
fetchTenant,
fetchTenants,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import {
type OrgChartTenantSelection,
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
type UserType = "hanmac" | "external" | "personal";
type PickerTarget = { kind: "appointment"; index: number };
type AppointmentDraft = UserAppointment & {
draftId: string;
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean" | "date";
required?: boolean;
adminOnly?: boolean;
validation?: string;
isLoginId?: boolean;
};
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: "",
};
}
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
function UserCreatePage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = React.useState<
@@ -114,15 +45,6 @@ function UserCreatePage() {
>(null);
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
const [autoPassword, setAutoPassword] = React.useState(true);
const [isHanmacFamily, setIsHanmacFamily] = React.useState(true);
const [userType, setUserType] = React.useState<UserType>("hanmac");
const [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 }],
@@ -147,9 +69,9 @@ function UserCreatePage() {
password: "",
name: "",
phone: "",
tenantSlug: searchParams.get("tenantSlug") || "",
role: "user",
tenantSlug: "",
department: "",
grade: "",
position: "",
jobTitle: "",
metadata: {},
@@ -163,33 +85,8 @@ 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 personalTenant = React.useMemo(
() =>
tenants.find(
(tenant) =>
tenant.slug === "personal" ||
(tenant.type === "PERSONAL" &&
tenant.name.toLowerCase() === "personal"),
),
[tenants],
);
const selectedTenant =
userType !== "external"
? undefined
: nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug);
const selectedTenant = tenants.find((t) => t.slug === selectedTenantSlug);
const selectedTenantId = selectedTenant?.id ?? "";
@@ -228,109 +125,6 @@ function UserCreatePage() {
: undefined,
});
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
{
tenantId: userType === "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]);
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 handleUserTypeChange = (value: string) => {
const nextType = value as UserType;
setUserType(nextType);
setIsHanmacFamily(nextType === "hanmac");
if (nextType !== "hanmac") {
setAdditionalAppointments([]);
}
};
const ensurePersonalTenant = async () => {
if (personalTenant) return personalTenant;
const tenant = await createTenant({
name: "Personal",
slug: "personal",
type: "PERSONAL",
status: "active",
});
queryClient.invalidateQueries({ queryKey: ["tenants"] });
return tenant;
};
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (data: UserCreateResponse) => {
@@ -350,99 +144,12 @@ function UserCreatePage() {
},
});
const onSubmit = async (data: UserFormValues) => {
const onSubmit = (data: UserFormValues) => {
setError(null);
setGeneratedPassword(null);
setCreatedEmail(null);
const metadata: Record<string, unknown> = {
...(data.metadata ?? {}),
hanmacFamily: userType === "hanmac" && isHanmacFamily,
userType,
};
const payload: UserCreateRequest = {
email: data.email,
password: data.password,
name: data.name,
phone: data.phone,
metadata,
};
if (userType === "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 (userType === "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 (userType === "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,
};
}
const payload = { ...data };
if (autoPassword) {
payload.password = "";
@@ -644,227 +351,82 @@ function UserCreatePage() {
</div>
</div>
<Tabs value={userType} onValueChange={handleUserTypeChange}>
<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>
<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>
<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 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>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<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>
<div className="space-y-2">
<Label htmlFor="department">
{t("ui.admin.users.create.form.department", "부서")}
</Label>
<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>
<Input
id="department"
placeholder={t(
"ui.admin.users.create.form.department_placeholder",
"개발팀",
)}
{...register("department")}
/>
</div>
</div>
{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}
>
<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>
<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>
<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>
<Input
id="position"
placeholder={t(
"ui.admin.users.create.form.position_placeholder",
"수석/책임/선임",
)}
{...register("position")}
/>
</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>
<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>
{userSchema.length > 0 && (
<div className="border-t pt-4">
@@ -917,11 +479,8 @@ 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>
)}
@@ -931,6 +490,38 @@ 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"
@@ -950,31 +541,6 @@ 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"
/>
</DialogContent>
</Dialog>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,20 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
FileDown,
Pencil,
Plus,
RefreshCw,
Search,
Settings2,
Trash2,
User,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -33,14 +33,6 @@ import {
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import { Switch } from "../../components/ui/switch";
import {
Table,
TableBody,
@@ -51,18 +43,17 @@ import {
} from "../../components/ui/table";
import { toast } from "../../components/ui/use-toast";
import {
type UserSummary,
bulkDeleteUsers,
bulkUpdateUsers,
deleteUser,
exportUsersCSV,
exportUsersCSVUrl,
fetchMe,
fetchTenant,
fetchTenants,
fetchUsers,
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
type UserSchemaField = {
@@ -71,11 +62,6 @@ type UserSchemaField = {
type: string;
};
type SortConfig = {
key: string;
direction: "asc" | "desc";
};
function UserListPage() {
const navigate = useNavigate();
const [page, setPage] = React.useState(1);
@@ -86,8 +72,6 @@ function UserListPage() {
Record<string, boolean>
>({});
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
const limit = 1000;
const offset = (page - 1) * limit;
@@ -160,39 +144,6 @@ function UserListPage() {
},
});
const exportMutation = useMutation({
mutationFn: (includeIds: boolean) =>
exportUsersCSV(search, selectedCompany, includeIds),
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.users.export_error", "사용자 내보내기에 실패했습니다."),
);
},
});
const statusMutation = useMutation({
mutationFn: ({ userId, status }: { userId: string; status: string }) =>
updateUser(userId, { status }),
onSuccess: () => {
query.refetch();
},
onError: () => {
toast.error(
t("msg.admin.users.status_error", "사용자 상태 변경에 실패했습니다."),
);
},
});
const handleSearch = () => {
setSearch(searchDraft);
setPage(1);
@@ -204,8 +155,9 @@ function UserListPage() {
}
};
const handleExport = (includeIds = false) => {
exportMutation.mutate(includeIds);
const handleExport = () => {
const url = exportUsersCSVUrl(search, selectedCompany);
window.open(url, "_blank");
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
@@ -218,71 +170,7 @@ function UserListPage() {
)
: null;
const rawItems = query.data?.items ?? [];
const items = React.useMemo(() => {
const sorted = [...rawItems];
if (sortConfig) {
sorted.sort((a, b) => {
let aValue: string | number | boolean | null | undefined;
let bValue: string | number | boolean | null | undefined;
if (sortConfig.key === "name_email") {
aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || "";
} else if (sortConfig.key === "tenant_dept") {
aValue =
(a.tenant?.name || a.tenantSlug || "").toLowerCase() +
(a.department || "").toLowerCase();
bValue =
(b.tenant?.name || b.tenantSlug || "").toLowerCase() +
(b.department || "").toLowerCase();
} else {
aValue = (a as Record<string, unknown>)[sortConfig.key] as
| string
| number
| boolean;
bValue = (b as Record<string, unknown>)[sortConfig.key] as
| string
| number
| boolean;
}
if (aValue === bValue) return 0;
if (aValue === null || aValue === undefined) return 1;
if (bValue === null || bValue === undefined) return -1;
if (sortConfig.direction === "asc") {
return aValue < bValue ? -1 : 1;
}
return aValue > bValue ? -1 : 1;
});
}
return sorted;
}, [rawItems, sortConfig]);
const requestSort = (key: SortConfig["key"]) => {
let direction: "asc" | "desc" = "asc";
if (
sortConfig &&
sortConfig.key === key &&
sortConfig.direction === "asc"
) {
direction = "desc";
}
setSortConfig({ key, direction });
};
const getSortIcon = (key: SortConfig["key"]) => {
if (!sortConfig || sortConfig.key !== key) {
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
}
return sortConfig.direction === "asc" ? (
<ArrowUp size={14} className="ml-1" />
) : (
<ArrowDown size={14} className="ml-1" />
);
};
const items = query.data?.items ?? [];
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
@@ -379,80 +267,22 @@ function UserListPage() {
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 mr-2">
<div className="relative w-48">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="pl-9 h-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<select
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(e) => {
setSelectedCompany(e.target.value);
setPage(1);
}}
disabled={profile?.role === "tenant_admin"}
>
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name}
</option>
))}
</select>
<Button
variant="secondary"
size="sm"
onClick={handleSearch}
className="h-9"
>
{t("ui.common.search", "검색")}
</Button>
</div>
<Button
variant="outline"
size="sm"
className="h-9"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button
variant="outline"
onClick={() => handleExport(false)}
className="gap-2"
disabled={exportMutation.isPending}
>
<Button variant="outline" onClick={handleExport} className="gap-2">
<FileDown size={16} />
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</Button>
<Button
variant="outline"
onClick={() => handleExport(true)}
className="gap-2"
disabled={exportMutation.isPending}
>
<FileDown size={16} />
{t("ui.common.export_with_ids", "UUID 포함")}
{t("ui.common.export", "내보내기")}
</Button>
<UserBulkUploadModal onSuccess={() => query.refetch()} />
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Button variant="outline" size="icon">
<Settings2 size={16} />
</Button>
</DialogTrigger>
@@ -506,7 +336,7 @@ function UserListPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<Button asChild size="sm" className="h-9">
<Button asChild>
<Link to="/users/new">
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
@@ -531,6 +361,48 @@ function UserListPage() {
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-6 flex flex-wrap items-center gap-4 flex-shrink-0">
<div className="relative flex-1 min-w-[240px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
{t("ui.admin.users.list.filter.tenant", "테넌트 필터:")}
</span>
<select
className="flex h-9 w-[200px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(e) => {
setSelectedCompany(e.target.value);
setPage(1);
}}
disabled={profile?.role === "tenant_admin"}
>
<option value="">{t("ui.common.all", "전체")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name}
</option>
))}
</select>
</div>
<Button variant="secondary" onClick={handleSearch}>
{t("ui.common.search", "검색")}
</Button>
</div>
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
{errorMsg ?? fallbackError}
@@ -553,72 +425,38 @@ function UserListPage() {
onChange={toggleSelectAll}
/>
</TableHead>
<TableHead
className="min-w-[220px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("id")}
>
<div className="flex items-center">
{t("ui.admin.users.list.table.id", "ID")}
{getSortIcon("id")}
</div>
<TableHead className="min-w-[200px]">
{t(
"ui.admin.users.list.table.name_email",
"NAME / EMAIL",
)}
</TableHead>
<TableHead
className="min-w-[200px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("name_email")}
>
<div className="flex items-center">
{t(
"ui.admin.users.list.table.name_email",
"이름 / 이메일 / 전화번호",
)}
{getSortIcon("name_email")}
</div>
<TableHead>
{t("ui.admin.users.list.table.role", "ROLE")}
</TableHead>
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("status")}
>
<div className="flex items-center">
{t("ui.admin.users.list.table.status", "STATUS")}
{getSortIcon("status")}
</div>
<TableHead>
{t("ui.admin.users.list.table.status", "STATUS")}
</TableHead>
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("tenant_dept")}
>
<div className="flex items-center">
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
{getSortIcon("tenant_dept")}
</div>
<TableHead>
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<TableHead
key={field.key}
className="whitespace-nowrap uppercase cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort(field.key)}
>
<div className="flex items-center">
{field.label}
{getSortIcon(field.key)}
</div>
<TableHead key={field.key} className="uppercase">
{field.label}
</TableHead>
),
)}
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("createdAt")}
>
<div className="flex items-center">
{t("ui.admin.users.list.table.created", "CREATED")}
{getSortIcon("createdAt")}
</div>
<TableHead>
{t("ui.admin.users.list.table.created", "CREATED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.users.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
@@ -626,7 +464,7 @@ function UserListPage() {
{query.isLoading && (
<TableRow>
<TableCell
colSpan={6 + userSchema.length}
colSpan={7 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")}
@@ -636,7 +474,7 @@ function UserListPage() {
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell
colSpan={6 + userSchema.length}
colSpan={7 + userSchema.length}
className="h-24 text-center"
>
{t(
@@ -670,75 +508,41 @@ function UserListPage() {
}
/>
</TableCell>
<TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
data-testid={`user-internal-id-${user.id}`}
>
{user.id}
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div
className="truncate text-sm"
data-testid={`user-contact-${user.id}`}
>
<Link
to={`/users/${user.id}`}
className="font-medium hover:underline text-primary"
>
{user.name}
</Link>
<span className="text-muted-foreground">
{" "}
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
{user.phone && (
<span className="text-muted-foreground">
{" "}
{user.phone}
</span>
)}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) =>
statusMutation.mutate({
userId: user.id,
status: checked ? "active" : "inactive",
})
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
}
aria-label={t(
"ui.admin.users.list.toggle_status",
"{{name}} 활성 상태",
{ name: user.name },
)}
data-testid={`user-status-toggle-${user.id}`}
/>
<span className="text-sm text-muted-foreground">
{t(`ui.common.status.${user.status}`, user.status)}
</span>
</div>
<Badge variant="outline">
{t(`ui.admin.role.${user.role}`, user.role)}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{user.tenant?.name ||
user.companyCode ||
t("ui.common.unassigned", "미배정")}
<Badge
variant={
user.status === "active" ? "default" : "secondary"
}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium text-blue-600">
{user.tenant?.name || user.tenantSlug || "-"}
</span>
<span className="text-xs text-muted-foreground">
{user.department || "-"}
</span>
{user.department && (
<span className="text-xs text-muted-foreground">
{user.department}
</span>
)}
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
@@ -753,6 +557,37 @@ function UserListPage() {
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
>
<Pencil size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => handleDelete(user.id, user.name)}
disabled={
deleteMutation.isPending ||
user.id === profile?.id
}
title={
user.id === profile?.id
? t(
"msg.admin.users.self_delete_blocked",
"본인 계정은 삭제할 수 없습니다.",
)
: undefined
}
>
<Trash2 size={16} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -790,6 +625,16 @@ function UserListPage() {
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
<UserBulkMoveGroupModal
userIds={selectedUserIds}
selectedUsers={items.filter((u) =>
selectedUserIds.includes(u.id),
)}
onSuccess={() => {
query.refetch();
setSelectedUserIds([]);
}}
/>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
@@ -23,129 +23,22 @@ import {
type BulkUserItem,
type BulkUserResult,
bulkCreateUsers,
createTenant,
fetchTenants,
fetchUsers,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
type TenantCSVRow,
type TenantImportPreviewRow,
buildTenantImportPreview,
} from "../../tenants/utils/tenantCsvImport";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
import { parseUserCSV } from "../utils/csvParser";
import {
type HanmacImportEmailPreview,
buildHanmacImportEmailPreview,
} from "../utils/hanmacImportEmail";
interface UserBulkUploadModalProps {
onSuccess?: () => void;
}
function buildUserTenantPreviewRows(
users: BulkUserItem[],
tenants: Parameters<typeof buildTenantImportPreview>[1],
) {
const rowsByKey = new Map<string, TenantCSVRow>();
users.forEach((user, index) => {
const key = tenantImportKeyFromUser(user);
if (!key || rowsByKey.has(key)) {
return;
}
rowsByKey.set(key, {
rowNumber: index + 2,
tenantId: user.tenantImport?.sourceTenantId ?? "",
name: user.tenantImport?.name || user.tenantSlug || key,
type: user.tenantImport?.type || "COMPANY",
parentTenantId: user.tenantImport?.parentTenantId ?? "",
parentTenantSlug: user.tenantImport?.parentTenantSlug ?? "",
slug: user.tenantImport?.slug || user.tenantSlug || key,
memo: user.tenantImport?.memo ?? "",
emailDomain: user.tenantImport?.emailDomain ?? "",
});
});
return buildTenantImportPreview([...rowsByKey.values()], tenants);
}
function tenantImportKeyFromUser(user: BulkUserItem) {
return (
user.tenantImport?.sourceTenantId ||
user.tenantImport?.slug ||
user.tenantSlug ||
user.tenantImport?.name ||
""
);
}
function tenantImportKeyFromRow(row: TenantCSVRow) {
return row.tenantId || row.slug || row.name;
}
function splitTenantImportDomains(value: string) {
return value
.replaceAll("\n", ";")
.replaceAll(",", ";")
.split(";")
.map((domain) => domain.trim().toLowerCase())
.filter(Boolean);
}
function emailLocalPart(email: string) {
return email.trim().toLowerCase().split("@")[0] || "";
}
function hanmacEmailStatusLabel(preview?: HanmacImportEmailPreview) {
if (!preview) return "";
if (preview.status === "suggested") return "제안";
if (preview.status === "needsReview") return "확인 필요";
if (preview.status === "ruleMismatch") return "규칙 확인";
if (preview.status === "blockingError") return "오류";
return "";
}
function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
if (!preview) return "text-muted-foreground";
if (preview.status === "blockingError") return "text-destructive";
if (preview.status === "ruleMismatch" || preview.status === "needsReview") {
return "text-amber-600";
}
if (preview.status === "suggested") return "text-blue-600";
return "text-muted-foreground";
}
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const [open, setOpen] = React.useState(false);
const [file, setFile] = React.useState<File | null>(null);
const [parsing, setParsing] = React.useState(false);
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
const [tenantPreviewRows, setTenantPreviewRows] = React.useState<
TenantImportPreviewRow[]
>([]);
const [selectedTenantMatches, setSelectedTenantMatches] = React.useState<
Record<number, string>
>({});
const [selectedTenantCreateSlugs, setSelectedTenantCreateSlugs] =
React.useState<Record<number, string>>({});
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
const [preparing, setPreparing] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const tenantQuery = useQuery({
queryKey: ["tenants", "user-bulk-import"],
queryFn: () => fetchTenants(1000, 0),
});
const usersQuery = useQuery({
queryKey: ["users", "user-bulk-import-email-policy"],
queryFn: () => fetchUsers(10000, 0),
enabled: open,
});
const mutation = useMutation({
mutationFn: bulkCreateUsers,
onSuccess: (data) => {
@@ -169,102 +62,22 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const text = e.target?.result as string;
const data = parseUserCSV(text);
setPreviewData(data);
const tenantRows = buildUserTenantPreviewRows(
data,
tenantQuery.data?.items ?? [],
);
setTenantPreviewRows(tenantRows);
setSelectedTenantMatches(
Object.fromEntries(
tenantRows.map((row) => [
row.row.rowNumber,
row.defaultTenantId || "__create__",
]),
),
);
setSelectedTenantCreateSlugs(
Object.fromEntries(
tenantRows.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
),
);
setParsing(false);
};
reader.readAsText(file);
};
const handleUpload = async () => {
const handleUpload = () => {
if (previewData.length > 0) {
setPreparing(true);
try {
const users = await resolveUserImportTenants();
mutation.mutate(users);
} finally {
setPreparing(false);
}
mutation.mutate(previewData);
}
};
const resolveUserImportTenants = async () => {
const tenants = tenantQuery.data?.items ?? [];
const tenantByKey = new Map<
string,
{ id: string; slug: string; emailDomain: string }
>();
for (const preview of tenantPreviewRows) {
const key = tenantImportKeyFromRow(preview.row);
const selected =
selectedTenantMatches[preview.row.rowNumber] ?? "__create__";
if (selected !== "__create__") {
const tenant = tenants.find((item) => item.id === selected);
if (tenant) {
tenantByKey.set(key, {
id: tenant.id,
slug: tenant.slug,
emailDomain: preview.row.emailDomain,
});
}
continue;
}
const created = await createTenant({
name: preview.row.name || preview.row.slug,
slug:
selectedTenantCreateSlugs[preview.row.rowNumber] ||
preview.defaultCreateSlug,
type: preview.row.type || "COMPANY",
parentId: preview.row.parentTenantId || undefined,
description: preview.row.memo,
domains: splitTenantImportDomains(preview.row.emailDomain),
status: "active",
});
tenantByKey.set(key, {
id: created.id,
slug: created.slug,
emailDomain: preview.row.emailDomain,
});
}
return previewData.map((user, index) => {
const key = tenantImportKeyFromUser(user);
const resolvedTenant = key ? tenantByKey.get(key) : undefined;
const emailPreview = hanmacEmailPreviews[index];
const { tenantImport: _tenantImport, ...payload } = user;
return {
...payload,
email: emailPreview?.finalEmail ?? payload.email,
tenantId: resolvedTenant?.id ?? payload.tenantId,
tenantSlug: resolvedTenant?.slug ?? payload.tenantSlug,
emailDomain: resolvedTenant?.emailDomain ?? payload.emailDomain,
};
});
};
const downloadTemplate = () => {
const headers =
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
"email,name,phone,role,tenant,department,position,jobTitle,employee_id";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;",
});
@@ -279,47 +92,12 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const reset = () => {
setFile(null);
setPreviewData([]);
setTenantPreviewRows([]);
setSelectedTenantMatches({});
setSelectedTenantCreateSlugs({});
setResults(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const successCount = results?.filter((r) => r.success).length ?? 0;
const failCount = results ? results.length - successCount : 0;
const tenants = tenantQuery.data?.items ?? [];
const existingHanmacLocalParts = React.useMemo(() => {
const values = new Set<string>();
for (const user of usersQuery.data?.items ?? []) {
if (!isHanmacFamilyUser(user, tenants)) {
continue;
}
const localPart = emailLocalPart(user.email);
if (localPart) values.add(localPart);
}
return values;
}, [tenants, usersQuery.data?.items]);
const hanmacEmailPreviews = React.useMemo(() => {
const batchLocalParts = new Set<string>();
return previewData.map((user) => {
const tenant = tenants.find(
(item) =>
item.slug.toLowerCase() === user.tenantSlug?.trim().toLowerCase(),
);
if (!isHanmacFamilyTenant(tenant, tenants)) {
return undefined;
}
return buildHanmacImportEmailPreview(
user,
existingHanmacLocalParts,
batchLocalParts,
);
});
}, [existingHanmacLocalParts, previewData, tenants]);
const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some(
(preview) => preview?.status === "blockingError",
);
return (
<Dialog
@@ -407,79 +185,6 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
</div>
)}
{tenantPreviewRows.length > 0 && (
<div
className="rounded-md border p-3 text-sm"
data-testid="user-import-tenant-resolution"
>
<div className="mb-2 font-medium">
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
</div>
<div className="space-y-2">
{tenantPreviewRows.map((preview) => (
<div
key={preview.row.rowNumber}
className="grid gap-2 sm:grid-cols-[1fr_1fr]"
>
<div>
<div className="font-medium">{preview.row.name}</div>
<div className="font-mono text-xs text-muted-foreground">
{preview.row.slug}
</div>
</div>
<div className="space-y-2">
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={
selectedTenantMatches[preview.row.rowNumber] ??
"__create__"
}
onChange={(event) =>
setSelectedTenantMatches((prev) => ({
...prev,
[preview.row.rowNumber]: event.target.value,
}))
}
>
<option value="__create__">
{t(
"ui.admin.users.bulk.create_missing_tenant",
"신규 생성",
)}
</option>
{preview.candidates.map((candidate) => (
<option
key={candidate.tenantId}
value={candidate.tenantId}
>
{candidate.name} ({candidate.slug})
</option>
))}
</select>
{(selectedTenantMatches[preview.row.rowNumber] ??
"__create__") === "__create__" && (
<input
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
value={
selectedTenantCreateSlugs[
preview.row.rowNumber
] ?? ""
}
onChange={(event) =>
setSelectedTenantCreateSlugs((prev) => ({
...prev,
[preview.row.rowNumber]: event.target.value,
}))
}
/>
)}
</div>
</div>
))}
</div>
</div>
)}
{previewData.length > 0 && (
<ScrollArea className="h-[200px] rounded-md border">
<table className="w-full text-sm">
@@ -488,47 +193,20 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
<th className="p-2 text-left">Email</th>
<th className="p-2 text-left">Name</th>
<th className="p-2 text-left">Tenant</th>
<th className="p-2 text-left">Status</th>
</tr>
</thead>
<tbody>
{previewData.slice(0, 10).map((u, index) => (
<tr key={`${u.email}-${index}`} className="border-t">
<td className="p-2">
<input
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
value={
hanmacEmailPreviews[index]?.finalEmail ?? u.email
}
onChange={(event) =>
setPreviewData((prev) =>
prev.map((item, itemIndex) =>
itemIndex === index
? { ...item, email: event.target.value }
: item,
),
)
}
/>
</td>
{previewData.slice(0, 10).map((u) => (
<tr key={u.email} className="border-t">
<td className="p-2">{u.email}</td>
<td className="p-2">{u.name}</td>
<td className="p-2">{u.tenantSlug || "-"}</td>
<td
className={`p-2 text-xs ${hanmacEmailStatusClass(
hanmacEmailPreviews[index],
)}`}
>
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
{hanmacEmailPreviews[index]?.reason && (
<div>{hanmacEmailPreviews[index]?.reason}</div>
)}
</td>
</tr>
))}
{previewData.length > 10 && (
<tr>
<td
colSpan={4}
colSpan={3}
className="p-2 text-center text-muted-foreground italic"
>
... and {previewData.length - 10} more users
@@ -599,16 +277,11 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
{!results ? (
<Button
onClick={handleUpload}
disabled={
previewData.length === 0 ||
mutation.isPending ||
preparing ||
hasBlockingHanmacEmailRows
}
disabled={previewData.length === 0 || mutation.isPending}
className="w-full sm:w-auto"
data-testid="bulk-start-btn"
>
{(mutation.isPending || preparing) && (
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.start_upload", "등록 시작")}

View File

@@ -1,172 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
buildAuthenticatedOrgChartUrl,
buildOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
isHanmacFamilyUser,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
describe("orgChartPicker", () => {
it("builds the tenant picker embed URL from ORGFRONT_URL", () => {
expect(buildOrgChartTenantPickerUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600",
);
});
it("adds tenantId to the tenant picker URL when Hanmac family scope is known", () => {
expect(
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
tenantId: "hanmac-family-id",
}),
).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600&tenantId=hanmac-family-id",
);
});
it("wraps the picker URL with the org-chart auto login entry", () => {
expect(
buildAuthenticatedOrgChartTenantPickerUrl(
"https://orgchart.example.com",
{
tenantId: "hanmac-family-id",
},
),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id",
);
});
it("builds the chart navigation URL through the org-chart auto login entry", () => {
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
);
});
it("parses the first tenant id and name from orgfront confirm messages", () => {
expect(
parseOrgChartTenantSelection({
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [
{
type: "tenant",
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
},
],
},
}),
).toEqual({
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
});
});
it("ignores non-tenant or malformed picker messages", () => {
expect(
parseOrgChartTenantSelection({
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [{ type: "user", id: "u-1", name: "User" }],
},
}),
).toBeNull();
expect(parseOrgChartTenantSelection({ type: "other" })).toBeNull();
});
it("filters Hanmac family subtree and system tenants from non-family tenant choices", () => {
const visibleTenants = filterNonHanmacFamilyTenants(
[
{
id: "system-id",
slug: "system",
name: "System",
type: "SYSTEM",
parentId: undefined,
},
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
type: "USER_GROUP",
parentId: "hanmac-company-id",
},
],
"hanmac-family-id",
);
expect(visibleTenants.map((tenant) => tenant.slug)).toEqual(["external"]);
});
it("detects existing users as Hanmac family from tenant subtree without metadata flag", () => {
const tenants = [
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "기술기획",
type: "USER_GROUP",
parentId: "hanmac-company-id",
},
];
expect(
isHanmacFamilyUser(
{
companyCode: "external",
tenant: tenants[0],
joinedTenants: [tenants[3]],
metadata: {},
},
tenants,
"hanmac-family-id",
),
).toBe(true);
});
});

View File

@@ -1,229 +0,0 @@
export type OrgChartTenantSelection = {
id: string;
name: string;
};
export type TenantFilterTarget = {
id?: string;
slug?: string;
type?: string;
parentId?: string | null;
name?: string;
};
export type HanmacFamilyUserTarget = {
companyCode?: string;
tenantSlug?: string;
tenant?: TenantFilterTarget;
joinedTenants?: TenantFilterTarget[];
metadata?: Record<string, unknown>;
};
type OrgChartPickerMessage = {
type?: unknown;
payload?: {
selections?: Array<{
type?: unknown;
id?: unknown;
name?: unknown;
}>;
};
};
type OrgChartTenantPickerOptions = {
tenantId?: string;
};
type OrgChartLoginOptions = {
returnTo?: string;
};
function isSystemTenant(tenant: TenantFilterTarget) {
const slug = tenant.slug?.trim().toLowerCase();
const type = tenant.type?.trim().toUpperCase();
return (
!tenant.id?.trim() ||
!tenant.slug?.trim() ||
type === "SYSTEM" ||
slug === "system" ||
slug === "global"
);
}
function isInTenantSubtree<T extends TenantFilterTarget>(
tenant: T,
rootTenantId: string,
tenantById: Map<string, T>,
) {
if (!rootTenantId) {
return false;
}
if (tenant.id === rootTenantId) {
return true;
}
const visitedTenantIds = new Set<string>();
let parentId = tenant.parentId ?? "";
while (parentId) {
if (parentId === rootTenantId) {
return true;
}
if (visitedTenantIds.has(parentId)) {
return false;
}
visitedTenantIds.add(parentId);
parentId = tenantById.get(parentId)?.parentId ?? "";
}
return false;
}
function resolveHanmacFamilyTenantId<T extends TenantFilterTarget>(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const envTenantId = hanmacFamilyTenantId?.trim();
if (envTenantId) return envTenantId;
return (
tenants.find((tenant) => tenant.slug?.toLowerCase() === "hanmac-family")
?.id ?? ""
);
}
export function isHanmacFamilyTenant<T extends TenantFilterTarget>(
tenant: T | undefined,
tenants: T[],
hanmacFamilyTenantId?: string,
) {
if (!tenant || !tenant.id) return false;
const rootTenantId = resolveHanmacFamilyTenantId(
tenants,
hanmacFamilyTenantId,
);
if (!rootTenantId) return false;
const tenantById = new Map(
tenants
.filter((item) => item.id?.trim())
.map((item) => [item.id as string, item]),
);
const target = tenantById.get(tenant.id) ?? tenant;
return isInTenantSubtree(target, rootTenantId, tenantById);
}
export function isHanmacFamilyUser<T extends TenantFilterTarget>(
user: HanmacFamilyUserTarget,
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const metadata = user.metadata ?? {};
if (metadata.hanmacFamily === true || metadata.userType === "hanmac") {
return true;
}
const tenantBySlug = new Map(
tenants
.filter((tenant) => tenant.slug?.trim())
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
);
const tenantCandidates = [
user.tenant,
...(user.joinedTenants ?? []),
tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""),
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
];
return tenantCandidates.some((tenant) =>
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
);
}
export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const rootTenantId = resolveHanmacFamilyTenantId(
tenants,
hanmacFamilyTenantId,
);
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
.map((tenant) => [tenant.id as string, tenant]),
);
return tenants.filter(
(tenant) =>
!isSystemTenant(tenant) &&
!isInTenantSubtree(tenant, rootTenantId, tenantById),
);
}
export function buildOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const params = new URLSearchParams({
mode: "single",
select: "tenant",
width: "400",
height: "600",
});
const tenantId = options.tenantId?.trim();
if (tenantId) {
params.set("tenantId", tenantId);
}
return `${normalizedBase}/embed/picker?${params.toString()}`;
}
export function buildAuthenticatedOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const pickerUrl = buildOrgChartTenantPickerUrl("", options);
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
}
export function buildAuthenticatedOrgChartUrl(
baseUrl?: string,
options: OrgChartLoginOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const returnTo = options.returnTo?.trim() || "/chart";
const params = new URLSearchParams({
auto: "1",
returnTo,
});
return `${normalizedBase}/login?${params.toString()}`;
}
export function parseOrgChartTenantSelection(
message: unknown,
): OrgChartTenantSelection | null {
const data = message as OrgChartPickerMessage;
if (data?.type !== "orgfront:picker:confirm") {
return null;
}
const selection = data.payload?.selections?.[0];
if (
selection?.type !== "tenant" ||
typeof selection.id !== "string" ||
typeof selection.name !== "string" ||
selection.id.trim() === ""
) {
return null;
}
return {
id: selection.id,
name: selection.name,
};
}

View File

@@ -1,18 +0,0 @@
export type UserSchemaFieldType =
| "text"
| "number"
| "boolean"
| "date"
| "float"
| "datetime";
export type UserSchemaField = {
key: string;
label?: string;
type?: UserSchemaFieldType;
required?: boolean;
adminOnly?: boolean;
validation?: string;
isLoginId?: boolean;
indexed?: boolean;
};

View File

@@ -1,14 +0,0 @@
import { t } from "../../lib/i18n";
export const userStatusValues = [
"active",
"inactive",
"suspended",
"leave_of_absence",
] as const;
export type UserStatusValue = (typeof userStatusValues)[number];
export function userStatusLabel(status: string) {
return t(`ui.common.status.${status}`, status);
}

View File

@@ -43,89 +43,4 @@ test@test.com,Test,baron`;
expect(result[0].email).toBe("test@test.com");
expect(result[0].tenantSlug).toBe("baron");
});
it("should parse NAVERWORKS member CSV sample into Baron bulk user fields", () => {
const csv = `"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time"
"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
email: "john1@company.com",
loginId: "john.doe",
name: "John Doe",
phone: "+19144812222",
department: "myteam",
grade: "Manager",
position: "Manager",
jobTitle: "Sales management",
tenantImport: {
name: "myteam",
parentTenantName: "org.3",
},
metadata: {
personal_email: "john@naver.com",
employee_id: "AB001",
naverworks_user_type: "Permanent Employee",
naverworks_level: "Manager",
naverworks_organization_path: "org.1|org.2|org.3|myteam",
naverworks_workplace: "New York",
},
});
});
it("should parse tenant conflict metadata for import resolution", () => {
const csv = `email,name,tenant_id,tenant_slug,tenant_name,tenant_type,parent_tenant_slug,tenant_memo,email_domain
test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-slug,Imported memo,missing.example.com`;
const result = parseUserCSV(csv);
expect(result[0]).toMatchObject({
tenantId: "local-tenant-id",
tenantSlug: "missing-slug",
emailDomain: "missing.example.com",
tenantImport: {
sourceTenantId: "local-tenant-id",
slug: "missing-slug",
name: "Missing Tenant",
type: "COMPANY",
parentTenantSlug: "parent-slug",
memo: "Imported memo",
emailDomain: "missing.example.com",
},
});
});
it("should parse one nullable additional appointment from numbered columns", () => {
const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002
nullable@test.com,Nullable User,010-1111-1111,user,primary-tenant,개발팀,책임,팀장,Backend,EMP003,,,,,,`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
tenantSlug: "primary-tenant",
department: "개발팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: {
employee_id: "EMP001",
},
additionalAppointments: [
{
tenantSlug: "second-tenant",
department: "센터",
grade: "수석",
jobTitle: "Architecture",
metadata: {
employee_id: "EMP002",
},
},
],
});
expect(result[1].additionalAppointments).toBeUndefined();
});
});

View File

@@ -1,25 +1,21 @@
import type { BulkUserAppointment, BulkUserItem } from "../../../lib/adminApi";
import type { BulkUserItem } from "../../../lib/adminApi";
export function parseUserCSV(text: string): BulkUserItem[] {
const records = parseCSVRecords(text.replace(/^\uFEFF/, ""));
if (records.length < 2) {
const lines = text.split(/\r?\n/);
if (lines.length < 2) {
return [];
}
const headers = records[0].map(normalizeHeader);
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const data: BulkUserItem[] = [];
for (let i = 1; i < records.length; i++) {
const values = records[i].map((v) => v.trim());
if (values.every((value) => value === "")) continue;
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const values = lines[i].split(",").map((v) => v.trim());
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
metadata: {},
};
const additionalAppointment: BulkUserAppointment & {
metadata: Record<string, string>;
} = {
metadata: {},
};
for (let index = 0; index < headers.length; index++) {
const header = headers[index];
@@ -36,146 +32,17 @@ export function parseUserCSV(text: string): BulkUserItem[] {
item.role = value;
} else if (header === "tenant") {
item.tenantSlug = value;
} else if (header === "tenant_slug" || header === "companycode") {
item.tenantSlug = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
slug: value,
};
} else if (header === "tenant_id") {
item.tenantId = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
sourceTenantId: value,
};
} else if (header === "tenant_name") {
item.tenantImport = {
...(item.tenantImport ?? {}),
name: value,
};
} else if (header === "tenant_type") {
item.tenantImport = {
...(item.tenantImport ?? {}),
type: value,
};
} else if (header === "parent_tenant_id") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantId: value,
};
} else if (header === "parent_tenant_slug") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantSlug: value,
};
} else if (header === "parent_tenant_name") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantName: value,
};
} else if (header === "tenant_memo") {
item.tenantImport = {
...(item.tenantImport ?? {}),
memo: value,
};
} else if (header === "email_domain" || header === "tenant_domain") {
item.emailDomain = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
emailDomain: value,
};
} else if (header === "department") {
item.department = value;
} else if (header === "grade") {
item.grade = value;
} else if (header === "position") {
item.position = value;
} else if (header === "jobtitle") {
item.jobTitle = value;
} else if (header === "employee_id") {
item.metadata.employee_id = value;
} else if (header === "tenant_slug1") {
additionalAppointment.tenantSlug = value;
} else if (header === "department1") {
additionalAppointment.department = value;
} else if (header === "grade1") {
additionalAppointment.grade = value;
} else if (header === "position1") {
additionalAppointment.position = value;
} else if (header === "jobtitle1") {
additionalAppointment.jobTitle = value;
} else if (header === "employee_id1") {
additionalAppointment.metadata.employee_id = value;
} else if (header === "lastname") {
item.metadata.naverworks_last_name = value;
} else if (header === "firstname") {
item.metadata.naverworks_first_name = value;
} else if (header === "id") {
item.loginId = value;
item.metadata.naverworks_id = value;
} else if (header === "personalemail") {
item.metadata.personal_email = value;
} else if (header === "subemail") {
item.metadata.naverworks_sub_email = value;
item.email = firstEmailToken(value) || item.email;
} else if (header === "nickname") {
item.metadata.naverworks_nickname = value;
} else if (header === "usertype") {
item.metadata.naverworks_user_type = value;
} else if (header === "level") {
item.grade = value;
item.metadata.naverworks_level = value;
} else if (header === "organization") {
item.metadata.naverworks_organization_path = value;
const parts = splitOrganizationPath(value);
const leaf = parts.at(-1) ?? "";
const parent = parts.at(-2) ?? "";
if (leaf) {
item.department = leaf;
item.tenantImport = {
...(item.tenantImport ?? {}),
name: leaf,
parentTenantName: parent,
};
}
} else if (header === "companymainphone") {
item.metadata.naverworks_company_main_phone = value;
} else if (header === "mobilecountrycode") {
item.metadata.naverworks_mobile_country_code = value;
} else if (header === "mobilenumbers") {
item.metadata.naverworks_mobile_numbers = value;
} else if (header === "language") {
item.metadata.naverworks_language = value;
} else if (header === "responsibilities") {
item.jobTitle = value;
} else if (header === "workplace") {
item.metadata.naverworks_workplace = value;
} else if (header === "sns") {
item.metadata.naverworks_sns = value;
} else if (header === "snsid") {
item.metadata.naverworks_sns_id = value;
} else if (header === "birthdaysolarlunar") {
item.metadata.naverworks_birthday_calendar = value;
} else if (header === "birthday") {
item.metadata.naverworks_birthday = value;
} else if (header === "entrydate") {
item.metadata.naverworks_entry_date = value;
} else if (header === "employeenumber") {
item.metadata.employee_id = value;
} else if (header === "accountactivationtime") {
item.metadata.naverworks_account_activation_time = value;
} else {
item.metadata[header] = value;
}
}
applyNaverWorksFallbacks(item);
if (additionalAppointment.tenantSlug) {
item.additionalAppointments = [
cleanAdditionalAppointment(additionalAppointment),
];
}
if (item.email && item.name) {
data.push(item as BulkUserItem);
}
@@ -183,125 +50,3 @@ export function parseUserCSV(text: string): BulkUserItem[] {
return data;
}
function cleanAdditionalAppointment(
appointment: BulkUserAppointment & { metadata: Record<string, string> },
) {
const metadata =
Object.keys(appointment.metadata).length > 0
? appointment.metadata
: undefined;
return {
...(appointment.tenantId ? { tenantId: appointment.tenantId } : {}),
...(appointment.tenantSlug ? { tenantSlug: appointment.tenantSlug } : {}),
...(appointment.tenantName ? { tenantName: appointment.tenantName } : {}),
...(appointment.isPrimary !== undefined
? { isPrimary: appointment.isPrimary }
: {}),
...(appointment.isOwner !== undefined
? { isOwner: appointment.isOwner }
: {}),
...(appointment.department ? { department: appointment.department } : {}),
...(appointment.grade ? { grade: appointment.grade } : {}),
...(appointment.position ? { position: appointment.position } : {}),
...(appointment.jobTitle ? { jobTitle: appointment.jobTitle } : {}),
...(metadata ? { metadata } : {}),
};
}
function normalizeHeader(header: string) {
return header
.trim()
.toLowerCase()
.replace(/^\uFEFF/, "")
.replace(/[^a-z0-9_]/g, "");
}
function parseCSVRecords(text: string) {
const records: string[][] = [];
let field = "";
let row: string[] = [];
let quoted = false;
for (let index = 0; index < text.length; index++) {
const char = text[index];
const next = text[index + 1];
if (char === '"') {
if (quoted && next === '"') {
field += '"';
index++;
} else {
quoted = !quoted;
}
continue;
}
if (char === "," && !quoted) {
row.push(field);
field = "";
continue;
}
if ((char === "\n" || char === "\r") && !quoted) {
if (char === "\r" && next === "\n") index++;
row.push(field);
records.push(row);
field = "";
row = [];
continue;
}
field += char;
}
if (field !== "" || row.length > 0) {
row.push(field);
records.push(row);
}
return records;
}
function firstEmailToken(value: string) {
return (
value
.split(/[;,]/)
.map((token) => token.trim())
.find((token) => token.includes("@")) ?? ""
);
}
function splitOrganizationPath(value: string) {
return value
.split("|")
.map((part) => part.trim())
.filter(Boolean);
}
function applyNaverWorksFallbacks(
item: Partial<BulkUserItem> & { metadata: Record<string, string> },
) {
if (!item.name) {
const firstName = item.metadata.naverworks_first_name ?? "";
const lastName = item.metadata.naverworks_last_name ?? "";
item.name = [firstName, lastName].filter(Boolean).join(" ").trim();
if (!item.name && item.metadata.naverworks_nickname) {
item.name = item.metadata.naverworks_nickname;
}
}
if (!item.email) {
item.email = item.metadata.personal_email;
}
if (!item.phone) {
const countryCode = item.metadata.naverworks_mobile_country_code ?? "";
const number = item.metadata.naverworks_mobile_numbers ?? "";
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
}
if (!item.grade && item.metadata.naverworks_level) {
item.grade = item.metadata.naverworks_level;
}
}

View File

@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildHanmacImportEmailPreview,
buildKoreanNameEmailBase,
matchesSuggestedNameRule,
} from "./hanmacImportEmail";
describe("hanmac import email policy", () => {
it("builds name initials plus surname base", () => {
expect(buildKoreanNameEmailBase("한치영")).toEqual({
base: "cyhan",
needsReview: false,
});
});
it("matches base plus numeric suffix only", () => {
expect(matchesSuggestedNameRule("cyhan", "cyhan")).toBe(true);
expect(matchesSuggestedNameRule("cyhan2", "cyhan")).toBe(true);
expect(matchesSuggestedNameRule("hcy", "cyhan")).toBe(false);
expect(matchesSuggestedNameRule("cyhan-a", "cyhan")).toBe(false);
});
it("suggests the next available local part for domain-only email", () => {
const preview = buildHanmacImportEmailPreview(
{
email: "@hanmaceng.co.kr",
name: "한치영",
tenantSlug: "hanmac",
metadata: {},
},
new Set(["cyhan", "cyhan1"]),
new Set(),
);
expect(preview.finalEmail).toBe("cyhan2@hanmaceng.co.kr");
expect(preview.status).toBe("suggested");
expect(preview.warnings).toContain("suggested");
});
it("marks rule mismatch as a warning without blocking the row", () => {
const preview = buildHanmacImportEmailPreview(
{
email: "hcy@hanmaceng.co.kr",
name: "한치영",
tenantSlug: "hanmac",
metadata: {},
},
new Set(),
new Set(),
);
expect(preview.finalEmail).toBe("hcy@hanmaceng.co.kr");
expect(preview.status).toBe("ruleMismatch");
expect(preview.warnings).toContain("ruleMismatch");
expect(preview.blockingErrors).toEqual([]);
});
it("blocks duplicate full local part for Hanmac family", () => {
const preview = buildHanmacImportEmailPreview(
{
email: "han@samaneng.com",
name: "한치영",
tenantSlug: "hanmac",
metadata: {},
},
new Set(["han"]),
new Set(),
);
expect(preview.status).toBe("blockingError");
expect(preview.blockingErrors).toContain("duplicateLocalPart");
});
});

View File

@@ -1,296 +0,0 @@
import type { BulkUserItem } from "../../../lib/adminApi";
export type HanmacImportEmailStatus =
| "valid"
| "suggested"
| "needsReview"
| "ruleMismatch"
| "blockingError";
export type HanmacImportEmailPreview = {
originalEmail: string;
suggestedEmail?: string;
finalEmail: string;
status: HanmacImportEmailStatus;
warnings: string[];
blockingErrors: string[];
reason?: string;
localPart?: string;
};
const surnameRomanization: Record<string, string> = {
: "han",
: "kim",
: "lee",
: "park",
: "choi",
: "jung",
: "cho",
: "kang",
: "yoon",
: "jang",
: "lim",
: "lim",
: "shin",
: "oh",
: "seo",
: "kwon",
: "hwang",
: "ahn",
: "song",
: "jeon",
: "hong",
: "yoo",
: "ko",
: "moon",
: "yang",
: "son",
: "bae",
: "baek",
: "heo",
: "nam",
: "sim",
: "noh",
: "ha",
: "kwak",
: "sung",
: "cha",
: "joo",
: "woo",
: "koo",
: "min",
: "ryu",
: "na",
: "jin",
: "ji",
: "um",
: "chae",
: "won",
: "cheon",
: "bang",
: "gong",
: "hyun",
: "ham",
: "yeo",
: "choo",
: "do",
: "so",
: "seok",
: "sun",
: "seol",
: "ma",
: "gil",
: "yeon",
: "wi",
: "pyo",
: "myung",
: "ki",
: "ban",
: "ra",
: "wang",
: "geum",
: "ok",
: "yook",
: "in",
: "maeng",
: "je",
: "mo",
: "tak",
: "guk",
: "eo",
: "eun",
: "pyeon",
: "yong",
};
const initialRomanization = [
"g",
"g",
"n",
"d",
"d",
"r",
"m",
"b",
"b",
"s",
"s",
"y",
"j",
"j",
"c",
"k",
"t",
"p",
"h",
];
export function buildKoreanNameEmailBase(name: string) {
const runes = [...name.trim()].filter((char) => !/\s/.test(char));
if (runes.length < 2) {
return { base: "", needsReview: true };
}
const surname = surnameRomanization[runes[0]];
if (!surname) {
return { base: "", needsReview: true };
}
const initials = runes.slice(1).map((char) => romanizedHangulInitial(char));
if (initials.some((value) => !value)) {
return { base: "", needsReview: true };
}
return { base: `${initials.join("")}${surname}`, needsReview: false };
}
export function matchesSuggestedNameRule(localPart: string, base: string) {
const normalizedLocalPart = localPart.trim().toLowerCase();
const normalizedBase = base.trim().toLowerCase();
if (!normalizedLocalPart || !normalizedBase) {
return false;
}
if (normalizedLocalPart === normalizedBase) {
return true;
}
return new RegExp(`^${escapeRegExp(normalizedBase)}\\d+$`).test(
normalizedLocalPart,
);
}
export function buildHanmacImportEmailPreview(
user: BulkUserItem,
existingLocalParts: Set<string>,
batchLocalParts: Set<string>,
): HanmacImportEmailPreview {
const originalEmail = user.email.trim();
const split = splitEmailDomain(originalEmail);
if (!split) {
return {
originalEmail,
finalEmail: originalEmail,
status: "blockingError",
warnings: [],
blockingErrors: ["invalidEmail"],
reason: "이메일 형식을 확인해 주세요.",
};
}
const usedLocalParts = new Set([
...[...existingLocalParts].map((value) => value.toLowerCase()),
...[...batchLocalParts].map((value) => value.toLowerCase()),
]);
const { base, needsReview } = buildKoreanNameEmailBase(user.name);
const warnings: string[] = [];
if (needsReview) {
warnings.push("needsReview");
}
if (!split.localPart) {
if (!base) {
return {
originalEmail,
finalEmail: originalEmail,
status: "blockingError",
warnings,
blockingErrors: ["missingLocalPart"],
reason: "이름으로 이메일 ID를 제안할 수 없습니다.",
};
}
const nextLocalPart = nextAvailableLocalPart(base, usedLocalParts);
const finalEmail = `${nextLocalPart}@${split.domain}`;
batchLocalParts.add(nextLocalPart);
return {
originalEmail,
suggestedEmail: finalEmail,
finalEmail,
status: "suggested",
warnings: appendUnique(warnings, "suggested"),
blockingErrors: [],
localPart: nextLocalPart,
};
}
const localPart = split.localPart.toLowerCase();
if (usedLocalParts.has(localPart)) {
return {
originalEmail,
finalEmail: originalEmail,
status: "blockingError",
warnings,
blockingErrors: ["duplicateLocalPart"],
reason: "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.",
localPart,
};
}
batchLocalParts.add(localPart);
if (base && !matchesSuggestedNameRule(localPart, base)) {
return {
originalEmail,
finalEmail: originalEmail,
status: "ruleMismatch",
warnings: appendUnique(warnings, "ruleMismatch"),
blockingErrors: [],
reason: "권장 이메일 ID 규칙과 다릅니다.",
localPart,
};
}
return {
originalEmail,
finalEmail: originalEmail,
status: warnings.includes("needsReview") ? "needsReview" : "valid",
warnings,
blockingErrors: [],
localPart,
};
}
function splitEmailDomain(email: string) {
const normalized = email.trim().toLowerCase();
const parts = normalized.split("@");
if (parts.length !== 2 || !parts[1] || !parts[1].includes(".")) {
return null;
}
return {
localPart: parts[0],
domain: parts[1],
};
}
function romanizedHangulInitial(char: string) {
const code = char.codePointAt(0);
if (code === undefined || code < 0xac00 || code > 0xd7a3) {
return "";
}
const index = Math.floor((code - 0xac00) / 588);
return initialRomanization[index] ?? "";
}
function nextAvailableLocalPart(base: string, usedLocalParts: Set<string>) {
const normalizedBase = base.trim().toLowerCase();
if (!usedLocalParts.has(normalizedBase)) {
return normalizedBase;
}
let index = 1;
while (usedLocalParts.has(`${normalizedBase}${index}`)) {
index += 1;
}
return `${normalizedBase}${index}`;
}
function appendUnique(values: string[], value: string) {
if (values.includes(value)) {
return values;
}
return [...values, value];
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -21,7 +21,7 @@ export type AuditLogListResponse = {
export type TenantSummary = {
id: string;
type: string; // 허용 타입: PERSONAL, COMPANY, COMPANY_GROUP, ORGANIZATION, USER_GROUP
type: string; // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP
name: string;
slug: string;
description: string;
@@ -42,7 +42,6 @@ export type TenantCreateRequest = {
description?: string;
status?: string;
domains?: string[];
forceDomainConflicts?: string[];
config?: Record<string, unknown>;
};
@@ -61,17 +60,9 @@ export type TenantUpdateRequest = {
description?: string;
status?: string;
domains?: string[];
forceDomainConflicts?: string[];
config?: Record<string, unknown>;
};
export type TenantImportResult = {
created: number;
updated: number;
failed: number;
errors: string[];
};
export type ApiKeySummary = {
id: string;
name: string;
@@ -101,49 +92,6 @@ export type RoleListResponse = {
total: number;
};
export type RPUsageDailyMetric = {
date: string;
tenantId: string;
tenantType: string;
tenantName?: string;
clientId: string;
clientName: string;
loginRequests: number;
otherRequests: number;
uniqueSubjects: number;
};
export type RPUsagePeriod = "day" | "week" | "month";
export type RPUsageDailyResponse = {
items: RPUsageDailyMetric[];
days: number;
period: RPUsagePeriod;
tenantId?: string;
};
export type AdminOverviewStats = {
totalTenants: number;
oidcClients: number;
auditEvents24h: number;
};
export type UserProjectionStatus = {
name: string;
status: "ready" | "failed" | "syncing" | string;
ready: boolean;
lastSyncedAt?: string;
lastError?: string;
updatedAt?: string;
projectedUsers: number;
};
export type UserProjectionActionResult = {
status: string;
syncedUsers: number;
updatedAt: string;
};
export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor },
@@ -151,50 +99,6 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
return data;
}
export async function fetchAdminOverviewStats() {
const { data } = await apiClient.get<AdminOverviewStats>("/v1/admin/stats");
return data;
}
export async function fetchUserProjectionStatus() {
const { data } = await apiClient.get<UserProjectionStatus>(
"/v1/admin/projections/users",
);
return data;
}
export async function reconcileUserProjection() {
const { data } = await apiClient.post<UserProjectionActionResult>(
"/v1/admin/projections/users/reconcile",
);
return data;
}
export async function resetUserProjection() {
const { data } = await apiClient.post<UserProjectionActionResult>(
"/v1/admin/projections/users/reset",
);
return data;
}
export async function fetchAdminRPUsageDaily({
days = 14,
period = "day",
tenantId,
}: {
days?: number;
period?: RPUsagePeriod;
tenantId?: string;
} = {}) {
const { data } = await apiClient.get<RPUsageDailyResponse>(
"/v1/admin/rp-usage/daily",
{
params: { days, period, tenantId: tenantId || undefined },
},
);
return data;
}
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
@@ -241,37 +145,6 @@ export async function deleteTenantsBulk(ids: string[]) {
});
}
export async function exportTenantsCSV(includeIds = false) {
const response = await apiClient.get<Blob>("/v1/admin/tenants/export", {
params: { includeIds },
responseType: "blob",
});
const dispositionHeader = response.headers["content-disposition"];
const disposition = Array.isArray(dispositionHeader)
? dispositionHeader[0]
: String(dispositionHeader ?? "");
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/i);
return {
blob: response.data,
filename: filenameMatch?.[1] ?? "tenants.csv",
};
}
export async function importTenantsCSV(file: File) {
const formData = new FormData();
formData.append("file", file);
const { data } = await apiClient.post<TenantImportResult>(
"/v1/admin/tenants/import",
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return data;
}
export async function approveTenant(tenantId: string) {
const { data } = await apiClient.post<TenantSummary>(
`/v1/admin/tenants/${tenantId}/approve`,
@@ -393,6 +266,44 @@ export async function removeGroupMember(
);
}
export interface ImportResult {
totalRows: number;
processed: number;
userCreated: number;
userUpdated: number;
tenantCreated: number;
errors: string[];
}
export async function fetchImportProgress(
tenantId: string,
progressId: string,
) {
const { data } = await apiClient.get<{ current: number; total: number }>(
`/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`,
);
return data;
}
export async function importOrgChart(
tenantId: string,
file: File,
progressId?: string,
) {
const formData = new FormData();
formData.append("file", file);
const url = progressId
? `/v1/admin/tenants/${tenantId}/organization/import?progressId=${progressId}`
: `/v1/admin/tenants/${tenantId}/organization/import`;
const { data } = await apiClient.post<{ data: ImportResult }>(url, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return data.data;
}
export type GroupRole = {
tenantId: string;
tenantName: string;
@@ -477,7 +388,6 @@ export type UserSummary = {
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
createdAt: string;
@@ -500,13 +410,8 @@ export type UserCreateRequest = {
role?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
primaryTenantId?: string;
primaryTenantName?: string;
primaryTenantIsOwner?: boolean;
additionalAppointments?: UserAppointment[];
metadata?: Record<string, unknown>;
};
@@ -522,38 +427,7 @@ export type UserUpdateRequest = {
role?: string;
status?: string;
tenantSlug?: string;
isAddTenant?: boolean;
isRemoveTenant?: boolean;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
primaryTenantId?: string;
primaryTenantName?: string;
primaryTenantIsOwner?: boolean;
additionalAppointments?: UserAppointment[];
metadata?: Record<string, unknown>;
};
export type UserAppointment = {
tenantId: string;
tenantSlug?: string;
tenantName: string;
isPrimary?: boolean;
isOwner: boolean;
jobTitle?: string;
grade?: string;
position?: string;
};
export type BulkUserAppointment = {
tenantId?: string;
tenantSlug?: string;
tenantName?: string;
isPrimary?: boolean;
isOwner?: boolean;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
@@ -565,34 +439,15 @@ export type BulkUserItem = {
name: string;
phone?: string;
role?: string;
tenantId?: string;
tenantSlug?: string;
emailDomain?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
additionalAppointments?: BulkUserAppointment[];
tenantImport?: {
sourceTenantId?: string;
slug?: string;
name?: string;
type?: string;
parentTenantId?: string;
parentTenantSlug?: string;
parentTenantName?: string;
memo?: string;
emailDomain?: string;
};
metadata: Record<string, string>;
};
export type BulkUserResult = {
email: string;
originalEmail?: string;
suggestedEmail?: string;
status?: string;
warnings?: string[];
success: boolean;
message?: string;
userId?: string;
@@ -602,62 +457,6 @@ export type BulkUserResponse = {
results: BulkUserResult[];
};
export type WorksmobileOutboxItem = {
id: string;
resourceType: string;
resourceId: string;
action: string;
status: string;
retryCount: number;
lastError?: string;
createdAt: string;
updatedAt: string;
};
export type WorksmobileOverview = {
tenant: TenantSummary;
config: {
enabled: boolean;
domainMappings?: Record<string, number>;
tokenConfigured: boolean;
adminTenantId?: string;
};
recentJobs: WorksmobileOutboxItem[];
};
export type WorksmobileComparisonItem = {
resourceType: string;
baronId?: string;
baronName?: string;
baronEmail?: string;
baronPrimaryOrgId?: string;
baronPrimaryOrgName?: string;
baronParentId?: string;
baronParentName?: string;
worksmobileId?: string;
externalKey?: string;
worksmobileName?: string;
worksmobileEmail?: string;
worksmobileLevelId?: string;
worksmobileLevelName?: string;
worksmobileTask?: string;
worksmobileDomainId?: number;
worksmobileDomainName?: string;
worksmobilePrimaryOrgId?: string;
worksmobilePrimaryOrgName?: string;
worksmobilePrimaryOrgPositionId?: string;
worksmobilePrimaryOrgPositionName?: string;
worksmobilePrimaryOrgIsManager?: boolean;
worksmobileParentId?: string;
worksmobileParentName?: string;
status: string;
};
export type WorksmobileComparison = {
users: WorksmobileComparisonItem[];
groups: WorksmobileComparisonItem[];
};
export async function fetchUsers(
limit = 50,
offset = 0,
@@ -693,24 +492,19 @@ export async function createUser(payload: UserCreateRequest) {
return data;
}
export async function exportUsersCSV(
search?: string,
tenantSlug?: string,
includeIds = false,
) {
const response = await apiClient.get<Blob>("/v1/admin/users/export", {
params: { search, tenantSlug, includeIds },
responseType: "blob",
});
const dispositionHeader = response.headers["content-disposition"];
const disposition = Array.isArray(dispositionHeader)
? dispositionHeader[0]
: String(dispositionHeader ?? "");
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/i);
return {
blob: response.data,
filename: filenameMatch?.[1] ?? "users.csv",
};
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
const params = new URLSearchParams();
if (search) params.append("search", search);
if (tenantSlug) params.append("tenantSlug", tenantSlug);
// Get mock role from storage if exists for dev environment
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (isMockRoleEnabled && mockRole) params.append("x-test-role", mockRole);
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
return `${baseUrl}/admin/users/export?${params.toString()}`;
}
export async function bulkCreateUsers(users: BulkUserItem[]) {
@@ -728,87 +522,12 @@ export async function bulkCreateUsers(users: BulkUserItem[]) {
return data;
}
export async function fetchWorksmobileOverview(tenantId: string) {
const { data } = await apiClient.get<WorksmobileOverview>(
`/v1/admin/tenants/${tenantId}/worksmobile`,
);
return data;
}
export async function fetchWorksmobileComparison(
tenantId: string,
includeMatched = false,
) {
const { data } = await apiClient.get<WorksmobileComparison>(
`/v1/admin/tenants/${tenantId}/worksmobile/comparison`,
{
params: { includeMatched },
},
);
return data;
}
export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
const response = await apiClient.get<Blob>(
`/v1/admin/tenants/${tenantId}/worksmobile/initial-passwords.csv`,
{
responseType: "blob",
},
);
const dispositionHeader = response.headers["content-disposition"];
const disposition = Array.isArray(dispositionHeader)
? dispositionHeader[0]
: String(dispositionHeader ?? "");
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/i);
return {
blob: response.data,
filename: filenameMatch?.[1] ?? "worksmobile_initial_passwords.csv",
};
}
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
const { data } = await apiClient.post(
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,
);
return data;
}
export async function enqueueWorksmobileOrgUnitSync(
tenantId: string,
orgUnitId: string,
) {
const { data } = await apiClient.post<WorksmobileOutboxItem>(
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${orgUnitId}/sync`,
);
return data;
}
export async function enqueueWorksmobileUserSync(
tenantId: string,
userId: string,
) {
const { data } = await apiClient.post<WorksmobileOutboxItem>(
`/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`,
);
return data;
}
export async function retryWorksmobileJob(tenantId: string, jobId: string) {
const { data } = await apiClient.post(
`/v1/admin/tenants/${tenantId}/worksmobile/jobs/${jobId}/retry`,
);
return data;
}
export async function bulkUpdateUsers(payload: {
userIds: string[];
status?: string;
role?: string;
tenantSlug?: string;
department?: string;
position?: string;
grade?: string;
jobTitle?: string;
}) {
const requestPayload: typeof payload & { companyCode?: string } = {
...payload,

View File

@@ -1,24 +1,15 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
import {
buildAdminAuthRedirectUris,
resolveAdminPublicOrigin,
} from "./authConfig";
const adminPublicOrigin = resolveAdminPublicOrigin(
import.meta.env.VITE_ADMIN_PUBLIC_URL,
window.location.origin,
);
const adminRedirectUris = buildAdminAuthRedirectUris(adminPublicOrigin);
export const oidcConfig: AuthProviderProps = {
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL
authority:
import.meta.env.VITE_OIDC_AUTHORITY || `${window.location.protocol}//${window.location.hostname}:{{USERFRONT_PORT}}/oidc`,
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
redirect_uri: adminRedirectUris.redirectUri,
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: adminRedirectUris.postLogoutRedirectUri,
popup_redirect_uri: adminRedirectUris.popupRedirectUri,
post_logout_redirect_uri: window.location.origin,
popup_redirect_uri: `${window.location.origin}/auth/callback`,
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false,
};

View File

@@ -1,27 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildAdminAuthRedirectUris,
resolveAdminPublicOrigin,
} from "./authConfig";
describe("admin auth config", () => {
it("uses the explicit public admin origin for staging callback URLs", () => {
const publicOrigin = resolveAdminPublicOrigin(
"https://sadmin.hmac.kr",
"http://127.0.0.1:5173",
);
expect(publicOrigin).toBe("https://sadmin.hmac.kr");
expect(buildAdminAuthRedirectUris(publicOrigin)).toEqual({
redirectUri: "https://sadmin.hmac.kr/auth/callback",
postLogoutRedirectUri: "https://sadmin.hmac.kr",
popupRedirectUri: "https://sadmin.hmac.kr/auth/callback",
});
});
it("falls back to the browser origin when no explicit public origin is set", () => {
expect(resolveAdminPublicOrigin("", "http://localhost:5173")).toBe(
"http://localhost:5173",
);
});
});

View File

@@ -1,33 +0,0 @@
export interface AdminAuthRedirectUris {
redirectUri: string;
postLogoutRedirectUri: string;
popupRedirectUri: string;
}
export const ADMIN_AUTH_CALLBACK_PATH = "/auth/callback";
export function resolveAdminPublicOrigin(
configuredOrigin: string | undefined,
browserOrigin: string,
) {
const trimmed = configuredOrigin?.trim();
if (!trimmed) {
return browserOrigin;
}
try {
return new URL(trimmed).origin;
} catch {
return browserOrigin;
}
}
export function buildAdminAuthRedirectUris(
publicOrigin: string,
): AdminAuthRedirectUris {
return {
redirectUri: `${publicOrigin}${ADMIN_AUTH_CALLBACK_PATH}`,
postLogoutRedirectUri: publicOrigin,
popupRedirectUri: `${publicOrigin}${ADMIN_AUTH_CALLBACK_PATH}`,
};
}

View File

@@ -8,7 +8,7 @@ import {
describe("shouldAttemptSlidingSessionRenew", () => {
const nowMs = 1_700_000_000_000;
it("returns false when remaining time is above the 10 minute threshold", () => {
it("returns false when remaining time is above the 5 minute threshold", () => {
expect(
shouldAttemptSlidingSessionRenew({
expiresAtSec: Math.floor(
@@ -24,7 +24,7 @@ describe("shouldAttemptSlidingSessionRenew", () => {
).toBe(false);
});
it("returns true when remaining time is within the 10 minute threshold", () => {
it("returns true when remaining time is within the 5 minute threshold", () => {
expect(
shouldAttemptSlidingSessionRenew({
expiresAtSec: Math.floor(

View File

@@ -1,4 +1,4 @@
export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000;
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
type SlidingSessionRenewDecisionParams = {

View File

@@ -20,7 +20,7 @@ export function buildTenantFullTree(
tenantMap.set(t.id, {
...t,
children: [],
recursiveMemberCount: Number(t.memberCount) || 0,
recursiveMemberCount: t.memberCount || 0,
});
}
@@ -48,7 +48,7 @@ export function buildTenantFullTree(
}
visitedForCalc.add(node.id);
let total = Number(node.memberCount) || 0;
let total = node.memberCount || 0;
for (const child of node.children) {
total += calculateRecursive(child);
}

View File

@@ -16,7 +16,6 @@ saman = "Saman"
[domain.tenant_type]
company = "Company"
company_group = "Company Group"
organization = "Organization"
personal = "Personal"
user_group = "User Group"
@@ -160,12 +159,13 @@ scope = "Scope"
[msg.admin.org]
hover_member_info = "Hover to see member details."
import_description = "Upload a CSV or XLSX file to create or update organization tenants and users together, then map memberships."
import_description = "Upload a CSV file to bulk register the organization chart."
import_error = "An error occurred during organization chart import."
import_success = "Organization chart imported successfully."
[msg.admin.overview]
description = "Description"
idp_fallback = "Fallback: Descope"
idp_primary = "IDP: Ory primary"
[msg.admin.overview.playbook]
@@ -191,22 +191,11 @@ delete_confirm = "Delete Tenant \"{{name}}\"?"
delete_success = "Tenant deleted."
empty = "Empty"
fetch_error = "Fetch Error"
import_empty = "There are no tenant rows to import."
import_error = "Failed to import tenants."
import_result = "Created {{created}}, updated {{updated}}, failed {{failed}}"
missing_id = "No Tenant ID."
not_found = "Tenant not found."
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
subtitle = "Subtitle"
[msg.admin.tenants.import_preview]
description = "Rows without tenant_id are compared with existing tenant candidates, then imported as new tenants or updates."
[msg.admin.tenants.parent]
local_picker_description = "Select the tenant to use as the parent from the tenant list."
local_picker_empty = "No selectable tenants are available."
picker_description = "Select a tenant in org-chart to apply it as the parent tenant."
[msg.admin.tenants.admins]
add_success = "Add Success"
empty = "Empty"
@@ -222,7 +211,6 @@ remove_success = "Owner permission revoked."
subtitle = "List of owners with top-level permissions for this tenant."
[msg.admin.tenants.create]
pick_parent_first = "Select the parent tenant first."
subtitle = "Subtitle"
[msg.admin.tenants.create.form]
@@ -255,8 +243,6 @@ empty = "Empty"
subtitle = "Subtitle"
[msg.admin.users]
export_error = "Failed to export users."
status_error = "Failed to update user status."
[msg.admin.users.bulk]
delete_confirm = "Are you sure you want to delete the selected {{count}} users?"
@@ -269,13 +255,9 @@ parsed_count = "Parsed {{count}} rows."
update_success = "User info updated successfully."
[msg.admin.users.create]
appointment_required = "Select at least one tenant for Hanmac family members."
error = "Failed to User Create."
external_tenant_required = "Select a primary tenant for external users."
password_required = "Password Required"
personal_tenant_failed = "Failed to prepare the Personal tenant."
success = "User created successfully."
tenant_resolve_failed = "Failed to load the selected tenant information."
[msg.admin.users.create.account]
subtitle = "Subtitle"
@@ -287,7 +269,6 @@ field_required = "{{label}} is required."
name_required = "Name Required"
password_auto_help = "Password Auto Help"
password_manual_help = "Password Manual Help"
picker_description = "Select a tenant in org-chart to apply it to the user's appointment."
role_help = "Role Help"
[msg.admin.users.create.password_generated]
@@ -857,13 +838,12 @@ relying_parties = "Apps (RP)"
tenant_dashboard = "Tenant Dashboard"
user_groups = "User Groups"
tenants = "Tenants"
user_projection = "User Projection"
users = "Users"
[ui.admin.org]
download_template = "Download Template"
import_btn = "Org/User Import"
import_title = "Bulk Org/User Import"
import_btn = "Import"
import_title = "Bulk Organization Import"
start_import = "Start Import"
[ui.admin.overview]
@@ -898,34 +878,10 @@ user = "General User (Tenant Member)"
[ui.admin.tenants]
add = "Add Tenant"
csv_template = "Template"
delete_selected = "Delete Selected"
export_with_ids = "Include UUIDs"
export_without_ids = "Export without UUIDs"
import = "Import"
title = "Tenant Registry"
view_org_chart = "View Full Org Chart"
[ui.admin.tenants.domain_conflict]
description = ""
title = "Domain conflict"
[ui.admin.tenants.import_preview]
candidates = "Candidates"
confirm = "Run import"
create_new_reset = "Create new (reset ID/slug)"
csv_parents = "CSV Parents"
external_id = "External ID"
match = "Match"
no_candidates = "No candidates"
parent = "Parent"
parent_companies = "Parent Companies"
parent_company_groups = "Parent Company Groups"
parent_organizations = "Parent Organizations"
parent_unresolved = "Parent needs review"
slug_exists = "slug conflict"
title = "Confirm CSV import"
[ui.admin.tenants.admins]
add_button = "Add Button"
already_admin = "Already Admin"
@@ -968,20 +924,11 @@ domains_label = "Allowed Domains (Comma separated)"
domains_placeholder = "example.com, example.kr"
name = "Tenant name"
parent = "Parent"
pick_hanmac_parent = "Pick from Hanmac Family"
pick_other_parent = "Pick another tenant"
root_tenant = "Create as top-level tenant"
slug = "Slug"
slug_placeholder = "tenant-slug"
status = "Status"
type = "Type"
[ui.admin.tenants.create.parent_context]
general = "General child tenant"
hanmac = "Hanmac Family child tenant"
pick_required = "Parent tenant selection required"
root = "Top-level tenant"
[ui.admin.tenants.create.memo]
title = "Title"
@@ -1043,17 +990,11 @@ allowed_domains_help = "Users with these email domains will be automatically ass
approve_button = "Approve Tenant"
description = "Description"
name = "Tenant Name"
org_unit_type = "Organization detail type"
slug = "Slug"
status = "Status"
subtitle = "Slug and status changes are applied immediately."
title = "Tenant Profile"
type = "Type"
visibility = "Visibility"
[ui.admin.tenants.parent]
local_search_placeholder = "Search tenant name or slug"
pick_tenant = "Pick tenant"
[ui.admin.tenants.registry]
title = "Tenant registry"
@@ -1065,7 +1006,6 @@ title = "User Schema Extension"
[ui.admin.tenants.schema.field]
admin_only = "Admin Only"
indexed = "Search index"
key = "Field Key (ID)"
key_placeholder = "e.g. employee_id"
label = "Display Label"
@@ -1097,7 +1037,6 @@ status = "STATUS"
[ui.admin.tenants.table]
actions = "ACTIONS"
id = "ID"
members = "Members"
name = "NAME"
slug = "SLUG"
@@ -1108,7 +1047,6 @@ updated = "UPDATED"
[ui.admin.users]
[ui.admin.users.bulk]
create_missing_tenant = "Create new"
do_move = "Execute Move"
download_template = "Download Template"
move_group = "Bulk Tenant Move"
@@ -1117,7 +1055,6 @@ no_department = "No Department"
select_group = "Select Target Tenant"
selected_count = "{{count}} users selected"
start_upload = "Start Upload"
tenant_resolution = "Tenant mapping"
title = "Bulk Actions"
[ui.admin.users.create]
@@ -1144,8 +1081,6 @@ email = "Email"
email_placeholder = "user@example.com"
job_title = "Job Title"
job_title_placeholder = "e.g. Frontend Developer"
grade = "Grade"
grade_placeholder = "e.g. Senior"
name = "Name"
name_placeholder = "Name Placeholder"
password = "Password"
@@ -1153,7 +1088,7 @@ password_placeholder = "********"
phone = "Phone number"
phone_placeholder = "010-1234-5678"
position = "Position"
position_placeholder = "e.g. Team Lead"
position_placeholder = "e.g. Senior"
role = "Role"
tenant = "Tenant"
tenant_global = "Tenant Global"
@@ -1175,8 +1110,6 @@ multi_title = "Per-tenant Profile Management"
[ui.admin.users.detail.form]
department = "Department"
department_placeholder = "Department Placeholder"
grade = "Grade"
grade_placeholder = "e.g. Senior"
name = "Name"
name_placeholder = "Name Placeholder"
phone = "Phone number"
@@ -1185,8 +1118,6 @@ role = "Role"
status = "Status"
tenant = "Representative Affiliated Tenant"
tenant_global = "Tenant Global"
position = "Position"
position_placeholder = "e.g. Team Lead"
[ui.admin.users.detail.security]
password = "Password"
@@ -1209,49 +1140,13 @@ primary = "Representative Affiliated Tenant"
title = "Affiliation & Organization Info"
[ui.admin.users.list]
add = "Add User"
add_to_tenant = "Add to Tenant"
add = "User Add"
bulk_import = "Bulk Import"
empty = "No users found."
fetch_error = "Failed to fetch user list."
search_label = "Search Users"
search_placeholder = "Search by name or email..."
subtitle = "View and manage system users."
toggle_status = "{{name}} active status"
title = "User Management"
[msg.admin.users]
add_tenant_confirm = "Are you sure you want to add '{{name}}' to '{{tenant}}' tenant?"
add_tenant_error = "An error occurred while adding the tenant."
add_tenant_success = "Successfully added to the tenant."
export_error = "Failed to export users."
status_error = "Failed to change user status."
self_delete_blocked = "You cannot delete your own account."
[ui.admin.tenants.members]
add_existing = "Assign Existing Member"
create_new = "Create New Member"
remove = "Exclude from Organization"
title = "Tenant Members ({{count}})"
view_profile = "View Profile"
[ui.admin.tenants.members.table]
actions = "ACTIONS"
email = "EMAIL"
name = "NAME"
role = "ROLE"
status = "STATUS"
[msg.admin.tenants.members]
empty = "No members in this organization."
remove_confirm = "Are you sure you want to exclude '{{name}}' from this organization?"
remove_error = "An error occurred while excluding from organization."
remove_success = "Successfully excluded from organization."
[ui.admin.tenants.list]
search_label = "Search Tenants"
search_placeholder = "Search by name or slug..."
title = "Tenant List"
empty = "Empty"
fetch_error = "Fetch Error"
search_placeholder = "Search Placeholder"
subtitle = "Subtitle"
title = "User Manage"
[ui.admin.users.list.breadcrumb]
list = "List"
@@ -1351,12 +1246,9 @@ active = "Active"
blocked = "Blocked"
failure = "Failure"
inactive = "Inactive"
leave_of_absence = "Leave of absence"
ok = "Ok"
pending = "Pending"
status = "Status"
success = "Success"
suspended = "Suspended"
[test]
key = "Test"

View File

@@ -16,7 +16,6 @@ saman = "삼안"
[domain.tenant_type]
company = "COMPANY (일반 기업)"
company_group = "COMPANY_GROUP (그룹사/지주사)"
organization = "ORGANIZATION (정규 조직)"
personal = "PERSONAL (개인 워크스페이스)"
user_group = "USER_GROUP (내부 부서/팀)"
@@ -160,12 +159,13 @@ scope = "관리 기능은 /admin 네임스페이스에서만 노출합니다."
[msg.admin.org]
hover_member_info = "마우스를 올리면 상세 정보를 확인할 수 있습니다."
import_description = "CSV 또는 XLSX 파일을 업로드하여 조직 테넌트와 사용자를 함께 생성/업데이트하고 멤버십을 매핑합니다. (필수 컬럼: 이메일, 이름)"
import_description = "CSV 또는 XLSX 파일을 업로드하여 조직도를 일괄 등록합니다. (필수 컬럼: 이메일, 이름)"
import_error = "조직도 임포트 중 오류가 발생했습니다."
import_success = "조직도가 성공적으로 임포트되었습니다."
[msg.admin.overview]
description = "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다."
idp_fallback = "Fallback: Descope"
idp_primary = "IDP: Ory primary"
[msg.admin.overview.playbook]
@@ -192,22 +192,11 @@ delete_confirm = "테넌트 \"{{name}}\"를 삭제할까요?"
delete_success = "테넌트가 삭제되었습니다."
empty = "아직 등록된 테넌트가 없습니다."
fetch_error = "테넌트 목록 조회에 실패했습니다."
import_empty = "가져올 테넌트 행이 없습니다."
import_error = "테넌트 가져오기에 실패했습니다."
import_result = "생성 {{created}}, 갱신 {{updated}}, 실패 {{failed}}"
missing_id = "테넌트 ID가 없습니다."
not_found = "테넌트를 찾을 수 없습니다."
remove_sub_confirm = "테넌트 \"{{name}}\"을(를) 하위 조직에서 제외할까요?"
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
[msg.admin.tenants.import_preview]
description = "tenant_id가 없는 행은 기존 테넌트 후보와 비교한 뒤 신규 생성 또는 기존 테넌트 갱신으로 처리합니다."
[msg.admin.tenants.parent]
local_picker_description = "테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다."
local_picker_empty = "선택할 수 있는 테넌트가 없습니다."
picker_description = "org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다."
[msg.admin.tenants.admins]
add_success = "관리자가 추가되었습니다."
empty = "등록된 관리자가 없습니다."
@@ -223,7 +212,6 @@ remove_success = "소유자 권한이 회수되었습니다."
subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다."
[msg.admin.tenants.create]
pick_parent_first = "상위 테넌트를 먼저 선택하세요."
subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다."
[msg.admin.tenants.create.form]
@@ -256,8 +244,6 @@ empty = "하위 테넌트가 없습니다."
subtitle = "현재 테넌트 하위에 생성된 조직입니다."
[msg.admin.users]
export_error = "사용자 내보내기에 실패했습니다."
status_error = "사용자 상태 변경에 실패했습니다."
[msg.admin.users.bulk]
delete_confirm = "선택한 {{count}}명의 사용자를 정말로 삭제하시겠습니까?"
@@ -270,13 +256,9 @@ parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
update_success = "사용자 정보가 일괄 업데이트되었습니다."
[msg.admin.users.create]
appointment_required = "한맥 가족 구성원은 소속 테넌트를 하나 이상 선택해 주세요."
error = "사용자 생성에 실패했습니다."
external_tenant_required = "외부 사용자는 대표소속을 선택해 주세요."
password_required = "비밀번호를 입력하거나 자동 생성을 사용해 주세요."
personal_tenant_failed = "Personal 테넌트를 준비하지 못했습니다."
success = "사용자가 성공적으로 생성되었습니다."
tenant_resolve_failed = "선택한 테넌트 정보를 불러오지 못했습니다."
[msg.admin.users.create.account]
subtitle = "새로운 사용자를 시스템에 등록합니다."
@@ -288,7 +270,6 @@ field_required = "{{label}}은(는) 필수입니다."
name_required = "이름은 필수입니다."
password_auto_help = "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
password_manual_help = "초기 비밀번호를 직접 설정합니다."
picker_description = "org-chart에서 테넌트를 선택하면 사용자 소속에 반영됩니다."
role_help = "시스템 접근 권한을 결정합니다."
[msg.admin.users.create.password_generated]
@@ -859,13 +840,12 @@ relying_parties = "애플리케이션(RP)"
tenant_dashboard = "테넌트 대시보드"
user_groups = "유저 그룹"
tenants = "테넌트"
user_projection = "사용자 Projection"
users = "사용자"
[ui.admin.org]
download_template = "템플릿 다운로드"
import_btn = "조직/사용자 통합 임포트"
import_title = "조직/사용자 통합 일괄 등록"
import_btn = "임포트"
import_title = "조직도 대량 등록"
start_import = "임포트 시작"
[ui.admin.overview]
@@ -900,34 +880,10 @@ user = "일반 사용자 (Tenant Member)"
[ui.admin.tenants]
add = "테넌트 추가"
csv_template = "템플릿"
delete_selected = "선택 삭제"
export_with_ids = "UUID 포함"
export_without_ids = "UUID 제외 내보내기"
import = "가져오기"
title = "테넌트 목록"
view_org_chart = "전체 조직도 보기"
[ui.admin.tenants.domain_conflict]
description = ""
title = "도메인 충돌"
[ui.admin.tenants.import_preview]
candidates = "후보"
confirm = "가져오기 실행"
create_new_reset = "신규 생성 (ID/slug 재설정)"
csv_parents = "CSV 상위 테넌트"
external_id = "외부 ID"
match = "매칭"
no_candidates = "후보 없음"
parent = "상위"
parent_companies = "상위 회사"
parent_company_groups = "상위 그룹사"
parent_organizations = "상위 조직"
parent_unresolved = "부모 확인 필요"
slug_exists = "slug 충돌"
title = "CSV 가져오기 확인"
[ui.admin.tenants.admins]
add_button = "관리자 추가"
already_admin = "이미 관리자"
@@ -970,20 +926,11 @@ domains_label = "Allowed Domains (Comma separated)"
domains_placeholder = "example.com, example.kr"
name = "테넌트 이름"
parent = "상위 테넌트"
pick_hanmac_parent = "한맥가족에서 선택"
pick_other_parent = "다른 테넌트 선택"
root_tenant = "최상위 테넌트로 생성"
slug = "Slug"
slug_placeholder = "tenant-slug"
status = "상태"
type = "유형"
[ui.admin.tenants.create.parent_context]
general = "일반 하위 테넌트"
hanmac = "한맥가족 하위 테넌트"
pick_required = "상위 테넌트 선택 필요"
root = "최상위 테넌트"
[ui.admin.tenants.create.memo]
title = "정책 메모"
@@ -1045,17 +992,11 @@ allowed_domains_help = "이 도메인을 가진 이메일로 가입한 사용자
approve_button = "테넌트 승인"
description = "설명"
name = "테넌트 이름"
org_unit_type = "조직 세부타입"
slug = "슬러그 (Slug)"
status = "상태"
subtitle = "슬러그 및 상태 변경은 즉시 적용됩니다."
title = "테넌트 프로필"
type = "테넌트 유형"
visibility = "공개 범위"
[ui.admin.tenants.parent]
local_search_placeholder = "테넌트 이름 또는 슬러그 검색"
pick_tenant = "테넌트 선택"
[ui.admin.tenants.registry]
title = "Tenant registry"
@@ -1067,7 +1008,6 @@ title = "User Schema Extension"
[ui.admin.tenants.schema.field]
admin_only = "관리자 전용"
indexed = "조회 인덱스"
key = "Field Key (ID)"
key_placeholder = "e.g. employee_id"
label = "표시 레이블"
@@ -1099,7 +1039,6 @@ status = "STATUS"
[ui.admin.tenants.table]
actions = "ACTIONS"
id = "ID"
members = "멤버수"
name = "NAME"
slug = "SLUG"
@@ -1110,7 +1049,6 @@ updated = "UPDATED"
[ui.admin.users]
[ui.admin.users.bulk]
create_missing_tenant = "신규 생성"
do_move = "이동 실행"
download_template = "템플릿 받기"
move_group = "테넌트 일괄 이동"
@@ -1119,7 +1057,6 @@ no_department = "부서 없음"
select_group = "대상 테넌트 선택"
selected_count = "{{count}}명 선택됨"
start_upload = "업로드 시작"
tenant_resolution = "테넌트 매핑"
title = "일괄 작업"
[ui.admin.users.create]
@@ -1146,16 +1083,14 @@ email = "이메일"
email_placeholder = "user@example.com"
job_title = "직무"
job_title_placeholder = "프론트엔드 개발"
grade = "직급"
grade_placeholder = "수석/책임/선임"
name = "이름"
name_placeholder = "홍길동"
password = "비밀번호"
password_placeholder = "********"
phone = "전화번호"
phone_placeholder = "010-1234-5678"
position = "직"
position_placeholder = "팀장/센터장"
position = "직"
position_placeholder = "수석/책임/선임"
role = "역할"
tenant = "테넌트"
tenant_global = "시스템 전역"
@@ -1177,8 +1112,6 @@ multi_title = "테넌트별 프로필 관리"
[ui.admin.users.detail.form]
department = "부서"
department_placeholder = "개발팀"
grade = "직급"
grade_placeholder = "수석/책임/선임"
name = "이름"
name_placeholder = "홍길동"
phone = "전화번호"
@@ -1187,8 +1120,6 @@ role = "역할"
status = "상태"
tenant = "대표 소속 테넌트"
tenant_global = "시스템 전역"
position = "직책"
position_placeholder = "팀장/센터장"
[ui.admin.users.detail.security]
password = "비밀번호 변경"
@@ -1212,49 +1143,13 @@ title = "소속 및 조직 정보"
[ui.admin.users.list]
add = "사용자 추가"
add_to_tenant = "테넌트에 추가"
bulk_import = "일괄 임포트"
empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다."
search_label = "사용자 검색"
search_placeholder = "이름 또는 이메일 검색..."
subtitle = "시스템 사용자를 조회하고 관리합니다."
toggle_status = "{{name}} 활성 상태"
title = "사용자 관리"
[msg.admin.users]
add_tenant_confirm = "'{{name}}'님을 '{{tenant}}' 테넌트에 추가하시겠습니까?"
add_tenant_error = "테넌트 추가 중 오류가 발생했습니다."
add_tenant_success = "해당 테넌트에 추가되었습니다."
export_error = "사용자 내보내기에 실패했습니다."
status_error = "사용자 상태 변경에 실패했습니다."
self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
[ui.admin.tenants.members]
add_existing = "기존 멤버 배정"
create_new = "신규 멤버 생성"
remove = "조직에서 제외"
title = "테넌트 멤버 ({{count}})"
view_profile = "상세 정보"
[ui.admin.tenants.members.table]
actions = "ACTIONS"
email = "EMAIL"
name = "NAME"
role = "ROLE"
status = "STATUS"
[msg.admin.tenants.members]
empty = "소속된 사용자가 없습니다."
remove_confirm = "'{{name}}'님을 이 조직에서 제외하시겠습니까?"
remove_error = "조직에서 제외하는 중 오류가 발생했습니다."
remove_success = "조직에서 제외되었습니다."
[ui.admin.tenants.list]
search_label = "테넌트 검색"
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
title = "테넌트 목록"
[ui.admin.users.list.breadcrumb]
list = "List"
section = "Users"
@@ -1353,12 +1248,9 @@ active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "비활성"
leave_of_absence = "휴직"
ok = "정상"
pending = "준비 중"
status = "상태"
success = "성공"
suspended = "정지"
[test]
key = "테스트"

View File

@@ -16,7 +16,6 @@ saman = ""
[domain.tenant_type]
company = ""
company_group = ""
organization = ""
personal = ""
user_group = ""
@@ -131,17 +130,12 @@ empty = ""
import_error = ""
import_success = ""
loading = ""
no_results = ""
subtitle = ""
[msg.admin.groups.members]
add_modal_desc = ""
add_success = ""
all_added = ""
count = ""
empty = ""
move_modal_desc = ""
move_success = ""
remove_confirm = ""
remove_success = ""
title = ""
@@ -197,22 +191,11 @@ delete_confirm = ""
delete_success = ""
empty = ""
fetch_error = ""
import_empty = ""
import_error = ""
import_result = ""
missing_id = ""
not_found = ""
remove_sub_confirm = ""
subtitle = ""
[msg.admin.tenants.import_preview]
description = ""
[msg.admin.tenants.parent]
local_picker_description = ""
local_picker_empty = ""
picker_description = ""
[msg.admin.tenants.admins]
add_success = ""
empty = ""
@@ -228,7 +211,6 @@ remove_success = ""
subtitle = ""
[msg.admin.tenants.create]
pick_parent_first = ""
subtitle = ""
[msg.admin.tenants.create.form]
@@ -245,9 +227,6 @@ subtitle = ""
desc = ""
empty = ""
limit_notice = ""
remove_confirm = ""
remove_error = ""
remove_success = ""
[msg.admin.tenants.registry]
count = ""
@@ -264,9 +243,6 @@ empty = ""
subtitle = ""
[msg.admin.users]
confirm_remove_org = ""
export_error = ""
status_error = ""
[msg.admin.users.bulk]
delete_confirm = ""
@@ -279,13 +255,9 @@ parsed_count = ""
update_success = ""
[msg.admin.users.create]
appointment_required = ""
error = ""
external_tenant_required = ""
password_required = ""
personal_tenant_failed = ""
success = ""
tenant_resolve_failed = ""
[msg.admin.users.create.account]
subtitle = ""
@@ -297,7 +269,6 @@ field_required = ""
name_required = ""
password_auto_help = ""
password_manual_help = ""
picker_description = ""
role_help = ""
[msg.admin.users.create.password_generated]
@@ -842,11 +813,8 @@ unit_level_placeholder = ""
title = ""
[ui.admin.groups.members]
add_modal_title = ""
move_modal_title = ""
[ui.admin.groups.members.table]
actions = ""
email = ""
name = ""
remove = ""
@@ -871,7 +839,6 @@ relying_parties = ""
tenant_dashboard = ""
user_groups = ""
tenants = ""
user_projection = ""
users = ""
[ui.admin.org]
@@ -912,36 +879,10 @@ user = ""
[ui.admin.tenants]
add = ""
csv_template = ""
delete_selected = ""
export_with_ids = ""
export_without_ids = ""
import = ""
title = ""
view.hierarchy = ""
view.list = ""
view_org_chart = ""
[ui.admin.tenants.domain_conflict]
description = ""
title = ""
[ui.admin.tenants.import_preview]
candidates = ""
confirm = ""
create_new_reset = ""
csv_parents = ""
external_id = ""
match = ""
no_candidates = ""
parent = ""
parent_companies = ""
parent_company_groups = ""
parent_organizations = ""
parent_unresolved = ""
slug_exists = ""
title = ""
[ui.admin.tenants.admins]
add_button = ""
already_admin = ""
@@ -984,20 +925,11 @@ domains_label = ""
domains_placeholder = ""
name = ""
parent = ""
pick_hanmac_parent = ""
pick_other_parent = ""
root_tenant = ""
slug = ""
slug_placeholder = ""
status = ""
type = ""
[ui.admin.tenants.create.parent_context]
general = ""
hanmac = ""
pick_required = ""
root = ""
[ui.admin.tenants.create.memo]
title = ""
@@ -1020,12 +952,8 @@ search_placeholder = ""
select_placeholder = ""
[ui.admin.tenants.members]
add_existing = ""
create_new = ""
descendants = ""
direct = ""
remove = ""
view_profile = ""
[msg.admin.apikeys.registry]
count = ""
@@ -1052,7 +980,6 @@ total = ""
total_label = ""
[ui.admin.tenants.members.table]
actions = ""
email = ""
name = ""
role = ""
@@ -1064,17 +991,11 @@ allowed_domains_help = ""
approve_button = ""
description = ""
name = ""
org_unit_type = ""
slug = ""
status = ""
subtitle = ""
title = ""
type = ""
visibility = ""
[ui.admin.tenants.parent]
local_search_placeholder = ""
pick_tenant = ""
[ui.admin.tenants.registry]
title = ""
@@ -1086,7 +1007,6 @@ title = ""
[ui.admin.tenants.schema.field]
admin_only = ""
indexed = ""
key = ""
key_placeholder = ""
label = ""
@@ -1118,7 +1038,6 @@ status = ""
[ui.admin.tenants.table]
actions = ""
id = ""
members = ""
name = ""
slug = ""
@@ -1129,7 +1048,6 @@ updated = ""
[ui.admin.users]
[ui.admin.users.bulk]
create_missing_tenant = ""
do_move = ""
download_template = ""
move_group = ""
@@ -1138,7 +1056,6 @@ no_department = ""
select_group = ""
selected_count = ""
start_upload = ""
tenant_resolution = ""
title = ""
[ui.admin.users.create]
@@ -1230,7 +1147,6 @@ empty = ""
fetch_error = ""
search_placeholder = ""
subtitle = ""
toggle_status = ""
title = ""
[ui.admin.users.list.breadcrumb]
@@ -1331,12 +1247,9 @@ active = ""
blocked = ""
failure = ""
inactive = ""
leave_of_absence = ""
ok = ""
pending = ""
status = ""
success = ""
suspended = ""
[test]
key = ""

View File

@@ -100,16 +100,6 @@ test.describe("Authentication", () => {
);
});
test("should link org chart navigation through the auto login entry", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByRole("link", { name: "조직도" })).toHaveAttribute(
"href",
"http://localhost:5175/login?auto=1&returnTo=%2Fchart",
);
});
test("should logout and redirect to login page", async ({ page }) => {
await page.goto("/");
page.on("dialog", (dialog) => dialog.accept());

View File

@@ -1,158 +0,0 @@
import { expect, test } from "@playwright/test";
test.describe("Tenant Allowed Domains", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
window.localStorage.setItem("RoleSwitcher-Collapsed", "true");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
}),
);
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});
test("adds samaneng.com to the current tenant after duplicate warning confirmation", async ({
page,
}) => {
let savedPayload:
| {
domains?: string[];
forceDomainConflicts?: string[];
}
| undefined;
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/tenants/current") && method === "GET") {
return route.fulfill({
json: {
id: "current",
name: "현재 테넌트",
slug: "current",
type: "COMPANY",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
headers,
});
}
if (url.includes("/admin/tenants/current") && method === "PUT") {
savedPayload = route.request().postDataJSON();
return route.fulfill({
json: {
id: "current",
name: "현재 테넌트",
slug: "current",
type: "COMPANY",
description: "",
status: "active",
domains: savedPayload?.domains ?? [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
headers,
});
}
if (url.includes("/admin/tenants")) {
return route.fulfill({
json: {
items: [
{
id: "current",
name: "현재 테넌트",
slug: "current",
type: "COMPANY",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "existing",
name: "한맥가족",
slug: "hanmac-family",
type: "COMPANY",
description: "",
status: "active",
domains: ["samaneng.com"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
],
total: 2,
limit: 1000,
offset: 0,
},
headers,
});
}
return route.fulfill({ json: {}, headers });
});
await page.goto("/tenants/current");
await page.locator("#tenant-domains").fill("samaneng.com");
await page.keyboard.press("Space");
await expect(
page.getByText(
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
),
).toBeVisible();
await page.getByRole("button", { name: "계속 진행" }).click();
await expect(page.getByText("samaneng.com")).toBeVisible();
await page.getByRole("button", { name: "저장" }).click();
await expect
.poll(() => savedPayload)
.toMatchObject({
domains: ["samaneng.com"],
forceDomainConflicts: ["samaneng.com"],
});
});
});

View File

@@ -1,124 +0,0 @@
import { expect, test } from "@playwright/test";
test.describe("Tenant Schema Management", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
window.localStorage.setItem("RoleSwitcher-Collapsed", "true");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
};
window.localStorage.setItem(key, JSON.stringify(authData));
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});
test("should force indexed when schema field is used as login ID", async ({
page,
}) => {
let savedConfig: Record<string, unknown> | undefined;
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/tenants/t-1") && method === "GET") {
return route.fulfill({
json: {
id: "t-1",
name: "Test Tenant",
slug: "test-tenant",
type: "COMPANY",
status: "active",
config: { userSchema: [] },
},
headers,
});
}
if (url.includes("/admin/tenants/t-1") && method === "PUT") {
const payload = route.request().postDataJSON() as {
config?: Record<string, unknown>;
};
savedConfig = payload.config;
return route.fulfill({
json: {
id: "t-1",
name: "Test Tenant",
slug: "test-tenant",
type: "COMPANY",
status: "active",
config: savedConfig,
},
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.goto("/tenants/t-1/schema");
await expect(
page.getByText(/사용자 스키마 확장|User Schema Extension/),
).toBeVisible();
await page.getByRole("button", { name: /필드 추가/ }).click();
await page
.getByPlaceholder(/예: employee_id|e\.g\. employee_id/)
.fill("emp_no");
await page.getByPlaceholder("예: 사번").fill("사번");
const indexedCheckbox = page.getByLabel(/검색 인덱스 필요|조회 인덱스/);
await expect(indexedCheckbox).not.toBeChecked();
await expect(indexedCheckbox).toBeEnabled();
await page.getByLabel("로그인 ID로 사용").check();
await expect(indexedCheckbox).toBeChecked();
await expect(indexedCheckbox).toBeDisabled();
await page
.getByRole("button", { name: /변경사항 저장|스키마 저장|Save/ })
.click();
await expect
.poll(() => savedConfig)
.toMatchObject({
userSchema: [
{
key: "emp_no",
label: "사번",
type: "text",
indexed: true,
isLoginId: true,
},
],
});
});
});

View File

@@ -1,115 +0,0 @@
import { expect, test } from "@playwright/test";
const tenants = [
{
id: "seed-hanmac",
name: "한맥가족",
slug: "hanmac-family",
type: "COMPANY_GROUP",
description: "한맥가족 기본 루트 테넌트",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "normal-tenant",
name: "일반 테넌트",
slug: "normal-tenant",
type: "COMPANY",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
test.describe("Seed tenant protection", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
window.localStorage.setItem("RoleSwitcher-Collapsed", "true");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
}),
);
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/tenants/seed-hanmac")) {
return route.fulfill({ json: tenants[0], headers });
}
if (url.includes("/admin/tenants")) {
return route.fulfill({
json: {
items: tenants,
total: tenants.length,
limit: 1000,
offset: 0,
},
headers,
});
}
return route.fulfill({ json: {}, headers });
});
});
test("removes selection and disables delete action for seed tenants in the list", async ({
page,
}) => {
await page.goto("/tenants");
const seedRow = page.getByRole("row", { name: /한맥가족/ });
await expect(seedRow.getByRole("checkbox")).toHaveCount(0);
await expect(seedRow.getByText("초기 설정")).toBeVisible();
await expect(seedRow.getByRole("button", { name: /삭제/ })).toBeDisabled();
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
await expect(normalRow.getByRole("button", { name: /삭제/ })).toBeEnabled();
});
test("disables delete action on seed tenant profile", async ({ page }) => {
await page.goto("/tenants/seed-hanmac");
await expect(page.getByRole("heading", { name: "한맥가족" })).toBeVisible();
await expect(page.getByRole("button", { name: "삭제" })).toBeDisabled();
});
});

View File

@@ -57,16 +57,13 @@ test.describe("Tenants Management", () => {
});
test("should list tenants", async ({ page }) => {
await page.setViewportSize({ width: 900, height: 700 });
const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea";
await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({
json: {
items: [
{
id: internalTenantId,
id: "1",
name: "Tenant A",
slug: "tenant-a",
status: "active",
@@ -93,16 +90,6 @@ test.describe("Tenants Management", () => {
await expect(page.locator("table")).toContainText("Tenant A", {
timeout: 10000,
});
await expect(page.locator("table")).toContainText(internalTenantId);
await expect(page.locator("table")).toContainText("COMPANY");
await expect(page.locator("table")).not.toContainText("일반 기업");
const headerWhiteSpace = await page
.locator("table thead th")
.evaluateAll((headers) =>
headers.map((header) => window.getComputedStyle(header).whiteSpace),
);
expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true);
});
test("should create a new tenant", async ({ page }) => {
@@ -110,7 +97,6 @@ test.describe("Tenants Management", () => {
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
await page.getByRole("button", { name: "최상위 테넌트로 생성" }).click();
const nameInput = page.locator('input[name="name"]').first();
await nameInput.fill("New Tenant");
@@ -120,421 +106,12 @@ test.describe("Tenants Management", () => {
await page.locator("textarea").first().fill("Description");
const submitBtn = page.getByRole("button", { name: /^생성$/ });
await submitBtn.click();
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
});
test("should ask for parent tenant before tenant details", async ({
page,
}) => {
const tenants = [
{
id: "family-1",
name: "한맥가족",
slug: "hanmac-family",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
parentId: null,
},
{
id: "company-1",
name: "삼안",
slug: "saman",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: "family-1",
},
{
id: "outside-1",
name: "외부회사",
slug: "outside",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: null,
},
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
const headers = { "Access-Control-Allow-Origin": "*" };
return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
headers,
});
});
await page.goto("/tenants/new");
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
await expect(
page.getByRole("button", { name: "한맥가족에서 선택" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "다른 테넌트 선택" }),
).toBeVisible();
const parentLabelTop = await page
.getByText(/상위 테넌트/)
.first()
.evaluate((element) => element.getBoundingClientRect().top);
const rootButtonTop = await page
.getByRole("button", { name: "최상위 테넌트로 생성" })
.evaluate((element) => element.getBoundingClientRect().top);
expect(Math.abs(parentLabelTop - rootButtonTop)).toBeLessThan(10);
await expect(page.locator('input[name="name"]')).toHaveCount(0);
await page.getByRole("button", { name: "다른 테넌트 선택" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.getByPlaceholder("테넌트 이름 또는 슬러그 검색").fill("outside");
await page.getByRole("button", { name: /외부회사/ }).click();
await expect(
page
.getByTestId("tenant-parent-picker-slot")
.getByText("outside · COMPANY"),
).toBeVisible();
await expect(page.getByText("일반 하위 테넌트")).toBeVisible();
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(page.getByLabel("조직 세부타입")).toHaveCount(0);
await expect(page.getByLabel("공개 범위")).toHaveCount(0);
await page
.getByTestId("tenant-parent-picker-slot")
.getByRole("button", { name: "한맥가족에서 선택" })
.click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.evaluate(() => {
window.postMessage(
{
type: "orgfront:picker:confirm",
payload: {
selections: [{ type: "tenant", id: "family-1", name: "한맥가족" }],
},
},
window.location.origin,
);
});
await expect(page.getByText("hanmac-family · COMPANY_GROUP")).toBeVisible();
await expect(page.getByText("한맥가족 하위 테넌트")).toBeVisible();
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(page.getByLabel("조직 세부타입")).toBeVisible();
await expect(page.getByLabel("공개 범위")).toBeVisible();
});
test("should create a hanmac-family child tenant with org config", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 800 });
let createBody = "";
const tenants = [
{
id: "family-1",
name: "한맥가족",
slug: "hanmac-family",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
parentId: null,
},
{
id: "company-1",
name: "삼안",
slug: "saman",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: "family-1",
},
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (method === "GET") {
return route.fulfill({
json: {
items: tenants,
total: tenants.length,
limit: 1000,
offset: 0,
},
headers,
});
}
if (method === "POST") {
createBody = route.request().postData() ?? "";
return route.fulfill({
json: { id: "created-tenant-id", name: "신규 센터" },
headers,
});
}
return route.fulfill({ json: {}, headers });
});
await page.goto("/tenants/new");
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
await page.getByRole("button", { name: "한맥가족에서 선택" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.evaluate(() => {
window.postMessage(
{
type: "orgfront:picker:confirm",
payload: {
selections: [{ type: "tenant", id: "family-1", name: "한맥가족" }],
},
},
window.location.origin,
);
});
await expect(page.getByText("hanmac-family · COMPANY_GROUP")).toBeVisible();
await expect(page.getByLabel("조직 세부타입")).toBeVisible();
await expect(page.getByLabel("공개 범위")).toBeVisible();
const layout = page.getByTestId("tenant-parent-org-config-layout");
const parentWidth = await page
.getByTestId("tenant-parent-picker-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const orgUnitWidth = await page
.getByTestId("tenant-org-unit-type-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const visibilityWidth = await page
.getByTestId("tenant-visibility-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const columns = await layout.evaluate(
(element) => window.getComputedStyle(element).gridTemplateColumns,
);
expect(columns.split(" ").length).toBe(4);
expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7);
expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3);
expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8);
await page.locator('input[name="name"]').first().fill("신규 센터");
await page.locator('input[name="slug"]').first().fill("new-center");
await page.getByLabel("조직 세부타입").selectOption("센터");
await page.getByLabel("공개 범위").selectOption("internal");
await page
const submitBtn = page
.locator("button")
.filter({ hasText: /생성|Create/i })
.first()
.click();
.first();
await submitBtn.click();
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
expect(JSON.parse(createBody)).toMatchObject({
parentId: "family-1",
config: {
orgUnitType: "센터",
visibility: "internal",
},
});
});
test("should export and import tenant CSV without organization/user combined import", async ({
page,
browserName,
}, testInfo) => {
let exportRequested = false;
let exportUrl = "";
let importRequested = false;
let importBody = "";
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/export")) {
exportRequested = true;
exportUrl = url;
return route.fulfill({
body: "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n",
contentType: "text/csv",
headers: {
...headers,
"Content-Disposition": 'attachment; filename="tenants.csv"',
},
});
}
if (url.includes("/import")) {
importRequested = true;
importBody = route.request().postData() ?? "";
return route.fulfill({
json: { created: 0, updated: 1, failed: 0, errors: [] },
headers,
});
}
if (method === "GET") {
return route.fulfill({
json: {
items: [
{
id: "tenant-alpha-id",
name: "Tenant Alpha",
slug: "tenant-alpha",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 1,
limit: 1000,
offset: 0,
},
headers,
});
}
return route.continue();
});
await page.goto("/tenants");
await expect(page.locator("h2").last()).toContainText(
/테넌트 목록|Tenants/i,
{ timeout: 20000 },
);
await expect(page.getByText(/조직\/사용자 통합/)).toHaveCount(0);
await expect(page.getByTestId("tenant-template-btn")).toBeVisible();
await expect(page.getByTestId("tenant-export-btn")).toBeVisible();
await expect(page.getByTestId("tenant-import-btn")).toBeVisible();
const download = page.waitForEvent("download");
await page.getByTestId("tenant-export-btn").click();
await download;
expect(exportRequested).toBe(true);
expect(exportUrl).toContain("includeIds=false");
await page.getByTestId("tenant-import-input").setInputFiles({
name: "tenants.csv",
mimeType: "text/csv",
buffer: Buffer.from(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Tenant Alpha,COMPANY,,tenant-alpha-copy,Imported memo,imported.example.com\n",
),
});
await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인");
await expect(page.getByTestId("tenant-import-candidate")).toContainText(
"Tenant Alpha",
);
await page.getByTestId("tenant-import-confirm-btn").click();
await expect(page.getByTestId("tenant-import-result")).toContainText(
/갱신 1|Updated 1/i,
);
expect(importRequested).toBe(true);
expect(importBody).toContain('filename="tenants.csv"');
if (browserName !== "webkit") {
if (testInfo.project.name !== "webkit") {
expect(importBody).toContain("tenant-alpha-id");
}
}
});
test("should resolve tenant CSV conflicts by choosing create and remapping parent ids", async ({
page,
}, testInfo) => {
let importBody = "";
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/import")) {
importBody = route.request().postData() ?? "";
return route.fulfill({
json: { created: 2, updated: 0, failed: 0, errors: [] },
headers,
});
}
if (method === "GET") {
return route.fulfill({
json: {
items: [
{
id: "staging-existing-id",
name: "Existing Parent",
slug: "parent-local",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 1,
limit: 1000,
offset: 0,
},
headers,
});
}
return route.continue();
});
await page.goto("/tenants");
await expect(page.locator("h2").last()).toContainText(
/테넌트 목록|Tenants/i,
{ timeout: 20000 },
);
await page.getByTestId("tenant-import-input").setInputFiles({
name: "tenants.csv",
mimeType: "text/csv",
buffer: Buffer.from(
[
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain",
"local-parent-id,Parent Tenant,COMPANY,,parent-local,,",
"local-child-id,Child Tenant,USER_GROUP,local-parent-id,child-local,,",
].join("\n"),
),
});
await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인");
await page
.getByTestId("tenant-import-match-select-2")
.selectOption("__create__");
await page
.getByTestId("tenant-import-create-slug-2")
.fill("parent-created");
await page
.getByTestId("tenant-import-match-select-3")
.selectOption("__create__");
await page.getByTestId("tenant-import-create-slug-3").fill("child-created");
await page.getByTestId("tenant-import-confirm-btn").click();
await expect(page.getByTestId("tenant-import-result")).toContainText(
/생성 2|Created 2/i,
);
if (testInfo.project.name === "webkit") {
expect(importBody).not.toContain("local-parent-id");
expect(importBody).not.toContain("local-child-id");
return;
}
expect(importBody).not.toContain("local-parent-id");
expect(importBody).not.toContain("local-child-id");
const parentMatch = importBody.match(
/([0-9a-f-]{36}),Parent Tenant,COMPANY,,,parent-created/,
);
expect(parentMatch?.[1]).toBeTruthy();
expect(importBody).toContain(
`,Child Tenant,USER_GROUP,${parentMatch?.[1]},parent-created,child-created`,
);
});
test("should show validation error on empty name", async ({ page }) => {
@@ -542,9 +119,11 @@ test.describe("Tenants Management", () => {
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
await page.getByRole("button", { name: "최상위 테넌트로 생성" }).click();
const submitBtn = page.getByRole("button", { name: /^생성$/ });
const submitBtn = page
.locator("button")
.filter({ hasText: /생성|Create/i })
.first();
await expect(submitBtn).toBeDisabled();
await page.locator('input[name="name"]').first().fill("Valid Name");
@@ -615,120 +194,4 @@ test.describe("Tenants Management", () => {
.first(),
).toBeVisible();
});
test("should show tenant UUID at the top of tenant detail profile", async ({
page,
}) => {
const tenantUuid = "11111111-2222-4333-8444-555555555555";
const tenant = {
id: tenantUuid,
name: "Tenant With UUID",
slug: "tenant-with-uuid",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: null,
config: {},
domains: [],
updatedAt: new Date().toISOString(),
};
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes(`/admin/tenants/${tenantUuid}`)) {
return route.fulfill({ json: tenant, headers });
}
return route.fulfill({
json: { items: [tenant], total: 1, limit: 1000, offset: 0 },
headers,
});
});
await page.goto(`/tenants/${tenantUuid}`);
const titleRow = page.getByTestId("tenant-detail-title-row");
await expect(titleRow).toBeVisible({ timeout: 20000 });
await expect(titleRow).toContainText("Tenant With UUID");
await expect(titleRow).toContainText(tenantUuid);
await expect(titleRow).not.toContainText("Tenant UUID");
await expect(page.getByTestId("tenant-detail-copy-uuid")).toBeVisible();
});
test("should place hanmac org config beside parent tenant picker", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 800 });
const tenants = [
{
id: "family-1",
name: "한맥가족",
slug: "hanmac-family",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
parentId: null,
config: {},
},
{
id: "company-1",
name: "삼안",
slug: "saman",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: "family-1",
config: {},
},
{
id: "team-1",
name: "기획팀",
slug: "planning",
status: "active",
type: "USER_GROUP",
memberCount: 0,
parentId: "company-1",
config: { orgUnitType: "팀", visibility: "internal" },
},
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/admin/tenants/team-1")) {
return route.fulfill({ json: tenants[2], headers });
}
return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
headers,
});
});
await page.goto("/tenants/team-1");
const layout = page.getByTestId("tenant-parent-org-config-layout");
await expect(layout).toBeVisible({ timeout: 20000 });
await expect(layout).toContainText("상위 테넌트");
await expect(layout).toContainText("조직 세부타입");
await expect(layout).toContainText("공개 범위");
const columns = await layout.evaluate(
(element) => window.getComputedStyle(element).gridTemplateColumns,
);
expect(columns.split(" ").length).toBe(4);
const parentWidth = await page
.getByTestId("tenant-parent-picker-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const orgUnitWidth = await page
.getByTestId("tenant-org-unit-type-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const visibilityWidth = await page
.getByTestId("tenant-visibility-slot")
.evaluate((element) => element.getBoundingClientRect().width);
expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7);
expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3);
expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8);
});
});

View File

@@ -1,120 +0,0 @@
import { expect, test } from "@playwright/test";
const liveE2E = process.env.LIVE_BACKEND_E2E === "1";
const oidcAuthority = "https://sso.hmac.kr/oidc";
const clientId = "adminfront";
test.describe("Tenants CSV live E2E", () => {
test.skip(!liveE2E, "Set LIVE_BACKEND_E2E=1 to run against a live backend.");
test.beforeEach(async ({ page, baseURL }) => {
await page.addInitScript(
({ authority, client_id }) => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
window.localStorage.setItem("X-Mock-Role", "super_admin");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "live-e2e-placeholder-token",
token_type: "Bearer",
profile: {
sub: "live-e2e-admin",
name: "Live E2E Admin",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
);
},
{ authority: oidcAuthority, client_id: clientId },
);
await page.route("**/api/v1/**", async (route) => {
const requestUrl = new URL(route.request().url());
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
const { authorization: _authorization, ...headers } = route
.request()
.headers();
headers["x-test-role"] = "super_admin";
const response = await route.fetch({ url: liveUrl, headers });
await route.fulfill({ response });
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({
json: {
issuer: oidcAuthority,
authorization_endpoint: `${oidcAuthority}/auth`,
token_endpoint: `${oidcAuthority}/token`,
jwks_uri: `${oidcAuthority}/jwks`,
userinfo_endpoint: `${oidcAuthority}/userinfo`,
end_session_endpoint: `${oidcAuthority}/session/end`,
},
});
});
});
test("exports and imports tenant CSV through the admin UI", async ({
page,
baseURL,
}) => {
const slug = `csv-live-${Date.now()}`;
let tenantId = "";
await page.goto("/tenants");
await expect(page.locator("h2").last()).toContainText(
/테넌트 목록|Tenants/i,
);
await expect(page.getByTestId("tenant-export-btn")).toBeVisible();
await expect(page.getByTestId("tenant-import-btn")).toBeVisible();
const download = page.waitForEvent("download");
await page.getByTestId("tenant-export-btn").click();
const exported = await download;
expect(exported.suggestedFilename()).toContain("tenants");
await page.getByTestId("tenant-import-input").setInputFiles({
name: "tenants.csv",
mimeType: "text/csv",
buffer: Buffer.from(
`tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Live CSV Tenant,COMPANY,,${slug},Live E2E import,${slug}.example.com\n`,
),
});
await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인");
await page.getByTestId("tenant-import-confirm-btn").click();
await expect(page.getByTestId("tenant-import-result")).toContainText(
/생성 1|Created 1/i,
);
const listResponse = await page.request.get(
`${baseURL}/api/v1/admin/tenants?limit=1000&offset=0`,
{ headers: { "X-Test-Role": "super_admin" } },
);
expect(listResponse.ok()).toBeTruthy();
const list = await listResponse.json();
const imported = list.items.find(
(tenant: { slug: string }) => tenant.slug === slug,
);
expect(imported).toMatchObject({
name: "Live CSV Tenant",
slug,
description: "Live E2E import",
domains: [`${slug}.example.com`],
});
tenantId = imported.id;
const deleteResponse = await page.request.delete(
`${baseURL}/api/v1/admin/tenants/${tenantId}`,
{ headers: { "X-Test-Role": "super_admin" } },
);
expect(deleteResponse.status()).toBe(204);
});
});

View File

@@ -79,73 +79,14 @@ test.describe("User Management", () => {
],
},
},
{
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
slug: "tech-planning",
name: "기술기획",
type: "USER_GROUP",
parentId: "hanmac-company-id",
config: {},
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
config: {},
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
config: {},
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
type: "USER_GROUP",
parentId: "hanmac-company-id",
config: {},
},
{
id: "system-id",
slug: "system",
name: "System Tenant",
type: "SYSTEM",
config: {},
},
{
id: "external-tenant-id",
slug: "external-tenant",
name: "External Tenant",
type: "COMPANY",
config: {},
},
],
total: 7,
total: 1,
limit: 100,
offset: 0,
},
});
}
if (url.match(/\/admin\/tenants$/) && method === "POST") {
const postData = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "personal-tenant-id",
slug: postData?.slug || "personal",
name: postData?.name || "Personal",
type: postData?.type || "PERSONAL",
config: {},
},
});
}
if (url.match(/\/admin\/tenants\/t-1$/) && method === "GET") {
return route.fulfill({
json: {
@@ -166,21 +107,6 @@ test.describe("User Management", () => {
});
}
if (
url.match(/\/admin\/tenants\/03dbe16b-e47b-4f72-927b-782807d67a35$/) &&
method === "GET"
) {
return route.fulfill({
json: {
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
slug: "tech-planning",
name: "기술기획",
type: "USER_GROUP",
config: {},
},
});
}
if (url.match(/\/admin\/users\/u-1$/) && method === "GET") {
return route.fulfill({
json: {
@@ -259,7 +185,6 @@ test.describe("User Management", () => {
name: "John Doe Updated",
email: "john@test.com",
loginId: "johndoe_updated",
status: "inactive",
},
});
}
@@ -272,11 +197,9 @@ test.describe("User Management", () => {
id: "u-1",
name: "John Doe",
email: "john@test.com",
phone: "010-1111-2222",
loginId: "johndoe",
role: "user",
status: "active",
createdAt: "2026-04-01T00:00:00Z",
},
],
total: 1,
@@ -362,26 +285,8 @@ test.describe("User Management", () => {
// Ensure the page title is loaded
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
const userTypeTabs = page.getByRole("tab");
await expect(userTypeTabs).toHaveText([
"한맥가족 구성원",
"외부 기업 회원",
"개인 회원",
]);
await expect(
page.getByRole("tab", { name: /외부 기업 회원/i }),
).toBeVisible();
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toBeVisible();
await expect(page.getByRole("tab", { name: /개인 회원/i })).toBeVisible();
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active");
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
// Select Tenant first (important for schema fields to show up)
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
await page.selectOption("select#tenantSlug", "test-tenant");
// Fill required fields
@@ -400,450 +305,6 @@ test.describe("User Management", () => {
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
});
test("should export users through the authenticated API client", async ({
page,
}) => {
let authorizationHeader: string | undefined;
let exportUrl = "";
await page.route(/\/admin\/users\/export(\?.*)?$/, async (route) => {
authorizationHeader = route.request().headers().authorization;
exportUrl = route.request().url();
return route.fulfill({
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="users.csv"',
},
body: "email,name\njohn@test.com,John Doe\n",
});
});
await page.goto("/users");
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: /내보내기|Export/i }).click(),
]);
expect(download.suggestedFilename()).toBe("users.csv");
expect(authorizationHeader).toBe("Bearer fake-token");
expect(exportUrl).toContain("includeIds=false");
});
test("should show contact info in one row, hide roles, and toggle user status", async ({
page,
}) => {
let updatePayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/users\/u-1$/, async (route) => {
if (route.request().method() === "PUT") {
updatePayload = route.request().postDataJSON();
return route.fulfill({
json: {
id: "u-1",
name: "John Doe",
email: "john@test.com",
phone: "010-1111-2222",
loginId: "johndoe",
status: "inactive",
createdAt: "2026-04-01T00:00:00Z",
},
});
}
return route.fallback();
});
await page.goto("/users");
const table = page.locator("table");
await expect(
table.getByRole("columnheader", { name: /ROLE|역할/i }),
).toHaveCount(0);
await expect(page.getByTestId("user-contact-u-1")).toContainText(
"John Doe john@test.com 010-1111-2222",
);
await page.getByTestId("user-status-toggle-u-1").click();
await expect
.poll(() => updatePayload)
.toMatchObject({ status: "inactive" });
});
test("should expose internal user uuid in the users table", async ({
page,
}) => {
const internalUserId = "4d20c735-05d0-42d4-9479-0e9be74fd987";
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: {
items: [
{
id: internalUserId,
name: "UUID User",
email: "uuid-user@test.com",
phone: "010-2222-3333",
loginId: "uuid_login_id",
role: "user",
status: "active",
createdAt: "2026-04-01T00:00:00Z",
},
],
total: 1,
limit: 50,
offset: 0,
},
});
});
await page.goto("/users");
await expect(page.locator("table")).toContainText(internalUserId);
});
test("should create a Hanmac family user with tenant appointments and no representative affiliation", async ({
page,
}) => {
let createPayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() === "POST") {
createPayload = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "new-user-id",
name: "Family User",
email: "family@test.com",
},
});
}
return route.fallback();
});
await page.goto("/users/new");
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active");
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
await expect(page.locator("select#role")).toHaveCount(0);
await expect(page.locator("input#department")).toHaveCount(0);
await expect(page.getByText(/대표 소속/i)).toHaveCount(0);
await page.getByRole("button", { name: /^추가$/i }).click();
await expect(page.getByTestId("appointment-row-0")).toBeVisible();
await expect(
page.getByTestId("appointment-tenant-owner-line-0"),
).toBeVisible();
await expect(page.getByTestId("appointment-position-line-0")).toBeVisible();
await page.getByRole("button", { name: /테넌트 선택/i }).click();
await expect(page.getByTitle(/테넌트 선택/i)).toHaveAttribute(
"src",
/\/login\?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id$/,
);
await page.evaluate(() => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [
{
type: "tenant",
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
},
],
},
},
}),
);
});
await expect(page.getByText("기술기획")).toBeVisible();
await page.getByRole("switch", { name: /대표 조직/i }).click();
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
await page.getByLabel(/^직급$/i).fill("책임");
await page.getByLabel(/^직책$/i).fill("팀장");
await page.locator('input[name="name"]').fill("Family User");
await page.locator('input[name="email"]').fill("family@test.com");
await page.getByRole("button", { name: /생성/i }).click();
await expect
.poll(() => createPayload)
.toMatchObject({
metadata: {
hanmacFamily: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
tenantSlug: "tech-planning",
tenantName: "기술기획",
isOwner: true,
grade: "책임",
jobTitle: "플랫폼 운영",
position: "팀장",
},
],
},
});
expect(createPayload).not.toHaveProperty("role");
expect(createPayload).not.toHaveProperty("department");
expect(createPayload).not.toHaveProperty("tenantSlug");
expect(createPayload).not.toHaveProperty("companyCode");
expect(createPayload).not.toHaveProperty("primaryTenantId");
});
test("should hide Hanmac family subtree and system tenants when creating a non-family user", async ({
page,
}) => {
await page.goto("/users/new");
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
const tenantOptionValues = await page
.locator("select#tenantSlug option")
.evaluateAll((options) =>
options.map((option) => (option as HTMLOptionElement).value),
);
expect(tenantOptionValues).toContain("test-tenant");
expect(tenantOptionValues).toContain("external-tenant");
expect(tenantOptionValues).not.toContain("");
expect(tenantOptionValues).not.toContain("system");
expect(tenantOptionValues).not.toContain("hanmac-family");
expect(tenantOptionValues).not.toContain("hanmac-company");
expect(tenantOptionValues).not.toContain("hanmac-team");
expect(tenantOptionValues).not.toContain("tech-planning");
});
test("should create a personal user and provision Personal tenant when missing", async ({
page,
}) => {
let tenantPayload: Record<string, unknown> | undefined;
let createPayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/tenants$/, async (route) => {
if (route.request().method() === "POST") {
tenantPayload = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "personal-tenant-id",
slug: "personal",
name: "Personal",
type: "PERSONAL",
config: {},
},
});
}
return route.fallback();
});
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() === "POST") {
createPayload = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "personal-user-id",
name: "Personal User",
email: "personal@test.com",
},
});
}
return route.fallback();
});
await page.goto("/users/new");
await page.getByRole("tab", { name: /개인 회원/i }).click();
await expect(page.getByTestId("personal-tenant-summary")).toContainText(
/Personal/i,
);
await page.locator('input[name="name"]').fill("Personal User");
await page.locator('input[name="email"]').fill("personal@test.com");
await page.getByRole("button", { name: /생성/i }).click();
await expect
.poll(() => tenantPayload)
.toMatchObject({ name: "Personal", slug: "personal", type: "PERSONAL" });
await expect
.poll(() => createPayload)
.toMatchObject({
tenantSlug: "personal",
metadata: { userType: "personal", hanmacFamily: false },
});
});
test("should show Hanmac family appointments layout on user detail", async ({
page,
}) => {
await page.route(/\/admin\/users\/u-1$/, async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
json: {
id: "u-1",
name: "Family User",
email: "family@test.com",
phone: "010-1111-2222",
loginId: "familyuser",
role: "user",
status: "active",
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
metadata: {
hanmacFamily: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
tenantSlug: "tech-planning",
tenantName: "기술기획",
isPrimary: true,
isOwner: true,
grade: "책임",
jobTitle: "플랫폼 운영",
position: "팀장",
},
],
},
},
});
}
return route.fallback();
});
await page.goto("/users/u-1");
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active");
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible();
await expect(
page.getByTestId("detail-appointment-tenant-owner-line-0"),
).toContainText(/기술기획|대표 조직|조직장/);
await expect(
page.getByTestId("detail-appointment-row-0").getByRole("switch", {
name: /대표 조직/i,
}),
).toBeChecked();
await expect(
page.getByTestId("detail-appointment-row-0").getByRole("switch", {
name: /대표 조직/i,
}),
).toBeDisabled();
await expect(
page.getByTestId("detail-appointment-position-line-0"),
).toBeVisible();
});
test("should save selected Hanmac representative appointment from user detail", async ({
page,
}) => {
let updatePayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/users\/u-1$/, async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
json: {
id: "u-1",
name: "Family User",
email: "family@test.com",
phone: "010-1111-2222",
loginId: "familyuser",
role: "user",
status: "active",
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
metadata: {
hanmacFamily: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
tenantSlug: "tech-planning",
tenantName: "기술기획",
isOwner: true,
grade: "책임",
jobTitle: "플랫폼 운영",
position: "팀장",
},
{
tenantId: "hanmac-team-id",
tenantSlug: "hanmac-team",
tenantName: "한맥팀",
isOwner: false,
grade: "선임",
jobTitle: "개발",
position: "파트장",
},
],
},
},
});
}
if (route.request().method() === "PUT") {
updatePayload = route.request().postDataJSON();
return route.fulfill({
json: {
id: "u-1",
name: "Family User",
email: "family@test.com",
status: "active",
},
});
}
return route.fallback();
});
await page.goto("/users/u-1");
await page
.getByTestId("detail-appointment-row-1")
.getByRole("switch", { name: /대표 조직/i })
.click();
await expect(
page.getByTestId("detail-appointment-row-0").getByRole("switch", {
name: /대표 조직/i,
}),
).not.toBeChecked();
await expect(
page.getByTestId("detail-appointment-row-1").getByRole("switch", {
name: /대표 조직/i,
}),
).toBeChecked();
await page.locator("form").evaluate((form) => {
(form as HTMLFormElement).requestSubmit();
});
await expect
.poll(() => updatePayload)
.toMatchObject({
tenantSlug: "hanmac-team",
primaryTenantId: "hanmac-team-id",
primaryTenantName: "한맥팀",
primaryTenantIsOwner: true,
metadata: {
primaryTenantId: "hanmac-team-id",
primaryTenantName: "한맥팀",
primaryTenantSlug: "hanmac-team",
primaryTenantIsOwner: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
isPrimary: false,
},
{ tenantId: "hanmac-team-id", isPrimary: true },
],
},
});
});
test("should show conflict error when creating with an existing Login ID", async ({
page,
}) => {
@@ -851,8 +312,6 @@ test.describe("User Management", () => {
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
// Select Tenant first (important for schema fields to show up)
await page.selectOption("select#tenantSlug", "test-tenant");

View File

@@ -112,196 +112,4 @@ test.describe("Users Bulk Upload", () => {
const uploadBtn = page.getByTestId("bulk-start-btn");
await expect(uploadBtn).toBeDisabled();
});
test("should create missing tenant before user bulk import", async ({
page,
}) => {
const requests: string[] = [];
let bulkPayload = "";
await page.route("**/api/v1/admin/tenants**", async (route) => {
const method = route.request().method();
requests.push(`${method} ${route.request().url()}`);
if (method === "GET") {
return route.fulfill({
json: { items: [], total: 0, limit: 100, offset: 0 },
headers: { "Access-Control-Allow-Origin": "*" },
});
}
if (method === "POST") {
return route.fulfill({
status: 201,
json: {
id: "staging-missing-tenant-id",
name: "Missing Tenant",
slug: "missing-slug",
type: "COMPANY",
description: "Imported memo",
status: "active",
domains: ["missing.example.com"],
memberCount: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
headers: { "Access-Control-Allow-Origin": "*" },
});
}
return route.continue();
});
await page.route("**/api/v1/admin/users/bulk", async (route) => {
bulkPayload = route.request().postData() ?? "";
return route.fulfill({
json: {
results: [{ email: "new@test.com", success: true, userId: "u-1" }],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/users");
await expect(page.getByTestId("page-title")).toContainText(
/사용자|Users/i,
{ timeout: 20000 },
);
await page.getByTestId("bulk-import-btn").click();
await page.locator('input[type="file"]').setInputFiles({
name: "users.csv",
mimeType: "text/csv",
buffer: Buffer.from(
"email,name,tenant_id,tenant_slug,tenant_name,tenant_type,tenant_memo,email_domain\nnew@test.com,New User,local-tenant-id,missing-slug,Missing Tenant,COMPANY,Imported memo,missing.example.com\n",
),
});
await expect(
page.getByTestId("user-import-tenant-resolution"),
).toContainText(/신규 생성|Create new/i);
await page.getByTestId("bulk-start-btn").click();
await expect(page.getByText("new@test.com")).toBeVisible();
expect(requests.some((request) => request.startsWith("POST "))).toBe(true);
expect(bulkPayload).toContain('"tenantId":"staging-missing-tenant-id"');
expect(bulkPayload).toContain('"tenantSlug":"missing-slug"');
expect(bulkPayload).toContain('"emailDomain":"missing.example.com"');
});
test("should include one nullable additional appointment from numbered CSV columns", async ({
page,
}) => {
let bulkPayload = "";
await page.unroute("**/api/v1/**");
await page.route("**/api/v1/user/me", async (route) => {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.route(/\/api\/v1\/admin\/users(?:\?|$)/, async (route) => {
return route.fulfill({
json: { items: [], total: 0, limit: 50, offset: 0 },
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.route(/\/api\/v1\/admin\/tenants(?:\?|$)/, async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
json: {
items: [
{
id: "tenant-primary-id",
name: "Primary Tenant",
slug: "primary-tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "tenant-second-id",
name: "Second Tenant",
slug: "second-tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 2,
limit: 1000,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
}
return route.fulfill({
status: 201,
json: {
id: "tenant-created-id",
name: "Primary Tenant",
slug: "primary-tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.route("**/api/v1/admin/users/bulk", async (route) => {
bulkPayload = route.request().postData() ?? "";
return route.fulfill({
json: {
results: [{ email: "dual@test.com", success: true, userId: "u-1" }],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/users");
await expect(page.getByTestId("page-title")).toContainText(
/사용자|Users/i,
{ timeout: 20000 },
);
await page.getByTestId("bulk-import-btn").click();
await page.locator('input[type="file"]').setInputFiles({
name: "users.csv",
mimeType: "text/csv",
buffer: Buffer.from(
[
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1",
"dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002",
].join("\n"),
),
});
await page.getByTestId("bulk-start-btn").click();
await expect(page.getByText("dual@test.com")).toBeVisible();
const payload = JSON.parse(bulkPayload);
expect(payload.users[0].tenantSlug).toBe("primary-tenant");
expect(payload.users[0].metadata.employee_id).toBe("EMP001");
expect(payload.users[0].additionalAppointments).toEqual([
{
tenantSlug: "second-tenant",
department: "센터",
grade: "수석",
jobTitle: "Architecture",
metadata: {
employee_id: "EMP002",
},
},
]);
});
});

View File

@@ -1,85 +0,0 @@
import fs from "node:fs";
import { expect, test } from "@playwright/test";
const liveE2E = process.env.LIVE_BACKEND_E2E === "1";
const oidcAuthority = "https://sso.hmac.kr/oidc";
const clientId = "adminfront";
test.describe("Users CSV live E2E", () => {
test.skip(!liveE2E, "Set LIVE_BACKEND_E2E=1 to run against a live backend.");
test.beforeEach(async ({ page, baseURL }) => {
await page.addInitScript(
({ authority, client_id }) => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
window.localStorage.setItem("X-Mock-Role", "super_admin");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "live-e2e-placeholder-token",
token_type: "Bearer",
profile: {
sub: "live-e2e-admin",
name: "Live E2E Admin",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
);
},
{ authority: oidcAuthority, client_id: clientId },
);
await page.route("**/api/v1/**", async (route) => {
const requestUrl = new URL(route.request().url());
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
const { authorization: _authorization, ...headers } = route
.request()
.headers();
headers["x-test-role"] = "super_admin";
const response = await route.fetch({ url: liveUrl, headers });
await route.fulfill({ response });
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({
json: {
issuer: oidcAuthority,
authorization_endpoint: `${oidcAuthority}/auth`,
token_endpoint: `${oidcAuthority}/token`,
jwks_uri: `${oidcAuthority}/jwks`,
userinfo_endpoint: `${oidcAuthority}/userinfo`,
end_session_endpoint: `${oidcAuthority}/session/end`,
},
});
});
});
test("exports user CSV through the authenticated admin UI path", async ({
page,
}) => {
await page.goto("/users");
await expect(page.getByTestId("page-title")).toContainText(/사용자|Users/i);
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: /내보내기|Export/i }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain("users");
const path = await download.path();
expect(path).toBeTruthy();
const csv = fs.readFileSync(path as string, "utf8");
expect(csv).toContain(
"ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt",
);
expect(csv).not.toContain("Role");
expect(csv).not.toContain("Department");
});
});

View File

@@ -1,506 +0,0 @@
import { expect, test } from "@playwright/test";
test.describe("Worksmobile tenant management", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
window.localStorage.setItem("RoleSwitcher-Collapsed", "true");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
};
window.localStorage.setItem(key, JSON.stringify(authData));
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});
test("opens Worksmobile in the current tab and filters comparison rows", async ({
page,
}) => {
const comparisonRequests: boolean[] = [];
const syncRequests: string[] = [];
await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url());
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.pathname.endsWith("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id") &&
method === "GET"
) {
return route.fulfill({
json: {
id: "hanmac-family-id",
name: "한맥 가족",
slug: "hanmac-family",
type: "COMPANY_GROUP",
status: "active",
parentId: null,
},
headers,
});
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
method === "GET"
) {
return route.fulfill({
json: {
tenant: {
id: "hanmac-family-id",
name: "한맥 가족",
slug: "hanmac-family",
type: "COMPANY_GROUP",
status: "active",
memberCount: 0,
createdAt: "2026-05-04T00:00:00Z",
updatedAt: "2026-05-04T00:00:00Z",
},
config: {
enabled: true,
tokenConfigured: true,
},
recentJobs: [],
},
headers,
});
}
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
) &&
method === "GET"
) {
const includeMatched =
url.searchParams.get("includeMatched") === "true";
comparisonRequests.push(includeMatched);
return route.fulfill({
json: {
users: includeMatched
? [
{
resourceType: "USER",
baronId: "user-matched",
baronName: "홍길동",
baronPrimaryOrgId: "team-tech",
baronPrimaryOrgName: "기술기획",
worksmobileId: "works-user-matched",
externalKey: "user-matched",
worksmobileName: "홍길동",
status: "matched",
worksmobilePrimaryOrgId: "works-team-tech",
worksmobilePrimaryOrgName: "WORKS 기술기획",
},
{
resourceType: "USER",
baronId: "user-missing",
baronName: "김누락",
status: "missing_in_worksmobile",
},
{
resourceType: "USER",
worksmobileId: "works-user-only",
externalKey: "works-user-only",
worksmobileName: "박웍스",
status: "missing_in_baron",
},
]
: [
{
resourceType: "USER",
baronId: "user-missing",
baronName: "김누락",
status: "missing_in_worksmobile",
},
{
resourceType: "USER",
worksmobileId: "works-user-only",
externalKey: "works-user-only",
worksmobileName: "박웍스",
status: "missing_in_baron",
},
],
groups: [
{
resourceType: "GROUP",
baronId: "group-missing",
baronName: "Baron 전용 조직",
baronParentId: "parent-tech",
baronParentName: "기술본부",
status: "missing_in_worksmobile",
},
{
resourceType: "GROUP",
worksmobileId: "works-group-only",
externalKey: "works-group-only",
worksmobileName: "WORKS 전용 조직",
worksmobileParentId: "works-parent-tech",
worksmobileParentName: "WORKS 기술본부",
status: "missing_in_baron",
},
],
},
headers,
});
}
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/users/user-missing/sync",
) &&
method === "POST"
) {
syncRequests.push("user-missing");
return route.fulfill({
json: { id: "job-user-missing", resourceId: "user-missing" },
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.goto("/tenants/hanmac-family-id");
await page.getByRole("link", { name: "Worksmobile" }).click();
await expect(page).toHaveURL(/\/tenants\/hanmac-family-id\/worksmobile$/);
await expect(page.getByText("Baron / Works 비교")).toBeVisible();
await expect(page.getByText("domainMappings")).not.toBeVisible();
await expect(page.getByText("SCIM token")).not.toBeVisible();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("WORKS 전용 조직")).toBeVisible();
await expect(page.getByText("기술본부", { exact: true })).toBeVisible();
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
await expect(page.getByText("WORKS 기술본부")).toBeVisible();
await expect(page.getByText("works-parent-tech")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
expect(comparisonRequests[0]).toBe(true);
const filterButtons = page
.getByRole("button", {
name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/,
})
.allTextContents();
await expect
.poll(() => filterButtons)
.toEqual(["바론에만 있음", "웍스에만 있음", "양쪽 다 있음"]);
await page.getByRole("button", { name: "웍스에만 있음" }).click();
await expect(page.getByText("박웍스")).not.toBeVisible();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
await page.getByRole("button", { name: "양쪽 다 있음" }).click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("기술기획", { exact: true })).toBeVisible();
await expect(page.getByText("team-tech", { exact: true })).toBeVisible();
await expect(page.getByText("WORKS 기술기획")).toBeVisible();
await expect(page.getByText("works-team-tech")).toBeVisible();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).not.toBeVisible();
await page.getByRole("button", { name: "바론에만 있음" }).click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).not.toBeVisible();
await page.getByRole("button", { name: "웍스에만 있음" }).click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await page.getByRole("button", { name: "양쪽 다 있음" }).click();
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
await page.getByRole("button", { name: "바론에만 있음" }).click();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
await page
.getByRole("row", { name: /김누락/ })
.getByRole("checkbox")
.check();
await page
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await expect.poll(() => syncRequests).toEqual(["user-missing"]);
});
test("shows a toast when selected WORKS creation fails", async ({ page }) => {
await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url());
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.pathname.endsWith("/user/me")) {
return route.fulfill({
json: { id: "admin-user", name: "Admin", role: "super_admin" },
headers,
});
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id") &&
method === "GET"
) {
return route.fulfill({
json: {
id: "hanmac-family-id",
name: "한맥 가족",
slug: "hanmac-family",
parentId: null,
},
headers,
});
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
method === "GET"
) {
return route.fulfill({
json: {
tenant: {
id: "hanmac-family-id",
name: "한맥 가족",
slug: "hanmac-family",
parentId: null,
},
config: {},
recentJobs: [],
},
headers,
});
}
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
) &&
method === "GET"
) {
return route.fulfill({
json: {
users: [
{
resourceType: "USER",
baronId: "user-fail",
baronName: "실패 사용자",
status: "missing_in_worksmobile",
},
],
groups: [],
},
headers,
});
}
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/users/user-fail/sync",
) &&
method === "POST"
) {
return route.fulfill({
status: 500,
json: { error: "WORKS API rejected user creation" },
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.goto("/tenants/hanmac-family-id/worksmobile");
await page
.getByRole("row", { name: /실패 사용자/ })
.getByRole("checkbox")
.check();
await page
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await expect(page.getByText("WORKS 생성 작업 등록 실패")).toBeVisible();
await expect(
page.getByText(/WORKS API rejected user creation/),
).toBeVisible();
});
test("keeps wide comparison columns inside table scroll and blocks immutable WORKS accounts", async ({
page,
}) => {
await page.setViewportSize({ width: 900, height: 700 });
await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url());
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.pathname.endsWith("/user/me")) {
return route.fulfill({
json: { id: "admin-user", name: "Admin", role: "super_admin" },
headers,
});
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id") &&
method === "GET"
) {
return route.fulfill({
json: {
id: "hanmac-family-id",
name: "한맥 가족",
slug: "hanmac-family",
parentId: null,
},
headers,
});
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
method === "GET"
) {
return route.fulfill({
json: {
tenant: {
id: "hanmac-family-id",
name: "한맥 가족",
slug: "hanmac-family",
parentId: null,
},
config: {
adminTenantId: "works-tenant-1",
},
recentJobs: [],
},
headers,
});
}
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
) &&
method === "GET"
) {
return route.fulfill({
json: {
users: [
{
resourceType: "USER",
worksmobileId:
"works-user-with-extra-long-identifier-for-scroll-check",
externalKey: "external-key-with-extra-long-identifier",
worksmobileName: "긴 WORKS 사용자",
worksmobileEmail:
"long-works-user-name-for-scroll@samaneng.com",
worksmobileDomainId: 300285955,
worksmobileDomainName: "samaneng.com",
worksmobilePrimaryOrgId:
"works-primary-org-with-extra-long-identifier",
worksmobilePrimaryOrgName: "긴 WORKS 조직",
status: "missing_in_baron",
},
{
resourceType: "USER",
worksmobileId: "works-cyhan",
worksmobileName: "변경 불가 계정",
worksmobileEmail: "cyhan@samaneng.com",
worksmobileDomainId: 300285955,
worksmobileDomainName: "samaneng.com",
status: "missing_in_baron",
},
],
groups: [],
},
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.goto("/tenants/hanmac-family-id/worksmobile");
await expect(page.getByText("긴 WORKS 사용자")).toBeVisible();
const userColumnButton = page
.getByRole("heading", { name: "구성원" })
.locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]")
.getByRole("button", { name: "컬럼 설정" });
await userColumnButton.click();
const dialog = page.getByRole("dialog", { name: "구성원 컬럼 설정" });
await dialog.getByLabel("Baron ID").check();
await dialog.getByLabel("WORKS ID").check();
await dialog.getByLabel("external_key").check();
await dialog.getByRole("button", { name: "닫기" }).click();
const pageOverflow = await page.evaluate(() => ({
documentScrollWidth: document.documentElement.scrollWidth,
bodyScrollWidth: document.body.scrollWidth,
viewportWidth: document.documentElement.clientWidth,
}));
expect(
Math.max(pageOverflow.documentScrollWidth, pageOverflow.bodyScrollWidth),
).toBeLessThanOrEqual(pageOverflow.viewportWidth + 1);
const userTableScroll = await page
.locator("table")
.first()
.evaluate((table) => {
const container = table.parentElement?.parentElement as HTMLElement;
return {
clientWidth: container.clientWidth,
overflowX: window.getComputedStyle(container).overflowX,
scrollWidth: container.scrollWidth,
};
});
expect(userTableScroll.overflowX).toBe("auto");
expect(userTableScroll.scrollWidth).toBeGreaterThan(
userTableScroll.clientWidth,
);
const immutableRow = page.getByRole("row", {
name: /cyhan@samaneng\.com/,
});
await expect(immutableRow.getByRole("checkbox")).toBeDisabled();
await expect(
immutableRow.getByRole("button", { name: /비밀번호 관리/ }),
).toBeDisabled();
});
});

View File

@@ -24,6 +24,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
"include": ["src"]
}

View File

@@ -1,25 +1,16 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
const buildOutDir =
process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist";
export default defineConfig({
plugins: [react()],
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
cacheDir:
process.env.ADMINFRONT_VITE_CACHE_DIR ??
"/tmp/baron-sso-adminfront-vite-cache",
build: {
outDir: buildOutDir,
emptyOutDir: true,
},
envPrefix: ["VITE_", "USERFRONT_"],
server: {
host: "127.0.0.1",
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
// 인스턴스별 도메인을 자동으로 허용
allowedHosts: ["{{ADMINFRONT_DOMAIN}}", "localhost", "127.0.0.1","adminb.hmac.kr"],
proxy: {
"/api": {
target: process.env.API_PROXY_TARGET || "http://localhost:3000",
target: process.env.API_PROXY_TARGET || "http://backend:{{BACKEND_PORT}}",
changeOrigin: true,
},
},
@@ -27,10 +18,10 @@ export default defineConfig({
preview: {
host: "127.0.0.1",
port: 5173,
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
allowedHosts: ["{{ADMINFRONT_DOMAIN}}", "localhost", "127.0.0.1", "adminb.hmac.kr"],
proxy: {
"/api": {
target: process.env.API_PROXY_TARGET || "http://localhost:3000",
target: process.env.API_PROXY_TARGET || "http://backend:{{BACKEND_PORT}}",
changeOrigin: true,
},
},

View File

@@ -3,9 +3,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
esbuild: {
jsx: "automatic",
},
test: {
globals: true,
environment: "jsdom",

View File

@@ -1,4 +1,4 @@
FROM golang:1.26.2-alpine
FROM golang:1.25-alpine
WORKDIR /app

View File

@@ -1,36 +0,0 @@
package main
import (
"fmt"
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type User struct {
ID string
Email string
Name string
CompanyCode string
Status string
}
func main() {
dsn := "host=localhost user=baron password=password dbname=baron_sso port=5432 sslmode=disable TimeZone=Asia/Seoul"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
var users []User
err = db.Raw("SELECT id, email, name, company_code, status FROM users WHERE company_code = 'aaa2' OR 'aaa2' = ANY(company_codes)").Scan(&users).Error
if err != nil {
log.Fatal(err)
}
fmt.Printf("Total users for aaa2: %d\n", len(users))
for _, u := range users {
fmt.Printf("- %s (%s) | status: %s | primary: %s\n", u.Name, u.Email, u.Status, u.CompanyCode)
}
}

View File

@@ -1,176 +0,0 @@
package main
import (
"baron-sso-backend/internal/bootstrap"
"baron-sso-backend/internal/idp"
"baron-sso-backend/internal/logger"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"context"
"flag"
"fmt"
"log"
"log/slog"
"os"
"strings"
"time"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormLogger "gorm.io/gorm/logger"
)
type createSuperAdminConfig struct {
Email string
Password string
Name string
UpdatePassword bool
}
func main() {
loadEnv()
logger.Init(logger.Config{
ServiceName: "baron-sso-adminctl",
Environment: getenv("APP_ENV", getenv("GO_ENV", "dev")),
LevelOverride: getenv("BACKEND_LOG_LEVEL", ""),
})
if len(os.Args) < 2 {
printUsage()
os.Exit(2)
}
switch os.Args[1] {
case "create-super-admin":
if err := runCreateSuperAdmin(os.Args[2:]); err != nil {
slog.Error("create-super-admin failed", "error", err)
os.Exit(1)
}
default:
printUsage()
os.Exit(2)
}
}
func runCreateSuperAdmin(args []string) error {
config, err := resolveCreateSuperAdminConfig(args)
if err != nil {
return err
}
db, err := openDB()
if err != nil {
return err
}
if err := bootstrap.Run(db); err != nil {
return err
}
provider, err := idp.InitializeProvider()
if err != nil {
return err
}
if provider == nil {
return fmt.Errorf("idp provider is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := bootstrap.EnsureSuperAdmin(
ctx,
service.NewKratosAdminService(),
bootstrap.NewGormSuperAdminStore(db, repository.NewKetoOutboxRepository(db)),
bootstrap.EnsureSuperAdminOptions{
Email: config.Email,
Password: config.Password,
Name: config.Name,
Source: "adminctl",
UpdatePassword: config.UpdatePassword,
},
)
if err != nil {
return err
}
fmt.Printf("super admin ensured: email=%s identity_id=%s user_id=%s identity_created=%t local_created=%t local_updated=%t password_updated=%t keto_relation_queued=%t\n",
result.Email,
result.IdentityID,
result.LocalUserID,
result.IdentityCreated,
result.LocalUserCreated,
result.LocalUserUpdated,
result.PasswordUpdated,
result.KetoRelationQueued,
)
return nil
}
func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) {
fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
config := createSuperAdminConfig{}
fs.StringVar(&config.Email, "email", getenv("ADMIN_EMAIL", ""), "admin email")
fs.StringVar(&config.Password, "password", getenv("ADMIN_PASSWORD", ""), "admin password")
fs.StringVar(&config.Name, "name", getenv("ADMIN_NAME", "System Admin"), "admin display name")
fs.BoolVar(&config.UpdatePassword, "update-password", false, "update password when identity already exists")
if err := fs.Parse(args); err != nil {
return config, err
}
config.Email = strings.TrimSpace(config.Email)
config.Name = strings.TrimSpace(config.Name)
if config.Email == "" {
return config, fmt.Errorf("admin email is required; pass --email or set ADMIN_EMAIL")
}
if strings.TrimSpace(config.Password) == "" {
return config, fmt.Errorf("admin password is required; pass --password or set ADMIN_PASSWORD")
}
if config.Name == "" {
config.Name = "System Admin"
}
return config, nil
}
func openDB() (*gorm.DB, error) {
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
getenv("DB_HOST", "localhost"),
getenv("DB_USER", "baron"),
getenv("DB_PASSWORD", "password"),
getenv("DB_NAME", "baron_sso"),
getenv("DB_PORT", "5432"),
)
return gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: gormLogger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
gormLogger.Config{
SlowThreshold: time.Second,
LogLevel: gormLogger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
),
})
}
func loadEnv() {
_ = godotenv.Load(".env")
_ = godotenv.Load("../.env")
_ = godotenv.Load("../../.env")
}
func getenv(key string, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
func printUsage() {
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]")
}

View File

@@ -1,62 +0,0 @@
package main
import "testing"
func TestResolveCreateSuperAdminConfigUsesEnvDefaults(t *testing.T) {
t.Setenv("ADMIN_EMAIL", "admin@example.com")
t.Setenv("ADMIN_PASSWORD", "Password!123")
t.Setenv("ADMIN_NAME", "Env Admin")
config, err := resolveCreateSuperAdminConfig([]string{})
if err != nil {
t.Fatalf("resolveCreateSuperAdminConfig returned error: %v", err)
}
if config.Email != "admin@example.com" {
t.Fatalf("email = %q", config.Email)
}
if config.Password != "Password!123" {
t.Fatal("password was not read from ADMIN_PASSWORD")
}
if config.Name != "Env Admin" {
t.Fatalf("name = %q", config.Name)
}
}
func TestResolveCreateSuperAdminConfigAllowsFlagOverrides(t *testing.T) {
t.Setenv("ADMIN_EMAIL", "admin@example.com")
t.Setenv("ADMIN_PASSWORD", "Password!123")
t.Setenv("ADMIN_NAME", "Env Admin")
config, err := resolveCreateSuperAdminConfig([]string{
"--email", "flag@example.com",
"--password", "FlagPassword!123",
"--name", "Flag Admin",
"--update-password",
})
if err != nil {
t.Fatalf("resolveCreateSuperAdminConfig returned error: %v", err)
}
if config.Email != "flag@example.com" {
t.Fatalf("email = %q", config.Email)
}
if config.Password != "FlagPassword!123" {
t.Fatal("password flag was not used")
}
if config.Name != "Flag Admin" {
t.Fatalf("name = %q", config.Name)
}
if !config.UpdatePassword {
t.Fatal("update password flag was not set")
}
}
func TestResolveCreateSuperAdminConfigRequiresEmailAndPassword(t *testing.T) {
t.Setenv("ADMIN_EMAIL", "")
t.Setenv("ADMIN_PASSWORD", "")
if _, err := resolveCreateSuperAdminConfig([]string{}); err == nil {
t.Fatal("expected error")
}
}

View File

@@ -26,12 +26,14 @@ func main() {
norm := domain.NormalizeRole(r)
if norm != r && norm == domain.RoleUser {
traits["role"] = norm
traits["grade"] = norm
changed = true
}
} else if g, ok := traits["grade"].(string); ok {
if norm, ok := domain.NormalizeRoleAlias(g); ok {
norm := domain.NormalizeRole(g)
if norm != g && norm == domain.RoleUser {
traits["role"] = norm
delete(traits, "grade")
traits["grade"] = norm
changed = true
}
}

View File

@@ -419,8 +419,6 @@ func TestHeadlessPasswordLogin_E2E_ResponseIncludesDetailedCodeAndLogs(t *testin
}
func TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics(t *testing.T) {
t.Setenv("BACKEND_PUBLIC_URL", "")
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login"
clientAssertion := mustE2EHeadlessClientAssertion(
@@ -460,8 +458,6 @@ func TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics(t *testing.T) {
}
func TestHeadlessPasswordLogin_E2E_AcceptsForwardedHTTPSAudience(t *testing.T) {
t.Setenv("BACKEND_PUBLIC_URL", "")
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login"
clientAssertion := mustE2EHeadlessClientAssertion(

View File

@@ -16,7 +16,6 @@ import (
"log/slog"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -40,34 +39,6 @@ func getEnv(key, fallback string) string {
return fallback
}
func getEnvFileOrValue(fileKey string, valueKey string, fallback string) (string, error) {
if path := strings.TrimSpace(getEnv(fileKey, "")); path != "" {
value, err := readEnvFileValue(path)
if err != nil {
return "", err
}
return value, nil
}
return getEnv(valueKey, fallback), nil
}
func readEnvFileValue(path string) (string, error) {
candidates := []string{path}
if !filepath.IsAbs(path) {
candidates = append(candidates, filepath.Join("..", path), filepath.Join("..", "..", path))
}
var lastErr error
for _, candidate := range candidates {
data, err := os.ReadFile(candidate)
if err == nil {
return string(data), nil
}
lastErr = err
}
return "", fmt.Errorf("read secret file %q: %w", path, lastErr)
}
func normalizeDocsPrefix(prefix string) string {
trimmed := strings.TrimSpace(prefix)
if trimmed == "" || trimmed == "/" {
@@ -183,15 +154,11 @@ func main() {
chDB := getEnv("CLICKHOUSE_DB", "baron_sso")
var auditRepo domain.AuditRepository
var rpUsageProjectionRepo domain.RPUsageProjectionRepository
var rpUsageQueryRepo domain.RPUsageQueryRepository
if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil {
slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err)
auditRepo = nil // Explicitly set to nil interface
} else {
auditRepo = repo
rpUsageProjectionRepo = repo
rpUsageQueryRepo = repo
slog.Info("✅ Connected to ClickHouse")
}
@@ -300,50 +267,12 @@ func main() {
tenantRepo := repository.NewTenantRepository(db)
userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db)
userProjectionRepo := repository.NewUserProjectionRepository(db)
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db)
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
sharedLinkRepo := repository.NewSharedLinkRepository(db)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
userProjectionSyncer := service.NewUserProjectionSyncService(kratosAdminService, userProjectionRepo)
if synced, err := userProjectionSyncer.Reconcile(context.Background()); err != nil {
slog.Error("❌ Kratos user projection sync failed", "error", err)
} else {
slog.Info("✅ Kratos user projection synced", "users", synced)
}
tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo)
worksmobilePrivateKey, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
if err != nil {
slog.Error("Worksmobile private key file could not be loaded", "error", err)
os.Exit(1)
}
worksmobileClient := service.NewWorksmobileHTTPClientWithAuth(
getEnv("WORKS_ADMIN_ACCESS_TOKEN", getEnv("WORKS_ADMIN_OAUTH_ACCESS_TOKEN", "")),
getEnv("SAMAN_SCIM_LONGLIVE_TOKEN", ""),
service.WorksmobileOAuthConfig{
ClientID: getEnv("WORKS_ADMIN_OAUTH_CLIENT_ID", ""),
ClientSecret: getEnv("WORKS_ADMIN_OAUTH_CLIENT_SECRET", ""),
ServiceAccount: getEnv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT", ""),
PrivateKey: worksmobilePrivateKey,
Scope: getEnv("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
},
)
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient)
go worksmobileRelayWorker.Start(context.Background())
slog.Info("✅ Worksmobile Relay Worker started")
rpUsageEmitter := service.NewRPUsageEventEmitter(rpUsageOutboxRepo)
if rpUsageProjectionRepo != nil {
rpUsageProjectorWorker := service.NewRPUsageProjectorWorker(rpUsageOutboxRepo, rpUsageProjectionRepo)
go rpUsageProjectorWorker.Start(context.Background())
slog.Info("✅ RP Usage Projector Worker started")
} else {
slog.Warn("RP Usage Projector Worker skipped because ClickHouse is unavailable")
}
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
tenantService.SetKetoService(ketoService) // Keto 주입
@@ -356,36 +285,24 @@ func main() {
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db)
rpUserMetadataRepo := repository.NewRPUserMetadataRepository(db)
developerService := service.NewDeveloperService(db)
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
authHandler.HeadlessJWKS = headlessJWKSCache
authHandler.UserProjectionRepo = userProjectionRepo
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
authHandler.RPUsageSink = rpUsageEmitter
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
adminHandler.RPUsageQueries = rpUsageQueryRepo
adminHandler.TenantRepo = tenantRepo
adminHandler.Hydra = hydraService
adminHandler.AuditRepo = auditRepo
adminHandler.UserProjectionRepo = userProjectionRepo
adminHandler.UserProjectionSyncer = userProjectionSyncer
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
devHandler.HeadlessJWKS = headlessJWKSCache
devHandler.AuditRepo = auditRepo
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
userHandler.UserProjectionRepo = userProjectionRepo
tenantHandler.SetWorksmobileSyncer(worksmobileService)
userHandler.SetWorksmobileSyncer(worksmobileService)
worksmobileHandler := handler.NewWorksmobileHandler(worksmobileService)
apiKeyHandler := handler.NewApiKeyHandler(db)
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
orgChartHandler := handler.NewOrgChartHandler(orgChartService)
// 3. Initialize Fiber
appEnv := getEnv("APP_ENV", "dev")
clientLogDebugFlag := getEnv("CLIENT_LOG_DEBUG", "")
@@ -587,10 +504,6 @@ func main() {
"checks": checks,
})
})
rpManifestHandler := handler.NewRPManifestHandler()
app.Get("/.well-known/baron-rp-manifest", rpManifestHandler.GetHTML)
app.Get("/.well-known/baron-rp-manifest.json", rpManifestHandler.GetJSON)
app.Get("/.well-known/baron-rp-manifest.schema.json", rpManifestHandler.GetSchema)
// API Group
api := app.Group("/api/v1")
@@ -619,7 +532,6 @@ func main() {
// Public Tenant Registration
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
api.Get("/admin/worksmobile/oauth/callback", worksmobileHandler.OAuthCallback)
// Tenant Context Middleware (identifies tenant from Host header)
api.Use(middleware.TenantContextMiddleware(middleware.TenantContextConfig{
@@ -660,7 +572,6 @@ func main() {
auth.Post("/qr/init", authHandler.InitQRLogin)
auth.Post("/qr/poll", authHandler.PollQRLogin)
auth.Post("/qr/approve", authHandler.ScanQRLogin)
auth.Get("/backchannel/jwks.json", authHandler.GetBackchannelLogoutJWKS)
// Signup Routes
signup := auth.Group("/signup")
@@ -708,15 +619,9 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus)
admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection)
admin.Post("/projections/users/reset", requireSuperAdmin, adminHandler.ResetUserProjection)
admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)
// Tenant Management (Mixed roles, handler filters results)
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
admin.Get("/tenants/export", requireSuperAdmin, tenantHandler.ExportTenantsCSV)
admin.Post("/tenants/import", requireSuperAdmin, tenantHandler.ImportTenantsCSV)
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
// [New] Shared Link Management
@@ -736,16 +641,11 @@ func main() {
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview)
admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison)
admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV)
admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun)
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
org := admin.Group("/tenants/:tenantId/organization")
org.Post("/import", orgChartHandler.ImportOrgChart) // Org Chart Bulk Import API
org.Get("/import/progress/:progressId", orgChartHandler.GetImportProgress) // Progress API
org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
org.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
org.Get("/:id", userGroupHandler.Get)
@@ -788,7 +688,7 @@ func main() {
// Admin User Management
admin.Get("/users", requireAnyUser, userHandler.ListUsers)
admin.Get("/users/export", requireAdmin, userHandler.ExportUsersCSV)
admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param
admin.Post("/users", requireAdmin, userHandler.CreateUser)
admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers)
admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers)
@@ -810,8 +710,6 @@ func main() {
dev.Get("/users", devHandler.SearchUsers)
dev.Get("/clients", devHandler.ListClients)
dev.Post("/clients", devHandler.CreateClient)
dev.Get("/clients/:id/users/:userId/metadata", devHandler.GetRPUserMetadata)
dev.Put("/clients/:id/users/:userId/metadata", devHandler.UpsertRPUserMetadata)
dev.Get("/clients/:id", devHandler.GetClient)
dev.Get("/clients/:id/relations", devHandler.ListClientRelations)
dev.Post("/clients/:id/relations", devHandler.AddClientRelation)

Some files were not shown because too many files have changed in this diff Show More