diff --git a/.env.sample b/.env.sample index af98d230..2200fd2d 100644 --- a/.env.sample +++ b/.env.sample @@ -146,6 +146,8 @@ HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc # HYDRA_LOGIN_URL=https://sso.hmac.kr/login # HYDRA_CONSENT_URL=https://sso.hmac.kr/consent # HYDRA_ERROR_URL=https://sso.hmac.kr/error +# Refresh Token 만료시각 source of truth (Hydra + backend ID Token rt_expires_at claim) +HYDRA_REFRESH_TOKEN_TTL=720h # Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택) # 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다. @@ -178,9 +180,9 @@ 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_URL=http://localhost:5175 ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback VITE_ORGCHART_URL= # promtail에서 로그를 전송받을 Loki 서버 엔드포인트 URL LOKI_URL=http://loki:3100/loki/api/v1/push - diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index 362dbd26..c945a6e1 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -131,7 +131,8 @@ jobs: global='^(\.gitea/workflows/code_check\.yml|Makefile|scripts/|tools/|test/code_check_)' front_shared='^(common/|scripts/playwrightPackageVersion\.cjs|scripts/summarize_vitest_coverage\.mjs|scripts/run_adminfront_ci_tests\.sh|\.gitea/workflows/code_check\.yml|Makefile)' - i18n_shared='^(common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)' + i18n_shared='^(locales/|common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)' + react_i18n='^(adminfront/src/locales/|devfront/src/locales/|orgfront/src/locales/)' backend=false userfront=false @@ -154,7 +155,7 @@ jobs: if matches "$front_shared|^adminfront/|^devfront/|^orgfront/"; then biome=true; fi lint=false - if [ "$backend" = true ] || [ "$userfront" = true ] || [ "$adminfront" = true ] || [ "$devfront" = true ] || [ "$orgfront" = true ] || matches "$i18n_shared"; then + if [ "$backend" = true ] || [ "$userfront" = true ] || matches "$global|$i18n_shared|$react_i18n"; then lint=true fi @@ -204,7 +205,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.25" + go-version: "1.26.2" cache-dependency-path: backend/go.sum - name: Setup Flutter @@ -213,42 +214,6 @@ jobs: channel: "stable" cache: true - - name: Install adminfront dependencies - run: | - cd adminfront - npx pnpm install -C ../common --no-frozen-lockfile - npx pnpm install --no-frozen-lockfile - - - name: Biome check adminfront (lint + format) - run: | - cd adminfront - npx biome check . --formatter-enabled=false --assist-enabled=false - npx biome check . --linter-enabled=false --assist-enabled=false - - - name: Install devfront dependencies - run: | - cd devfront - npx pnpm install -C ../common --no-frozen-lockfile - npx pnpm install --no-frozen-lockfile - - - name: Biome check devfront (lint + format) - run: | - cd devfront - npx biome check . --formatter-enabled=false --assist-enabled=false - npx biome check . --linter-enabled=false --assist-enabled=false - - - name: Install orgfront dependencies - run: | - cd orgfront - npx pnpm install -C ../common --no-frozen-lockfile - npx pnpm install --no-frozen-lockfile - - - name: Biome check orgfront (lint + format) - run: | - cd orgfront - npx biome check . --formatter-enabled=false --assist-enabled=false - npx biome check . --linter-enabled=false --assist-enabled=false - - name: Lint Go backend run: | docker run --rm \ @@ -353,7 +318,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.25" + go-version: "1.26.2" cache-dependency-path: backend/go.sum - name: Run backend tests @@ -879,7 +844,7 @@ jobs: adminfront-vitest-coverage: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} runs-on: ubuntu-latest steps: @@ -1010,7 +975,7 @@ jobs: devfront-vitest-coverage: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} runs-on: ubuntu-latest steps: @@ -1141,7 +1106,7 @@ jobs: orgfront-vitest-coverage: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} runs-on: ubuntu-latest steps: @@ -1272,7 +1237,7 @@ jobs: adminfront-tests: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }} runs-on: ubuntu-latest timeout-minutes: 30 @@ -1367,7 +1332,7 @@ jobs: devfront-tests: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }} runs-on: ubuntu-latest steps: @@ -1461,7 +1426,7 @@ jobs: run: | mkdir -p ../reports set +e - pnpm test 2>&1 | tee ../reports/devfront-test.log + pnpm run test:ci 2>&1 | tee ../reports/devfront-test.log test_exit_code=${PIPESTATUS[0]} set -e @@ -1477,7 +1442,7 @@ jobs: echo "1. \`cd devfront\`" echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`" echo "3. \`pnpm exec playwright install --with-deps\`" - echo "4. \`pnpm test\`" + echo "4. \`pnpm run test:ci\`" echo echo "## Log Tail (last 200 lines)" echo '```text' @@ -1550,7 +1515,7 @@ jobs: orgfront-tests: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }} runs-on: ubuntu-latest steps: diff --git a/.gitea/workflows/production_image_deploy.yml b/.gitea/workflows/production_image_deploy.yml new file mode 100644 index 00000000..dfa30803 --- /dev/null +++ b/.gitea/workflows/production_image_deploy.yml @@ -0,0 +1,96 @@ +name: Deploy Baron SSO Production Images + +on: + workflow_dispatch: + inputs: + image_tag: + description: "배포할 공용 저장소 이미지 태그 (예: v1.2606.ab12)" + required: true + type: string + +jobs: + deploy-production-images: + runs-on: ubuntu-latest + steps: + - name: Checkout deployment scripts and templates + uses: actions/checkout@v4 + + - name: Setup SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }} + + - name: Build production deployment bundle + env: + IMAGE_TAG: ${{ github.event.inputs.image_tag }} + IMAGE_DEPLOY_ENV: production + IMAGE_DEPLOY_INSTANCE_NAME: ${{ vars.PROD_INSTANCE_NAME }} + IMAGE_DEPLOY_PORT_PREFIX: ${{ vars.PROD_PORT_PREFIX }} + IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.PROD_FRONTEND_URL }} + IMAGE_DEPLOY_COMPOSE_TEMPLATE: deploy/templates/docker-compose.images.yaml + IMAGE_DEPLOY_BUNDLE_FILE: prod-image-deploy-bundle.tgz + ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }} + DEVFRONT_URL: ${{ vars.DEVFRONT_URL }} + ORGFRONT_URL: ${{ vars.ORGFRONT_URL }} + VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }} + IMAGE_DEPLOY_DB_PORT: ${{ vars.PROD_DB_PORT }} + IMAGE_DEPLOY_REDIS_PORT: ${{ vars.PROD_REDIS_PORT }} + IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP: ${{ vars.PROD_CLICKHOUSE_PORT_HTTP }} + IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE: ${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }} + IMAGE_DEPLOY_BACKEND_PORT: ${{ vars.PROD_BACKEND_PORT }} + IMAGE_DEPLOY_FRONTEND_PORT: ${{ vars.PROD_FRONTEND_PORT }} + ADMINFRONT_PORT: ${{ vars.ADMINFRONT_PORT }} + DEVFRONT_PORT: ${{ vars.DEVFRONT_PORT }} + ORGFRONT_PORT: ${{ vars.ORGFRONT_PORT }} + IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.PROD_OATHKEEPER_PROXY_PORT }} + IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.PROD_DOMAIN_SUFFIX }} + ADMINFRONT_CALLBACK_URLS: ${{ vars.ADMINFRONT_CALLBACK_URLS }} + DEVFRONT_CALLBACK_URLS: ${{ vars.DEVFRONT_CALLBACK_URLS }} + ORGFRONT_CALLBACK_URLS: ${{ vars.ORGFRONT_CALLBACK_URLS }} + HYDRA_REFRESH_TOKEN_TTL: ${{ vars.HYDRA_REFRESH_TOKEN_TTL }} + ORY_POSTGRES_USER: ${{ vars.ORY_POSTGRES_USER }} + ORY_POSTGRES_DB: ${{ vars.ORY_POSTGRES_DB }} + KRATOS_DB: ${{ vars.KRATOS_DB }} + HYDRA_DB: ${{ vars.HYDRA_DB }} + KETO_DB: ${{ vars.KETO_DB }} + KRATOS_VERSION: ${{ vars.KRATOS_VERSION }} + HYDRA_VERSION: ${{ vars.HYDRA_VERSION }} + KETO_VERSION: ${{ vars.KETO_VERSION }} + OATHKEEPER_VERSION: ${{ vars.OATHKEEPER_VERSION }} + ORY_POSTGRES_TAG: ${{ vars.ORY_POSTGRES_TAG }} + OATHKEEPER_UID: ${{ vars.OATHKEEPER_UID }} + OATHKEEPER_GID: ${{ vars.OATHKEEPER_GID }} + OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }} + ADMIN_EMAIL: ${{ vars.ADMIN_EMAIL }} + HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }} + BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend + 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 + IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }} + IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.PROD_ORY_POSTGRES_PASSWORD }} + IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.PROD_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} + IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.PROD_CLICKHOUSE_PASSWORD }} + IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.PROD_COOKIE_SECRET }} + IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.PROD_JWT_SECRET }} + IMAGE_DEPLOY_CSRF_COOKIE_SECRET: ${{ secrets.PROD_CSRF_COOKIE_SECRET }} + IMAGE_DEPLOY_ADMIN_PASSWORD: ${{ secrets.PROD_ADMIN_PASSWORD }} + run: | + set -euo pipefail + # Same image tag contract as staging: production must consume the + # immutable image tag that already passed staging verification. + scripts/deploy/build_image_deploy_bundle.sh + + - name: Upload bundle and run requested production image tag + env: + IMAGE_DEPLOY_BUNDLE_FILE: prod-image-deploy-bundle.tgz + DEPLOY_HOST: ${{ vars.PROD_HOST }} + DEPLOY_USER: ${{ vars.PROD_USER }} + DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }} + HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }} + HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }} + HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }} + run: | + set -euo pipefail + scripts/deploy/upload_and_run_image_deploy.sh diff --git a/.gitea/workflows/production_image_publish.yml b/.gitea/workflows/production_image_publish.yml new file mode 100644 index 00000000..af07e102 --- /dev/null +++ b/.gitea/workflows/production_image_publish.yml @@ -0,0 +1,182 @@ +name: Publish Baron SSO Production Images + +on: + workflow_dispatch: + inputs: + version_prefix: + description: "공용 저장소 이미지 태그 prefix (예: v1.2606, 최종 태그는 v1.2606.<커밋해시4자리>)" + required: true + type: string + +jobs: + publish-images: + runs-on: ubuntu-latest + steps: + - name: Checkout dev branch + uses: actions/checkout@v4 + with: + ref: dev + + - name: Validate publish inputs + env: + VERSION_PREFIX: ${{ github.event.inputs.version_prefix }} + HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }} + HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }} + HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }} + HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }} + ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }} + DEVFRONT_URL: ${{ vars.DEVFRONT_URL }} + ORGFRONT_URL: ${{ vars.ORGFRONT_URL }} + VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }} + run: | + set -euo pipefail + + if ! printf '%s' "${VERSION_PREFIX}" | grep -Eq '^v[0-9]+\.[0-9]{4}$'; then + echo "::error::version_prefix must look like vX.YYMM (got: ${VERSION_PREFIX})" + exit 1 + fi + + required_values=" + HARBOR_ENDPOINT HARBOR_HOSTNAME HARBOR_ROBOT_ACCOUNT HARBOR_ROBOT_KEY + ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY + " + for key in ${required_values}; do + if [ -z "${!key:-}" ]; then + echo "::error::Missing required publish value: ${key}. Check Gitea repo variables/secrets." + exit 1 + fi + done + + - name: Compute commit-hash image tag + id: version + env: + VERSION_PREFIX: ${{ github.event.inputs.version_prefix }} + run: | + set -euo pipefail + + short_sha="$(git rev-parse --short=4 HEAD)" + if ! printf '%s' "${short_sha}" | grep -Eq '^[0-9a-f]{4}$'; then + echo "::error::commit hash suffix must be 4 lowercase hexadecimal characters (got: ${short_sha})" + exit 1 + fi + + image_tag="${VERSION_PREFIX}.${short_sha}" + echo "image_tag=${image_tag}" >> "${GITHUB_OUTPUT}" + echo "Computed production image tag: ${image_tag}" + + - name: Login to shared registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.HARBOR_ENDPOINT }} + username: ${{ vars.HARBOR_ROBOT_ACCOUNT }} + password: ${{ secrets.HARBOR_ROBOT_KEY }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push backend production image + uses: docker/build-push-action@v5 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend:${{ steps.version.outputs.image_tag }} + provenance: false + sbom: false + + - name: Build and push userfront production image + uses: docker/build-push-action@v5 + with: + context: . + file: ./userfront/Dockerfile + target: production + push: true + tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront:${{ steps.version.outputs.image_tag }} + provenance: false + sbom: false + + - name: Build and push adminfront production image + uses: docker/build-push-action@v5 + with: + context: . + file: ./adminfront/Dockerfile + target: production + push: true + tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.version.outputs.image_tag }} + build-args: | + VITE_ADMIN_PUBLIC_URL=${{ vars.ADMINFRONT_URL }} + VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }} + VITE_OIDC_CLIENT_ID=adminfront + ORGFRONT_URL=${{ vars.ORGFRONT_URL }} + provenance: false + sbom: false + + - name: Build and push devfront production image + uses: docker/build-push-action@v5 + with: + context: . + file: ./devfront/Dockerfile + target: production + push: true + tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.version.outputs.image_tag }} + build-args: | + VITE_DEVFRONT_PUBLIC_URL=${{ vars.DEVFRONT_URL }} + VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }} + VITE_OIDC_CLIENT_ID=devfront + ORGFRONT_URL=${{ vars.ORGFRONT_URL }} + provenance: false + sbom: false + + - name: Build and push orgfront production image + uses: docker/build-push-action@v5 + with: + context: . + file: ./orgfront/Dockerfile + target: production + push: true + tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.version.outputs.image_tag }} + build-args: | + VITE_ORGFRONT_PUBLIC_URL=${{ vars.ORGFRONT_URL }} + VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }} + VITE_OIDC_CLIENT_ID=orgfront + provenance: false + sbom: false + + - name: Upload pushed images to WORKS Drive archive + if: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_ARCHIVE_ENABLED == 'true' }} + env: + IMAGE_TAG: ${{ steps.version.outputs.image_tag }} + HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }} + WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR }} + WORKS_DRIVE_TARGET: sharedrive + WORKS_DRIVE_SHARED_DRIVE_ID: ${{ vars.WORKS_DRIVE_SHARED_DRIVE_ID }} + WORKS_DRIVE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_PARENT_FILE_ID }} + WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }} + WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_SECRET }} + WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT }} + WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY }} + WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_OAUTH_REFRESH_TOKEN }} + WORKS_ADMIN_API_BASE_URL: ${{ vars.WORKS_ADMIN_API_BASE_URL }} + WORKS_ADMIN_OAUTH_TOKEN_URL: ${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }} + run: | + set -euo pipefail + + : "${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:=docker-build-image}" + + required_values=" + IMAGE_TAG HARBOR_HOSTNAME WORKS_DRIVE_SHARED_DRIVE_ID + " + for key in ${required_values}; do + if [ -z "${!key:-}" ]; then + echo "::error::Missing required WORKS image archive value: ${key}." + exit 1 + fi + done + + for image in backend userfront adminfront devfront orgfront; do + image_ref="${HARBOR_HOSTNAME}/baron_sso/${image}:${IMAGE_TAG}" + docker pull "${image_ref}" + DOCKER_IMAGE_REF="${image_ref}" \ + WORKS_DOCKER_IMAGE_ARCHIVE_DIR="${RUNNER_TEMP}/baron-sso-docker-image-upload" \ + scripts/docker-image/upload_works_drive.sh + done diff --git a/.gitea/workflows/production_release.yml b/.gitea/workflows/production_release.yml index 9c51ad89..5b9cd194 100644 --- a/.gitea/workflows/production_release.yml +++ b/.gitea/workflows/production_release.yml @@ -124,6 +124,7 @@ jobs: "ORGFRONT_URL=${{ vars.ORGFRONT_URL }}" \ "BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \ "VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}" \ + "HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}" \ "ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}" \ "DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}" \ "ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}" \ @@ -135,7 +136,7 @@ jobs: DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER - USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY + USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY HYDRA_REFRESH_TOKEN_TTL ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS " for key in ${required_dotenv_keys}; do diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index 6b971386..04ae1003 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -80,7 +80,6 @@ jobs: AUDIT_WORKER_COUNT=5 AUDIT_QUEUE_SIZE=2000 PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }} - ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }} 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 }} @@ -116,6 +115,7 @@ jobs: KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }} HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }} HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }} + HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }} JWKS_URL=${{ vars.JWKS_URL }} OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }} OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }} @@ -143,10 +143,6 @@ jobs: LOKI_URL=${{ vars.LOKI_URL || 'http://loki:3100/loki/api/v1/push' }} EOF - if ! grep -Eq "^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.+" .env; then - sed -i "s/^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.*/ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600/" .env - fi - # 코드 업데이트 (Git) ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \ if [ ! -d .git ]; then diff --git a/.gitea/workflows/staging_image_deploy.yml b/.gitea/workflows/staging_image_deploy.yml new file mode 100644 index 00000000..5fafa7a5 --- /dev/null +++ b/.gitea/workflows/staging_image_deploy.yml @@ -0,0 +1,94 @@ +name: Deploy Baron SSO Staging Images + +on: + workflow_dispatch: + inputs: + image_tag: + description: "스테이징에 배포할 공용 저장소 이미지 태그 (예: v1.2606.ab12)" + required: true + type: string + +jobs: + deploy-staging-images: + runs-on: ubuntu-latest + steps: + - name: Checkout deployment scripts and templates + uses: actions/checkout@v4 + + - name: Setup SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }} + + - name: Build staging deployment bundle + env: + IMAGE_TAG: ${{ github.event.inputs.image_tag }} + IMAGE_DEPLOY_ENV: stage + IMAGE_DEPLOY_INSTANCE_NAME: ${{ vars.STAGE_INSTANCE_NAME }} + IMAGE_DEPLOY_PORT_PREFIX: ${{ vars.STAGE_PORT_PREFIX }} + IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.USERFRONT_URL }} + IMAGE_DEPLOY_COMPOSE_TEMPLATE: deploy/templates/docker-compose.images.yaml + IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz + ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }} + DEVFRONT_URL: ${{ vars.DEVFRONT_URL }} + ORGFRONT_URL: ${{ vars.ORGFRONT_URL }} + VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }} + IMAGE_DEPLOY_DB_PORT: ${{ vars.DB_PORT }} + IMAGE_DEPLOY_REDIS_PORT: ${{ vars.REDIS_PORT }} + IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP: ${{ vars.CLICKHOUSE_PORT_HTTP }} + IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE: ${{ vars.CLICKHOUSE_PORT_NATIVE }} + IMAGE_DEPLOY_BACKEND_PORT: ${{ vars.BACKEND_PORT }} + IMAGE_DEPLOY_FRONTEND_PORT: ${{ vars.USERFRONT_PORT }} + ADMINFRONT_PORT: ${{ vars.ADMINFRONT_PORT }} + DEVFRONT_PORT: ${{ vars.DEVFRONT_PORT }} + ORGFRONT_PORT: ${{ vars.ORGFRONT_PORT }} + IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.OATHKEEPER_PROXY_PORT }} + IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.DOMAIN_SUFFIX }} + ADMINFRONT_CALLBACK_URLS: ${{ vars.ADMINFRONT_CALLBACK_URLS }} + DEVFRONT_CALLBACK_URLS: ${{ vars.DEVFRONT_CALLBACK_URLS }} + ORGFRONT_CALLBACK_URLS: ${{ vars.ORGFRONT_CALLBACK_URLS }} + HYDRA_REFRESH_TOKEN_TTL: ${{ vars.HYDRA_REFRESH_TOKEN_TTL }} + ORY_POSTGRES_USER: ${{ vars.ORY_POSTGRES_USER }} + ORY_POSTGRES_DB: ${{ vars.ORY_POSTGRES_DB }} + KRATOS_DB: ${{ vars.KRATOS_DB }} + HYDRA_DB: ${{ vars.HYDRA_DB }} + KETO_DB: ${{ vars.KETO_DB }} + KRATOS_VERSION: ${{ vars.KRATOS_VERSION }} + HYDRA_VERSION: ${{ vars.HYDRA_VERSION }} + KETO_VERSION: ${{ vars.KETO_VERSION }} + OATHKEEPER_VERSION: ${{ vars.OATHKEEPER_VERSION }} + ORY_POSTGRES_TAG: ${{ vars.ORY_POSTGRES_TAG }} + OATHKEEPER_UID: ${{ vars.OATHKEEPER_UID }} + OATHKEEPER_GID: ${{ vars.OATHKEEPER_GID }} + OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }} + ADMIN_EMAIL: ${{ vars.ADMIN_EMAIL }} + HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }} + BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend + 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 + IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.STG_DB_PASSWORD }} + IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.STG_ORY_POSTGRES_PASSWORD }} + IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} + IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }} + IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.STG_COOKIE_SECRET }} + IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.STG_JWT_SECRET }} + IMAGE_DEPLOY_CSRF_COOKIE_SECRET: ${{ secrets.STG_CSRF_COOKIE_SECRET }} + IMAGE_DEPLOY_ADMIN_PASSWORD: ${{ secrets.STG_ADMIN_PASSWORD }} + run: | + set -euo pipefail + scripts/deploy/build_image_deploy_bundle.sh + + - name: Upload bundle and run requested staging image tag + env: + IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz + DEPLOY_HOST: ${{ vars.STAGE_HOST }} + DEPLOY_USER: ${{ vars.STAGE_USER }} + DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }} + HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }} + HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }} + HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }} + run: | + set -euo pipefail + scripts/deploy/upload_and_run_image_deploy.sh diff --git a/.gitea/workflows/staging_release.yml b/.gitea/workflows/staging_release.yml index d15d1338..c8b9d59a 100644 --- a/.gitea/workflows/staging_release.yml +++ b/.gitea/workflows/staging_release.yml @@ -90,7 +90,6 @@ jobs: AUDIT_WORKER_COUNT=5 AUDIT_QUEUE_SIZE=2000 PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }} - ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }} 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 }} @@ -124,6 +123,7 @@ jobs: KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }} HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }} HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }} + HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }} JWKS_URL=${{ vars.JWKS_URL }} OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }} OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }} @@ -143,22 +143,17 @@ jobs: # OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} EOF - if ! grep -Eq "^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.+" .env; then - sed -i "s/^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.*/ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600/" .env - fi - required_dotenv_keys=" APP_ENV BACKEND_LOG_LEVEL CLIENT_LOG_DEBUG WORKS_ADMIN_API_BASE_URL WORKS_ADMIN_OAUTH_TOKEN_URL TZ IDP_PROVIDER DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_HOST CLICKHOUSE_USER CLICKHOUSE_PASSWORD BACKEND_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT USERFRONT_PORT OATHKEEPER_API_URL DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR CORS_ALLOWED_ORIGINS PROFILE_CACHE_TTL - ORGFRONT_ORGCHART_CACHE_TTL_SECONDS NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER ADMIN_EMAIL ADMIN_PASSWORD USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL ORY_POSTGRES_TAG ORY_POSTGRES_USER ORY_POSTGRES_PASSWORD ORY_POSTGRES_DB KRATOS_DB HYDRA_DB KETO_DB KRATOS_VERSION KRATOS_UI_NODE_VERSION HYDRA_VERSION KETO_VERSION ORY_SDK_URL KRATOS_PUBLIC_URL - KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL JWKS_URL + KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL HYDRA_REFRESH_TOKEN_TTL JWKS_URL OATHKEEPER_VERSION OATHKEEPER_UID OATHKEEPER_GID OATHKEEPER_HEALTH_URL OATHKEEPER_HEALTH_INTERVAL_SECONDS OATHKEEPER_HEALTH_TIMEOUT_SECONDS OATHKEEPER_HEALTH_ENABLED CSRF_COOKIE_NAME CSRF_COOKIE_SECRET VITE_OIDC_AUTHORITY ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS diff --git a/.gitignore b/.gitignore index 191012ce..5b269838 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ config/.generated/ .npm-cache/ reports reports/* +/backups/ +/tmp/rp-restore-*/ config/*.pem common/node_modules common/.baron-deps-install.lock diff --git a/Makefile b/Makefile index 99d92498..a1a85749 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,10 @@ endif DUMP_SERVICES ?= all RESTORE_SERVICES ?= all +FILE_PATH ?= +RESTORE_INPUT ?= $(or $(FILE_PATH),$(word 2,$(MAKECMDGOALS))) +CONFIRM_RESTORE ?= +ALLOW_NON_EMPTY_RESTORE ?= false DUMP_MODE ?= maintenance BACKUP_USE_DOCKER ?= true BACKUP_TOOLS_IMAGE ?= baron-sso-backup-tools:local @@ -43,55 +47,108 @@ ifneq (,$(wildcard ./$(AUTH_CONFIG_ENV))) BACKUP_DOCKER_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV) endif BACKUP_DOCKER_RUN = docker run --rm $(BACKUP_DOCKER_ENV_ARGS) -e BACKUP_REPO_ROOT=/workspace -v /var/run/docker.sock:/var/run/docker.sock -v "$(CURDIR)":/workspace -v /tmp:/tmp -w /workspace $(BACKUP_TOOLS_IMAGE) +DOCKER_IMAGE_REF ?= +WORKS_DOCKER_COMMIT_CONTAINER ?= +WORKS_DOCKER_IMAGE_ARCHIVE_DIR ?= /tmp/baron-sso-docker-image-upload -.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app backup-tools-build dump restore dump-verify restore-verify dump-list restore-plan upload-cloud dump-upload-cloud +.PHONY: help 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 ensure-restore-containers up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app backup-tools-build dump restore dump-verify restore-verify dump-list restore-plan upload-cloud works-drive-refresh-token dump-upload-cloud docker-image-upload-works + +help: ## 생성된 타깃과 옵션 목록 표시 + @printf "Usage:\n make [OPTION=value ...]\n\n" + @printf "Targets:\n" + @awk ' \ + BEGIN { current = ""; printed_section = 0 } \ + /^# --- .+ ---/ { \ + current = $$0; \ + gsub(/^# ---[[:space:]]*/, "", current); \ + gsub(/[[:space:]]*---$$/, "", current); \ + next; \ + } \ + /^[[:alnum:]_.-]+:([^=]|$$)/ { \ + line = $$0; \ + target = line; \ + sub(/:.*/, "", target); \ + if (target ~ /^\.|%/) { next } \ + if (seen[target]++) { next } \ + desc = ""; \ + if (line ~ /##/) { \ + desc = line; \ + sub(/^.*##[[:space:]]*/, "", desc); \ + } \ + if (current != "" && current != printed_section) { \ + printf "\n %s\n", current; \ + printed_section = current; \ + } \ + if (desc != "") { \ + printf " %-36s %s\n", target, desc; \ + } else { \ + printf " %-36s\n", target; \ + } \ + } \ + ' Makefile + @printf "\nOptions:\n" + @awk ' \ + /^[A-Z][A-Z0-9_]+[[:space:]]*\?=/ { \ + name = $$1; \ + value = $$0; \ + sub(/[[:space:]]*\?=.*/, "", name); \ + sub(/^[^?]+\?=[[:space:]]*/, "", value); \ + printf " %-32s default: %s\n", name, value; \ + } \ + ' Makefile + @printf "\nRestore Safety:\n" + @printf " CONFIRM_RESTORE=baron-sso 복구 실행 의도를 명시하는 필수 확인값\n" + @printf " ALLOW_NON_EMPTY_RESTORE=true 비어 있지 않은 복구 대상에 덮어쓰는 승인된 복구에서만 사용\n" + @printf "\nRestore Examples:\n" + @printf " make restore-plan FILE_PATH=stg.today.tar.gz CONFIRM_RESTORE=baron-sso\n" + @printf " make restore FILE_PATH=stg.today.tar.gz CONFIRM_RESTORE=baron-sso ALLOW_NON_EMPTY_RESTORE=true\n" # --- 인증 설정 빌드/검증 --- -build-auth-config: +build-auth-config: ## 인증 설정 파일 생성 @echo "Building auth config..." @mkdir -p config/.generated @bash scripts/auth_config.sh build -validate-auth-config: build-auth-config +validate-auth-config: build-auth-config ## 인증 설정 값 검증 @echo "Validating auth config..." @bash scripts/auth_config.sh validate -verify-auth-config: validate-auth-config +verify-auth-config: validate-auth-config ## 인증 설정 연결 상태 확인 @echo "Verifying auth config wiring..." @bash scripts/auth_config.sh verify -render-ory-config: validate-auth-config +render-ory-config: validate-auth-config ## Ory 설정 파일 렌더링 @echo "Rendering Ory config..." @bash scripts/render_ory_config.sh # --- 기본 실행 --- # 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음) -up: up-all +up: up-all ## 전체 로컬 스택 실행 -up-all: ensure-networks render-ory-config +up-all: ensure-networks render-ory-config ## 인프라, Ory, 앱 스택 모두 실행 @echo "Starting ALL stacks (infra + ory + app)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos # --- 개별 스택 실행 --- -up-infra: ensure-networks +up-infra: ensure-networks ## 인프라 스택 실행 @echo "Starting Infra stack (postgres/clickhouse/redis)..." docker compose -f $(COMPOSE_INFRA) up -d -up-ory: ensure-networks render-ory-config +up-ory: ensure-networks render-ory-config ## Ory 스택 실행 @echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos -up-app: ensure-networks render-ory-config +up-app: ensure-networks render-ory-config ## 앱 스택 실행 @echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d -up-backend: ensure-networks render-ory-config +up-backend: ensure-networks render-ory-config ## 백엔드 컨테이너만 실행 @echo "Starting Backend only..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d backend -ensure-networks: +ensure-networks: ## 개발용 Docker 네트워크 보장 @echo "Ensuring Docker networks..." @for network in $(DEV_NETWORKS); do \ if ! docker network inspect "$$network" >/dev/null 2>&1; then \ @@ -102,7 +159,7 @@ ensure-networks: fi; \ done -ensure-infra: ensure-networks +ensure-infra: ensure-networks ## 인프라 스택 실행 상태 보장 @echo "Ensuring Infra stack..." @missing=0; \ for container in $(INFRA_CONTAINERS); do \ @@ -118,7 +175,7 @@ ensure-infra: ensure-networks echo "Infra stack is already running."; \ fi -ensure-ory: ensure-networks render-ory-config +ensure-ory: ensure-networks render-ory-config ## Ory 스택 실행 상태 보장 @echo "Ensuring Ory stack..." @missing=0; \ for container in $(ORY_CONTAINERS); do \ @@ -135,26 +192,74 @@ ensure-ory: ensure-networks render-ory-config docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos; \ fi -up-dev: ensure-infra ensure-ory +ensure-restore-containers: ## 복구 대상 저장소 컨테이너 실행 상태 보장 + @echo "Ensuring restore target containers..." + @if [ "$(CONFIRM_RESTORE)" != "baron-sso" ]; then \ + echo "Skipping restore target container startup until CONFIRM_RESTORE=baron-sso is provided."; \ + exit 0; \ + fi + @$(MAKE) --no-print-directory ensure-networks + @ensure_restore_container() { \ + container="$$1"; \ + compose_file="$$2"; \ + compose_service="$$3"; \ + if docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null | grep -qx 'true'; then \ + echo "Restore target container $$container is already running."; \ + return 0; \ + fi; \ + if docker inspect "$$container" >/dev/null 2>&1; then \ + echo "Starting stopped restore target container $$container..."; \ + docker start "$$container"; \ + else \ + echo "Creating restore target container $$container via $$compose_file service $$compose_service..."; \ + docker compose -f "$$compose_file" up -d "$$compose_service"; \ + fi; \ + for attempt in 1 2 3 4 5 6 7 8 9 10; do \ + if docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null | grep -qx 'true'; then \ + return 0; \ + fi; \ + sleep 1; \ + done; \ + echo "ERROR: restore target container $$container did not reach running state." >&2; \ + return 1; \ + }; \ + services="$(RESTORE_SERVICES)"; \ + if [ -z "$$services" ] || [ "$$services" = "all" ]; then \ + services="postgres ory-postgres clickhouse ory-clickhouse config"; \ + else \ + services="$$(printf '%s' "$$services" | tr ',' ' ')"; \ + fi; \ + for service in $$services; do \ + case "$$service" in \ + postgres) ensure_restore_container baron_postgres compose.infra.yaml postgres ;; \ + ory-postgres) ensure_restore_container ory_postgres compose.ory.yaml postgres ;; \ + clickhouse) ensure_restore_container baron_clickhouse compose.infra.yaml clickhouse ;; \ + ory-clickhouse) ensure_restore_container ory_clickhouse compose.ory.yaml ory_clickhouse ;; \ + config) ;; \ + *) echo "ERROR: unknown restore service: $$service" >&2; exit 1 ;; \ + esac; \ + done + +up-dev: ensure-infra ensure-ory ## 개발 기본 스택 준비 @echo "Dev stack is up (infra + ory)." -up-front-dev: up-infra up-ory up-backend +up-front-dev: up-infra up-ory up-backend ## 프론트 개발용 의존 스택 준비 @echo "Dev stack is up (infra + ory + backend)." -dev: up-dev +dev: up-dev ## 개발 앱 컨테이너를 포그라운드로 실행 @echo "Starting development app containers in foreground attach mode..." BACKEND_LOG_LEVEL=info CLIENT_LOG_DEBUG=false VITE_CLIENT_LOG_DEBUG=false docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES) -dev-debug: up-dev +dev-debug: up-dev ## 디버그 로그로 개발 앱 컨테이너 실행 @echo "Starting development app containers in foreground attach debug mode..." BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true VITE_CLIENT_LOG_DEBUG=true USERFRONT_FLUTTER_RUN_FLAGS=--debug docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES) # --- 종료 (Down) --- -down: +down: ## 전체 로컬 스택 중지 @echo "Stopping ALL stacks (infra + ory + app)..." docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down -drop: +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..." @@ -163,25 +268,25 @@ drop: done @echo "Drop complete. External Docker networks are preserved." -down-app: +down-app: ## 앱 스택 중지 @echo "Stopping App stack..." docker compose -f $(COMPOSE_APP) down -down-backend: +down-backend: ## 백엔드 컨테이너 중지 @echo "Stopping Backend only..." docker compose -f $(COMPOSE_APP) stop backend -down-infra: +down-infra: ## 인프라 스택 중지 @echo "Stopping Infra stack..." docker compose -f $(COMPOSE_INFRA) down -down-ory: +down-ory: ## Ory 스택 중지 @echo "Stopping Ory stack..." docker compose -f $(COMPOSE_ORY) down # --- 유틸리티 --- # 인프라 상태 확인 -check-infra: +check-infra: ## 인프라 헬스 상태 확인 @echo "Checking infra status..." @if [ "$$(docker inspect -f '{{.State.Health.Status}}' baron_postgres 2>/dev/null)" != "healthy" ]; then \ echo "Error: PostgreSQL is not running or not healthy."; \ @@ -191,67 +296,76 @@ check-infra: echo "PostgreSQL is healthy."; \ fi -ps: +ps: ## 전체 Compose 컨테이너 상태 조회 docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) ps -logs-infra: +logs-infra: ## 인프라 스택 로그 팔로우 docker compose -f $(COMPOSE_INFRA) logs -f -logs-ory: +logs-ory: ## Ory 스택 로그 팔로우 docker compose -f $(COMPOSE_ORY) logs -f -logs-app: +logs-app: ## 앱 스택 로그 팔로우 docker compose -f $(COMPOSE_APP) logs -f # --- 백업/복구 --- -backup-tools-build: +backup-tools-build: ## 백업 도구 Docker 이미지 빌드 docker build -f $(BACKUP_TOOLS_DOCKERFILE) -t $(BACKUP_TOOLS_IMAGE) . ifeq ($(BACKUP_USE_DOCKER),true) -dump: backup-tools-build +dump: backup-tools-build ## 백업 덤프 생성 $(BACKUP_DOCKER_RUN) bash -lc 'DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh' -restore: backup-tools-build - $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh' +restore: backup-tools-build ensure-restore-containers ## 백업 덤프 복구 + $(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh' -dump-verify: backup-tools-build +dump-verify: backup-tools-build ## 백업 덤프 검증 $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh' -restore-verify: backup-tools-build +restore-verify: backup-tools-build ## 복구 결과 검증 $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh' -dump-list: backup-tools-build +dump-list: backup-tools-build ## 사용 가능한 백업 덤프 목록 조회 $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh' -restore-plan: backup-tools-build - $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh' +restore-plan: backup-tools-build ## 복구 실행 계획 출력 + $(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh' -upload-cloud: backup-tools-build - $(BACKUP_DOCKER_RUN) bash -lc 'WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh' +upload-cloud: backup-tools-build ## 백업 덤프 클라우드 업로드 + $(BACKUP_DOCKER_RUN) bash -lc '$(if $(WORKS_DRIVE_DRY_RUN),WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" )$(if $(WORKS_DRIVE_AUTH_MODE),WORKS_DRIVE_AUTH_MODE="$(WORKS_DRIVE_AUTH_MODE)" )BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh' + +works-drive-refresh-token: ## WORKS Drive OAuth refresh token 갱신 + WORKS_DRIVE_TOKEN_GRANT="$(WORKS_DRIVE_TOKEN_GRANT)" WORKS_DRIVE_AUTH_CODE="$(WORKS_DRIVE_AUTH_CODE)" WORKS_DRIVE_AUTH_CALLBACK_URL="$(WORKS_DRIVE_AUTH_CALLBACK_URL)" scripts/backup/refresh_works_drive_token.sh else -dump: +dump: ## 백업 덤프 생성 DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh -restore: - BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh +restore: ensure-restore-containers ## 백업 덤프 복구 + RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh -dump-verify: +dump-verify: ## 백업 덤프 검증 BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh -restore-verify: +restore-verify: ## 복구 결과 검증 BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh -dump-list: +dump-list: ## 사용 가능한 백업 덤프 목록 조회 BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh -restore-plan: - BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh +restore-plan: ## 복구 실행 계획 출력 + RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh -upload-cloud: - WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh +upload-cloud: ## 백업 덤프 클라우드 업로드 + $(if $(WORKS_DRIVE_DRY_RUN),WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" )$(if $(WORKS_DRIVE_AUTH_MODE),WORKS_DRIVE_AUTH_MODE="$(WORKS_DRIVE_AUTH_MODE)" )BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh + +works-drive-refresh-token: ## WORKS Drive OAuth refresh token 갱신 + WORKS_DRIVE_TOKEN_GRANT="$(WORKS_DRIVE_TOKEN_GRANT)" WORKS_DRIVE_AUTH_CODE="$(WORKS_DRIVE_AUTH_CODE)" WORKS_DRIVE_AUTH_CALLBACK_URL="$(WORKS_DRIVE_AUTH_CALLBACK_URL)" scripts/backup/refresh_works_drive_token.sh endif -dump-upload-cloud: dump upload-cloud +dump-upload-cloud: dump upload-cloud ## 백업 덤프 생성 후 클라우드 업로드 + +docker-image-upload-works: ## Docker 이미지를 WORKS Shared Drive archive로 업로드 + WORKS_DOCKER_COMMIT_CONTAINER="$(WORKS_DOCKER_COMMIT_CONTAINER)" DOCKER_IMAGE_REF="$(DOCKER_IMAGE_REF)" WORKS_DOCKER_IMAGE_ARCHIVE_DIR="$(WORKS_DOCKER_IMAGE_ARCHIVE_DIR)" scripts/docker-image/upload_works_drive.sh # --- 로컬 통합 코드 체크 --- PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright @@ -268,12 +382,12 @@ CODE_CHECK_TEST_JOBS ?= 1 PLAYWRIGHT_WORKERS ?= 1 FLUTTER_TEST_CONCURRENCY ?= 1 -code-check: code-check-lint code-check-test-jobs +code-check: code-check-lint code-check-test-jobs ## 로컬 CI 상당 코드 검사 실행 @echo "code-check complete." -code-check-lint: code-check-i18n code-check-i18n-values code-check-front-lint code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint +code-check-lint: code-check-i18n code-check-i18n-values code-check-front-lint code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint ## 로컬 린트와 정적 검사 실행 -code-check-test-jobs: +code-check-test-jobs: ## 코드 검사 테스트 작업 실행 @echo "==> run CI-equivalent test jobs (parallel)" @$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \ code-check-backend-tests \ @@ -283,20 +397,20 @@ code-check-test-jobs: code-check-devfront-tests \ code-check-orgfront-tests -code-check-i18n: +code-check-i18n: ## i18n 리소스 검사 @echo "==> i18n resource check" @mkdir -p reports node tools/i18n-scanner/index.js node tools/i18n-scanner/report.js @cat reports/i18n-report.txt -code-check-i18n-values: +code-check-i18n-values: ## i18n 번역 값 품질 검사 @echo "==> i18n value quality check" @mkdir -p reports node tools/i18n-scanner/value-check.js @cat reports/i18n-value-report.txt -code-check-go-lint: +code-check-go-lint: ## Go 포맷과 린트 검사 @echo "==> go lint/format check" @if command -v golangci-lint >/dev/null 2>&1; then \ cd backend && golangci-lint fmt -E gofmt -E gofumpt -d; \ @@ -312,11 +426,11 @@ code-check-go-lint: exit 1; \ fi -code-check-sync-userfront-locales: +code-check-sync-userfront-locales: ## UserFront 로케일 동기화 검사 @echo "==> sync userfront locales" /bin/sh ./scripts/sync_userfront_locales.sh -code-check-userfront-install: +code-check-userfront-install: ## UserFront 의존성 설치 @echo "==> install userfront dependencies" @if command -v flutter >/dev/null 2>&1; then \ cd userfront && flutter pub get; \ @@ -324,7 +438,7 @@ code-check-userfront-install: echo "WARNING: flutter not found, skipping userfront dependencies install."; \ fi -code-check-userfront-lint: +code-check-userfront-lint: ## UserFront 포맷과 analyze 검사 @echo "==> userfront format/analyze" @if command -v dart >/dev/null 2>&1; then \ cd userfront && dart format --output=none --set-exit-if-changed lib test; \ @@ -337,10 +451,14 @@ code-check-userfront-lint: echo "WARNING: flutter not found, skipping userfront analyze."; \ fi -code-check-front-lint: +code-check-front-lint: ## 프론트엔드 Biome 린트와 포맷 검사 @echo "==> adminfront biome lint/format check" rm -rf adminfront/playwright-report adminfront/test-results - cd adminfront && CI=true npx pnpm install --frozen-lockfile --ignore-scripts + @if [ -d adminfront/node_modules ]; then \ + echo "adminfront/node_modules already present; skipping pnpm install."; \ + else \ + cd adminfront && CI=true npx pnpm install --frozen-lockfile --ignore-scripts; \ + fi cd adminfront && npx biome lint . cd adminfront && npx biome format . @echo "==> devfront biome lint/format check" @@ -354,15 +472,19 @@ code-check-front-lint: cd devfront && npx biome format . @echo "==> orgfront biome lint/format check" rm -rf orgfront/playwright-report orgfront/test-results - cd orgfront && npm ci --ignore-scripts - cd orgfront && npx biome lint . - cd orgfront && npx biome format . + @if [ -d orgfront/node_modules ]; then \ + echo "orgfront/node_modules already present; skipping npm install."; \ + else \ + cd orgfront && npm ci --ignore-scripts; \ + fi + cd orgfront && ./node_modules/@biomejs/biome/bin/biome lint . + cd orgfront && ./node_modules/@biomejs/biome/bin/biome format . -code-check-backend-tests: +code-check-backend-tests: ## 백엔드 Go 테스트 실행 @echo "==> backend tests" cd backend && GOCACHE=/tmp/baron-sso-go-cache go test -v ./... -code-check-userfront-tests: +code-check-userfront-tests: ## UserFront Flutter 테스트 실행 @echo "==> userfront tests (isolated workspace)" @if ! command -v flutter >/dev/null 2>&1; then \ echo "WARNING: flutter not found, skipping userfront tests."; \ @@ -388,11 +510,11 @@ code-check-userfront-tests: cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \ cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY) -code-check-adminfront-tests: +code-check-adminfront-tests: ## AdminFront 테스트 실행 @echo "==> adminfront tests" PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests -code-check-devfront-tests: +code-check-devfront-tests: ## DevFront 테스트 실행 @echo "==> devfront tests" @mkdir -p reports/devfront @rm -rf reports/devfront/playwright-report reports/devfront/test-results @@ -415,7 +537,7 @@ code-check-devfront-tests: [ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \ exit $$status -code-check-orgfront-tests: +code-check-orgfront-tests: ## OrgFront 테스트 실행 @echo "==> orgfront tests" @mkdir -p reports/orgfront @rm -rf reports/orgfront/playwright-report reports/orgfront/test-results @@ -431,7 +553,7 @@ code-check-orgfront-tests: [ -d orgfront/test-results ] && cp -R orgfront/test-results reports/orgfront/ || true; \ exit $$status -code-check-userfront-e2e-tests: +code-check-userfront-e2e-tests: ## UserFront WASM E2E 테스트 실행 @echo "==> userfront wasm playwright e2e tests (isolated workspace)" @if ! command -v flutter >/dev/null 2>&1; then \ echo "WARNING: flutter not found, skipping userfront e2e tests."; \ diff --git a/README.md b/README.md index 50e459ed..457b1a1e 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,20 @@ baron_sso/ * AdminFront: 사용자 관리 등 Admin 기능 * DevFront: RP 관리 등 개발자 기능 +## 개발 실행 정책 + +`make dev`는 로컬 개발용 실행 모드이며, React 기반 `adminfront`, `devfront`, `orgfront`는 모두 Vite HMR 모드로 동작해야 합니다. 이 세 서비스는 Docker Compose에서 Dockerfile `dev` target을 사용하고 `/workspace/` bind mount 위에서 `npm run dev -- --host 0.0.0.0`로 실행합니다. `make dev` 경로에서 production `dist`를 `serve_frontend_prod.mjs`로 정적 서빙하면 안 됩니다. + +현재 개발 포트는 다음과 같습니다. + +- AdminFront: `http://localhost:5173` +- DevFront: `http://localhost:5174` +- OrgFront: `http://localhost:5175` + +자세한 정책과 회귀 테스트는 [make dev Vite HMR Policy](docs/make-dev-vite-hmr-policy.md)를 확인하세요. 정책 회귀는 `test/frontend_dev_bind_mount_policy_test.sh`에서 검사합니다. + +로컬 Playwright E2E도 기본적으로 Vite dev server를 봅니다. Gitea Actions 같은 CI에서는 `CI=true`로 production bundle을 `vite preview`로 검증합니다. 로컬에서 production bundle을 명시적으로 검증하려면 `PLAYWRIGHT_USE_PREVIEW=true`를 사용하세요. 이 정책은 `test/playwright_frontend_runtime_policy_test.sh`에서 검사합니다. + ## 🏗 아키텍처 (Architecture) @@ -380,21 +394,23 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비 ### SSOT 및 Redis Cache 전략 -Baron SSO는 “하나의 DB가 모든 데이터의 원본”인 구조가 아닙니다. 데이터 성격별로 원장이 다르며, Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane입니다. Redis와 PostgreSQL projection은 성능과 운영 편의를 위한 read model/cache로만 사용하고, 원장과 불일치할 수 있다는 전제를 명시합니다. +Baron SSO의 인증, 권한, OAuth/OIDC 원장은 Ory Stack입니다. Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane입니다. 사용자 identity/profile/소속/조직도 노출 데이터에 대해 Backend DB `users`를 원장 또는 read model로 사용하지 않습니다. Redis는 Ory 원장 데이터의 성능 cache/mirror로만 사용합니다. + +Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend가 직접 소비하지 않습니다. Backend가 Redis mirror 또는 Ory Admin API fallback을 기준으로 cursor 기반 API를 adminfront, orgfront, userfront, 외부 API에 제공합니다. #### 데이터별 원본 위치 | 데이터 | SSOT | 보조 저장소/캐시 | 비고 | | --- | --- | --- | --- | -| Identity subject, credentials, recovery/verification address | Ory Kratos `identities` | Redis identity mirror, PostgreSQL `users.id` 참조 | Kratos identity ID가 사용자 subject이며 WORKS `externalKey` 기준입니다. | -| 로그인 식별자 | Kratos traits, `user_login_ids` | Redis identity mirror | Kratos는 인증 식별자, PostgreSQL은 중복/정책 검증용 index입니다. | -| 사용자 이름, 이메일, 전화번호, role 기본값 | Kratos traits | PostgreSQL `users`, Redis mirror | 인증/profile 계산에 필요한 최소 identity 값만 Kratos에 유지합니다. | -| Baron 사용자 상태, soft delete, 운영 메타데이터 | PostgreSQL `users`, `users.metadata` | Redis mirror 조합 응답 | `users.deleted_at`은 Baron 운영 상태이며 Kratos identity 삭제와 같은 의미가 아닙니다. | -| 테넌트 tree, slug, 조직/부서/직무/직책 | PostgreSQL `tenants`, `users`, membership metadata | Redis/API response cache 가능 | 관계형 조직 데이터는 Kratos traits가 아니라 Backend DB가 원장입니다. | +| Identity subject, credentials, recovery/verification address | Ory Kratos `identities` | Redis identity mirror | Kratos identity ID가 사용자 subject이며 WORKS `externalKey` 기준입니다. | +| 로그인 식별자 | Ory Kratos traits | Redis identity mirror/index | Kratos가 인증 식별자의 원장입니다. | +| 사용자 이름, 이메일, 전화번호, role 기본값 | Ory Kratos traits | Redis identity mirror | 인증/profile 계산에 필요한 identity 값은 Kratos 기준으로 유지합니다. | +| Baron 사용자 운영 상태, soft delete, 운영 메타데이터 | Ory Kratos traits/state 또는 별도 명시 원장 | Redis mirror/cache | Backend DB `users`를 사용자 read model로 사용하지 않습니다. | +| 테넌트 tree, slug, 조직/부서/직무/직책 | Ory Keto relation tuple, Backend read model | Redis/API response cache 가능 | 권한/관계 판단은 Keto가 원장입니다. Ory가 보관하거나 조회할 수 없는 조직 표시/검색 데이터만 Backend read model에 둡니다. | | 권한/관계 | Ory Keto relation tuple | PostgreSQL outbox/status | Backend를 통해 relation command를 보내고 처리 상태를 추적합니다. | | OAuth2/OIDC client, consent, token state | Ory Hydra | PostgreSQL `client_consents`, audit/read model | Hydra가 프로토콜 원장이며 로컬 테이블은 운영 조회/감사용입니다. | -| RP별 사용자 custom claim 값 | PostgreSQL `rp_user_metadata` | ID token/userinfo projection | RP 관리자 범위 데이터이며 전역 claim과 분리합니다. | -| 전역 사용자 custom claim 값 | PostgreSQL `users.metadata.global_custom_claims` | ID token projection | 전체 사용자 대상 claim으로 adminfront 사용자 상세에서만 관리합니다. | +| RP별 사용자 custom claim 값 | Backend read model `rp_user_metadata` | ID token/userinfo claim assembly | Ory에 저장되지 않는 RP 범위 데이터입니다. Kratos traits나 claim output을 SSOT로 취급하지 않습니다. | +| 전역 사용자 custom claim 값 | Backend read model `users.metadata.global_custom_claims` | ID token claim assembly | Ory에 저장되지 않는 운영 범위 custom 값입니다. | | WORKS Mobile mapping/outbox/job 상태 | PostgreSQL `worksmobile_*` | WORKS API 비교 응답 cache 가능 | 외부 SaaS 연동 상태이며 identity 원장이 아닙니다. | | 감사 로그/사용량 | ClickHouse, Oathkeeper/Ory 로그 | 화면별 summary cache 가능 | command와 보안 이벤트의 감사 원장입니다. | | Headless JWKS 검증 상태 | Redis `headless:jwks:*` cache | DevFront 상태 카드 | RP public key 문서 자체는 외부 `jwksUri`가 원본입니다. | @@ -403,11 +419,11 @@ Baron SSO는 “하나의 DB가 모든 데이터의 원본”인 구조가 아 #### SSOT 보장 원칙 1. Kratos/Hydra/Keto/WORKS로 향하는 쓰기 command는 Backend를 통과합니다. -2. Backend는 원장 write 성공 후 원장 ID를 기준으로 재조회하고, PostgreSQL read model 또는 Redis mirror를 write-through 갱신합니다. +2. Backend는 Ory write 성공 후 원장 ID를 기준으로 Ory를 재조회하고, Redis mirror를 갱신하거나 stale로 표시합니다. 사용자 identity/profile/소속 데이터는 Backend DB `users`에 read model로 갱신하지 않습니다. 3. write-through 갱신 실패 시 원장 write를 되돌린 것으로 간주하지 않습니다. 대신 mirror/cache 상태를 `stale` 또는 `failed`로 표시하고 drift report와 refresh 대상으로 둡니다. 4. Kratos Admin API 또는 Kratos DB를 Backend 밖에서 직접 수정하는 경로는 운영 정책상 금지합니다. 정비/DR처럼 예외가 필요한 경우에는 Redis mirror를 stale로 표시하고, full refresh와 drift report를 완료하기 전까지 cache 결과를 신뢰하지 않습니다. -5. PostgreSQL projection은 Kratos partial list를 full snapshot처럼 취급하지 않습니다. Kratos 목록 조회가 partial이면 로컬 사용자를 삭제/숨김 처리하지 않습니다. -6. frontend 대량 조회는 cursor 기반을 원칙으로 합니다. `limit=5000&offset=0` 같은 단일 대량 offset 조회는 사용자 수가 늘면 partial data를 전체처럼 보이게 만들 수 있으므로 신규 구현에서 금지합니다. +5. Backend DB `users`나 Redis cache는 Kratos partial list를 full snapshot처럼 취급하지 않습니다. Kratos 목록 조회가 partial이면 로컬 사용자 데이터를 근거로 정상 목록을 만들지 않습니다. +6. frontend/API 대량 조회는 Backend가 제공하는 cursor 기반을 원칙으로 합니다. `limit=5000&offset=0` 같은 단일 대량 offset 조회는 사용자 수가 늘면 partial data를 전체처럼 보이게 만들 수 있으므로 신규 구현에서 금지합니다. 7. Redis cache miss가 발생한 단건 조회는 가능한 경우 SSOT로 fallback하고, fallback 성공 시 Redis를 갱신합니다. 목록 조회는 mirror 상태가 `ready`가 아니면 화면/API에 경고 상태를 함께 전달해야 합니다. #### Redis 사용 원칙 @@ -417,7 +433,7 @@ Redis는 원장이 아니라 cache/mirror 계층입니다. Redis 데이터 유 | Redis 데이터 | 역할 | TTL/보존 정책 | 장애 시 처리 | | --- | --- | --- | --- | | `identity:mirror:{identityID}` | Kratos identity summary 단건 cache | 장기 mirror. refresh 상태와 함께 운영 | Kratos `GetIdentity` fallback 후 write-through | -| `identity:index:*` | identity 목록/검색 cursor index | mirror refresh 주기로 재작성 | `stale` 표시 후 full refresh | +| `identity:index:*` | Backend cursor API용 identity 목록/검색 index | mirror refresh 주기로 재작성 | `stale` 표시 후 full refresh | | `identity:mirror:state` | mirror 상태, count, last error | 영구 상태 key | adminfront에서 경고 표시 | | `headless:jwks:*` | RP headless login JWKS cache | JWKS TTL과 prefetch 정책 | kid miss/검증 실패/TTL 만료 시 재조회 | | login/verification/pending 계열 key | 인증 흐름의 단기 상태 | 짧은 TTL 필수 | 만료 또는 유실 시 사용자가 흐름 재시작 | diff --git a/adminfront/Dockerfile b/adminfront/Dockerfile index 305e3026..1c3e21dc 100644 --- a/adminfront/Dockerfile +++ b/adminfront/Dockerfile @@ -1,4 +1,4 @@ -FROM node:lts AS build +FROM node:lts AS deps WORKDIR /workspace @@ -22,6 +22,17 @@ ENV ORGFRONT_URL=$ORGFRONT_URL RUN pnpm install --frozen-lockfile --ignore-scripts +FROM deps AS dev + +WORKDIR /workspace/adminfront +ENV NODE_ENV=development + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] + +FROM deps AS build + WORKDIR /workspace/adminfront RUN npm run build diff --git a/adminfront/Trace-20260615T113806.json.gz b/adminfront/Trace-20260615T113806.json.gz new file mode 100644 index 00000000..e15ba30b Binary files /dev/null and b/adminfront/Trace-20260615T113806.json.gz differ diff --git a/adminfront/e2e-evidence/tenant-profile-performance-local.json b/adminfront/e2e-evidence/tenant-profile-performance-local.json new file mode 100644 index 00000000..0eae7c94 --- /dev/null +++ b/adminfront/e2e-evidence/tenant-profile-performance-local.json @@ -0,0 +1,134 @@ +{ + "metric": "tenant-profile-local-performance", + "tenantId": "56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "actualApiBaseUrl": "http://localhost:5173/api", + "measuredAt": "2026-06-16T23:45:00.441Z", + "browser": "chromium", + "samples": [ + { + "sample": 1, + "configFieldsVisibleMs": 424, + "networkIdleMs": 862, + "orgUnitType": "센터", + "visibility": "public", + "worksmobileSync": "enabled", + "apiTimings": [ + { + "method": "GET", + "url": "http://playwright-mock/api/v1/user/me", + "status": 200, + "durationMs": 134 + }, + { + "method": "GET", + "url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "status": 200, + "durationMs": 184 + } + ] + }, + { + "sample": 2, + "configFieldsVisibleMs": 376, + "networkIdleMs": 751, + "orgUnitType": "센터", + "visibility": "public", + "worksmobileSync": "enabled", + "apiTimings": [ + { + "method": "GET", + "url": "http://playwright-mock/api/v1/user/me", + "status": 200, + "durationMs": 20 + }, + { + "method": "GET", + "url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "status": 200, + "durationMs": 133 + } + ] + }, + { + "sample": 3, + "configFieldsVisibleMs": 400, + "networkIdleMs": 797, + "orgUnitType": "센터", + "visibility": "public", + "worksmobileSync": "enabled", + "apiTimings": [ + { + "method": "GET", + "url": "http://playwright-mock/api/v1/user/me", + "status": 200, + "durationMs": 21 + }, + { + "method": "GET", + "url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "status": 200, + "durationMs": 156 + } + ] + }, + { + "sample": 4, + "configFieldsVisibleMs": 431, + "networkIdleMs": 843, + "orgUnitType": "센터", + "visibility": "public", + "worksmobileSync": "enabled", + "apiTimings": [ + { + "method": "GET", + "url": "http://playwright-mock/api/v1/user/me", + "status": 200, + "durationMs": 25 + }, + { + "method": "GET", + "url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "status": 200, + "durationMs": 178 + } + ] + }, + { + "sample": 5, + "configFieldsVisibleMs": 380, + "networkIdleMs": 758, + "orgUnitType": "센터", + "visibility": "public", + "worksmobileSync": "enabled", + "apiTimings": [ + { + "method": "GET", + "url": "http://playwright-mock/api/v1/user/me", + "status": 200, + "durationMs": 24 + }, + { + "method": "GET", + "url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "status": 200, + "durationMs": 129 + } + ] + } + ], + "summary": { + "configFieldsVisibleMs": { + "min": 376, + "max": 431, + "p50": 400, + "p95": 431 + }, + "networkIdleMs": { + "min": 751, + "max": 862, + "p50": 797, + "p95": 862 + } + }, + "screenshotPath": "/home/lectom/repos/baron-sso/adminfront/e2e-evidence/tenant-profile-performance-local.png" +} diff --git a/adminfront/e2e-evidence/tenant-profile-performance-local.png b/adminfront/e2e-evidence/tenant-profile-performance-local.png new file mode 100644 index 00000000..a6352ed4 Binary files /dev/null and b/adminfront/e2e-evidence/tenant-profile-performance-local.png differ diff --git a/adminfront/playwright.config.ts b/adminfront/playwright.config.ts index e2b56df4..5e7f8b81 100644 --- a/adminfront/playwright.config.ts +++ b/adminfront/playwright.config.ts @@ -14,6 +14,8 @@ const port = Number.parseInt(process.env.PORT ?? "5173", 10); const defaultBaseUrl = `http://127.0.0.1:${port}`; const baseURL = process.env.BASE_URL ?? defaultBaseUrl; const reuseExistingServer = !process.env.CI && !process.env.PORT; +const usePreviewServer = + process.env.CI === "true" || process.env.PLAYWRIGHT_USE_PREVIEW === "true"; const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; /** @@ -84,7 +86,7 @@ export default defineConfig({ webServer: process.env.BASE_URL ? undefined : { - command: process.env.CI + command: usePreviewServer ? `pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort` : `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`, url: `http://127.0.0.1:${port}`, diff --git a/adminfront/seed-tenant.csv b/adminfront/seed-tenant.csv index 5eabc9bd..f5db24ef 100644 --- a/adminfront/seed-tenant.csv +++ b/adminfront/seed-tenant.csv @@ -10,4 +10,7 @@ b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-s e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr,,, 4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,MH_manager,USER_GROUP,hanmac-family,mhd,맨아워 대시보드 권한 보유자그룹,,private,,no e41adf79-3d15-4807-8303-afbdb0f2bab7,SW_uploader,USER_GROUP,hanmac-family,sw-uploader,소프트웨어 배포 권한 그룹,,private,,no -9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,, +ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916,일반회사,COMPANY_GROUP,,commercial,외부 기업회원 루트 테넌트,,,, +d19c10f0-0224-4bbb-bf3e-ce579c5338ea,공공기관,COMPANY_GROUP,,public-org,공공기관 기본 루트 테넌트,,,, +78accec5-8eba-4324-b8f1-10ab360011fe,교육/학생,COMPANY_GROUP,,edu,교육기관 및 학생 기본 루트 테넌트,,,, +9607eb7b-04d2-42ab-80fe-780fe21c7e8f,개인사용자,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,, diff --git a/adminfront/src/app/routes.test.tsx b/adminfront/src/app/routes.test.tsx index 64ff5323..ba169a42 100644 --- a/adminfront/src/app/routes.test.tsx +++ b/adminfront/src/app/routes.test.tsx @@ -28,16 +28,33 @@ describe("admin routes", () => { expect(matches?.at(-1)?.route.path).toBe("system/data-integrity"); }); - it("routes global custom claim settings before user detail id matching", () => { + it("routes global custom claim settings before user detail id matching", async () => { const matches = matchRoutes(adminRoutes, "/users/custom-claims"); const leafRoute = matches?.at(-1)?.route; expect(leafRoute?.path).toBe("users/custom-claims"); - expect(getRouteElementName(leafRoute?.element)).toBe( + expect(await getRouteComponentName(leafRoute)).toBe( "GlobalCustomClaimsPage", ); }); + it("code-splits tenant detail profile routes away from the initial admin shell", () => { + const matches = matchRoutes( + adminRoutes, + "/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + ); + const detailRoute = matches?.find( + (match) => match.route.path === "tenants/:tenantId", + )?.route; + const profileRoute = matches?.at(-1)?.route; + + expect(detailRoute?.element).toBeUndefined(); + expect(typeof detailRoute?.lazy).toBe("function"); + expect(profileRoute?.index).toBe(true); + expect(profileRoute?.element).toBeUndefined(); + expect(typeof profileRoute?.lazy).toBe("function"); + }); + it("keeps protected admin pages behind an auth guard before mounting the layout", () => { const rootRoute = adminRoutes.find((route) => route.path === "/"); const protectedShellRoute = rootRoute?.children?.[0]; @@ -48,6 +65,29 @@ describe("admin routes", () => { }); }); +async function getRouteComponentName(route: unknown) { + if ( + typeof route === "object" && + route !== null && + "lazy" in route && + typeof route.lazy === "function" + ) { + const lazyRoute = await route.lazy(); + if ("Component" in lazyRoute && typeof lazyRoute.Component === "function") { + return lazyRoute.Component.name; + } + if ("element" in lazyRoute) { + return getRouteElementName(lazyRoute.element); + } + } + + if (typeof route === "object" && route !== null && "element" in route) { + return getRouteElementName(route.element); + } + + return undefined; +} + function getRouteElementName(element: unknown) { if ( typeof element === "object" && diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 339a14d7..1b04e751 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -1,30 +1,33 @@ +import type { ComponentType } from "react"; import type { RouteObject } from "react-router-dom"; import { createBrowserRouter } from "react-router-dom"; import AppLayout from "../components/layout/AppLayout"; -import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage"; -import ApiKeyListPage from "../features/api-keys/ApiKeyListPage"; -import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuthCallbackPage from "../features/auth/AuthCallbackPage"; import AuthGuard from "../features/auth/AuthGuard"; -import AuthPage from "../features/auth/AuthPage"; import LoginPage from "../features/auth/LoginPage"; -import DataIntegrityPage from "../features/integrity/DataIntegrityPage"; -import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; -import UserProjectionPage from "../features/projections/UserProjectionPage"; -import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab"; -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 GlobalCustomClaimsPage from "../features/users/GlobalCustomClaimsPage"; -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"; +type RouteModule = { + default: ComponentType; +}; + +function lazyDefault(loader: () => Promise) { + return async () => { + const module = await loader(); + return { Component: module.default }; + }; +} + +function lazyNamed( + loader: () => Promise, + key: TKey, +) { + return async () => { + const module = await loader(); + return { Component: module[key] as ComponentType }; + }; +} + export const adminRoutes: RouteObject[] = [ { path: "/login", @@ -41,34 +44,147 @@ export const adminRoutes: RouteObject[] = [ { element: , children: [ - { index: true, element: }, - { path: "audit-logs", element: }, - { path: "auth", element: }, - { path: "users", element: }, - { path: "users/custom-claims", element: }, - { path: "users/new", element: }, - { path: "users/:id", element: }, - { path: "tenants", element: }, - { path: "tenants/new", element: }, - { path: "worksmobile", element: }, + { + index: true, + lazy: lazyDefault( + () => import("../features/overview/GlobalOverviewPage"), + ), + }, + { + path: "audit-logs", + lazy: lazyDefault(() => import("../features/audit/AuditLogsPage")), + }, + { + path: "auth", + lazy: lazyDefault(() => import("../features/auth/AuthPage")), + }, + { + path: "users", + lazy: lazyDefault(() => import("../features/users/UserListPage")), + }, + { + path: "users/custom-claims", + lazy: lazyDefault( + () => import("../features/users/GlobalCustomClaimsPage"), + ), + }, + { + path: "users/new", + lazy: lazyDefault(() => import("../features/users/UserCreatePage")), + }, + { + path: "users/:id", + lazy: lazyDefault(() => import("../features/users/UserDetailPage")), + }, + { + path: "tenants", + lazy: lazyDefault( + () => import("../features/tenants/routes/TenantListPage"), + ), + }, + { + path: "tenants/new", + lazy: lazyDefault( + () => import("../features/tenants/routes/TenantCreatePage"), + ), + }, + { + path: "worksmobile", + lazy: lazyNamed( + () => import("../features/tenants/routes/TenantWorksmobilePage"), + "TenantWorksmobilePage", + ), + }, + { + path: "permissions-direct", + lazy: lazyNamed( + () => + import( + "../features/tenants/routes/TenantFineGrainedPermissionsPage" + ), + "TenantFineGrainedPermissionsPage", + ), + }, { path: "tenants/:tenantId", - element: , + lazy: lazyDefault( + () => import("../features/tenants/routes/TenantDetailPage"), + ), children: [ - { index: true, element: }, - { path: "permissions", element: }, - { path: "organization", element: }, - { path: "schema", element: }, + { + index: true, + lazy: lazyNamed( + () => import("../features/tenants/routes/TenantProfilePage"), + "TenantProfilePage", + ), + }, + { + path: "permissions", + lazy: lazyNamed( + () => + import( + "../features/tenants/routes/TenantAdminsAndOwnersTab" + ), + "TenantAdminsAndOwnersTab", + ), + }, + { + path: "organization", + lazy: lazyDefault( + () => + import( + "../features/user-groups/routes/TenantUserGroupsTab" + ), + ), + }, + { + path: "schema", + lazy: lazyNamed( + () => import("../features/tenants/routes/TenantSchemaPage"), + "TenantSchemaPage", + ), + }, + { + path: "relations", + lazy: lazyNamed( + () => + import( + "../features/tenants/routes/TenantFineGrainedPermissionsTab" + ), + "TenantFineGrainedPermissionsTab", + ), + }, ], }, { path: "tenants/:tenantId/organization/:id", - element: , + lazy: lazyDefault( + () => + import("../features/user-groups/routes/TenantUserGroupsTab"), + ), + }, + { + path: "api-keys", + lazy: lazyDefault( + () => import("../features/api-keys/ApiKeyListPage"), + ), + }, + { + path: "api-keys/new", + lazy: lazyDefault( + () => import("../features/api-keys/ApiKeyCreatePage"), + ), + }, + { + path: "system/ory-ssot", + lazy: lazyDefault(() => import("../features/ory-ssot/OrySSOTPage")), + }, + { + path: "system/data-integrity", + lazy: lazyDefault( + () => import("../features/integrity/DataIntegrityPage"), + ), }, - { path: "api-keys", element: }, - { path: "api-keys/new", element: }, - { path: "system/ory-ssot", element: }, - { path: "system/data-integrity", element: }, ], }, ], diff --git a/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx b/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx index 24e945d8..c1ab9f60 100644 --- a/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx +++ b/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx @@ -31,4 +31,27 @@ describe("LocaleRefreshBoundary", () => { expect(screen.getByText("2")).toBeInTheDocument(); }); + + it("ignores storage events unrelated to locale changes", async () => { + render( + + + , + ); + + expect(screen.getByText("1")).toBeInTheDocument(); + + await act(async () => { + window.dispatchEvent( + new StorageEvent("storage", { + key: "admin_session", + newValue: "token", + oldValue: null, + storageArea: window.localStorage, + }), + ); + }); + + expect(screen.getByText("1")).toBeInTheDocument(); + }); }); diff --git a/adminfront/src/components/common/LocaleRefreshBoundary.tsx b/adminfront/src/components/common/LocaleRefreshBoundary.tsx index 64cc3841..370bed50 100644 --- a/adminfront/src/components/common/LocaleRefreshBoundary.tsx +++ b/adminfront/src/components/common/LocaleRefreshBoundary.tsx @@ -1,4 +1,5 @@ import { Fragment, type ReactNode, useEffect, useState } from "react"; +import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n"; type LocaleRefreshBoundaryProps = { children: ReactNode; @@ -12,12 +13,19 @@ function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) { setLocaleVersion((current) => current + 1); }; + const syncLocaleFromStorage = (event: StorageEvent) => { + if (event.key !== LOCALE_STORAGE_KEY && event.key !== null) { + return; + } + syncLocale(); + }; + window.addEventListener("localechange", syncLocale); - window.addEventListener("storage", syncLocale); + window.addEventListener("storage", syncLocaleFromStorage); return () => { window.removeEventListener("localechange", syncLocale); - window.removeEventListener("storage", syncLocale); + window.removeEventListener("storage", syncLocaleFromStorage); }; }, []); diff --git a/adminfront/src/components/layout/AppLayout.test.tsx b/adminfront/src/components/layout/AppLayout.test.tsx index 47c5d890..47f44e2f 100644 --- a/adminfront/src/components/layout/AppLayout.test.tsx +++ b/adminfront/src/components/layout/AppLayout.test.tsx @@ -116,6 +116,7 @@ describe("admin AppLayout", () => { "Ory SSOT System", "Data Integrity", "Users", + "권한 부여", "Auth Guard", "API Keys", "Audit Logs", diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 3cf9fcfd..07493e1c 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -62,6 +62,12 @@ const staticNavItems: ShellSidebarNavItem[] = [ to: "/users", icon: Users, }, + { + labelKey: "ui.admin.nav.permissions_direct", + labelFallback: "권한 부여", + to: "/permissions-direct", + icon: ShieldCheck, + }, { labelKey: "ui.admin.nav.auth_guard", labelFallback: "Auth Guard", @@ -206,70 +212,71 @@ function AppLayout() { ...profile, role: effectiveRole ?? profile?.role, }); - const filteredItems = items.filter((item) => { - if (item.to === "/api-keys") return isSuperAdmin; - return true; - }); - const orgfrontUrl = buildAuthenticatedOrgChartUrl( import.meta.env.ORGFRONT_URL || "http://localhost:5175", - { includeInternal: true }, + { includeInternal: false }, ); - if (isSuperAdmin) { - filteredItems.splice(1, 0, { - labelKey: "ui.admin.nav.tenants", - labelFallback: "Tenants", - to: "/tenants", - icon: Building2, - }); - filteredItems.splice(2, 0, { - labelKey: "ui.admin.nav.org_chart", - labelFallback: "Org Chart", - to: orgfrontUrl, - icon: Network, - isExternal: true, - }); - if (showWorksmobile) { - filteredItems.splice(3, 0, { - labelKey: "ui.admin.nav.worksmobile", - labelFallback: "Worksmobile", - to: "/worksmobile", - icon: LineWorksNavIcon, - }); - } - filteredItems.splice(4, 0, { - labelKey: "ui.admin.nav.ory_ssot", - labelFallback: "Ory SSOT System", - to: "/system/ory-ssot", - icon: Database, - }); - filteredItems.splice(5, 0, { - labelKey: "ui.admin.nav.data_integrity", - labelFallback: "Data Integrity", - to: "/system/data-integrity", - icon: ShieldCheck, - }); - } else { - // Non-superadmins - filteredItems.splice(1, 0, { - labelKey: "ui.admin.nav.org_chart", - labelFallback: "Org Chart", - to: orgfrontUrl, - icon: Network, - isExternal: true, - }); - if (showWorksmobile) { - filteredItems.splice(2, 0, { - labelKey: "ui.admin.nav.worksmobile", - labelFallback: "Worksmobile", - to: "/worksmobile", - icon: LineWorksNavIcon, - }); - } - } + // Splice optional menus in a standard order + items.splice(1, 0, { + labelKey: "ui.admin.nav.tenants", + labelFallback: "Tenants", + to: "/tenants", + icon: Building2, + }); + items.splice(2, 0, { + labelKey: "ui.admin.nav.org_chart", + labelFallback: "Org Chart", + to: orgfrontUrl, + icon: Network, + isExternal: true, + }); + items.splice(3, 0, { + labelKey: "ui.admin.nav.worksmobile", + labelFallback: "Worksmobile", + to: "/worksmobile", + icon: LineWorksNavIcon, + }); + items.splice(4, 0, { + labelKey: "ui.admin.nav.ory_ssot", + labelFallback: "Ory SSOT System", + to: "/system/ory-ssot", + icon: Database, + }); + items.splice(5, 0, { + labelKey: "ui.admin.nav.data_integrity", + labelFallback: "Data Integrity", + to: "/system/data-integrity", + icon: ShieldCheck, + }); - return filteredItems; + const permissions = profile?.systemPermissions; + + return items.filter((item) => { + // Super Admin ALWAYS bypasses and gets full access to everything + if (isSuperAdmin) { + if (item.to === "/worksmobile") return showWorksmobile; + return true; + } + + // For others, check their fine-grained systemPermissions + if (!permissions) return false; + + if (item.to === "/") return permissions.overview; + if (item.to === "/users") return permissions.users; + if (item.to === "/auth") return permissions.auth_guard; + if (item.to === "/api-keys") return permissions.api_keys; + if (item.to === "/audit-logs") return permissions.audit_logs; + if (item.to === "/permissions-direct") return false; + if (item.to === "/tenants") return permissions.tenants; + if (item.to === orgfrontUrl) return permissions.org_chart; + if (item.to === "/worksmobile") return permissions.worksmobile; + if (item.to === "/system/ory-ssot") return permissions.ory_ssot; + if (item.to === "/system/data-integrity") + return permissions.data_integrity; + + return true; + }); }, [profile]); const handleLogout = () => { diff --git a/adminfront/src/components/ui/use-toast.ts b/adminfront/src/components/ui/use-toast.ts index 402ed87c..aaea4c9a 100644 --- a/adminfront/src/components/ui/use-toast.ts +++ b/adminfront/src/components/ui/use-toast.ts @@ -18,6 +18,11 @@ const notify = () => { }; const toastBase = (message: string, type: ToastType = "success") => { + if ( + toasts.some((toast) => toast.message === message && toast.type === type) + ) { + return; + } const id = Math.random().toString(36).substring(2, 9); toasts = [...toasts, { id, message, type }]; notify(); diff --git a/adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx b/adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx index 067142b2..3516affc 100644 --- a/adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx +++ b/adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx @@ -1,6 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createApiKey } from "../../lib/adminApi"; @@ -50,15 +49,13 @@ describe("ApiKeyCreatePage", () => { }); it("includes org-context:read in the create request when selected", async () => { - const user = userEvent.setup(); renderPage(); - await user.type( - screen.getByLabelText("서비스 또는 목적 식별 이름"), - "org-context-client", - ); - await user.click(screen.getByRole("button", { name: /조직 Context 조회/ })); - await user.click(screen.getByRole("button", { name: /API 키 발급하기/ })); + fireEvent.change(screen.getByLabelText("서비스 또는 목적 식별 이름"), { + target: { value: "org-context-client" }, + }); + fireEvent.click(screen.getByRole("button", { name: /조직 Context 조회/ })); + fireEvent.click(screen.getByRole("button", { name: /API 키 발급하기/ })); await waitFor(() => { expect(createApiKey).toHaveBeenCalledWith( diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx index 43d43135..14b12389 100644 --- a/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx +++ b/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx @@ -74,7 +74,7 @@ describe("ApiKeyListPage", () => { }); it("updates scopes without changing client_id", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); renderPage(); expect(await screen.findByText("client-id-stable")).toBeInTheDocument(); @@ -88,7 +88,7 @@ describe("ApiKeyListPage", () => { scopes: expect.arrayContaining(["audit:read", "org-context:read"]), }); }); - }); + }, 15_000); it("rotates only the secret and shows the one-time secret", async () => { const user = userEvent.setup(); diff --git a/adminfront/src/features/coverage/adminPageAnimationPolicy.test.ts b/adminfront/src/features/coverage/adminPageAnimationPolicy.test.ts new file mode 100644 index 00000000..6cf89235 --- /dev/null +++ b/adminfront/src/features/coverage/adminPageAnimationPolicy.test.ts @@ -0,0 +1,33 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +function listSourceFiles(directory: string): string[] { + const entries = readdirSync(directory); + const files: string[] = []; + for (const entry of entries) { + const path = join(directory, entry); + const stat = statSync(path); + if (stat.isDirectory()) { + files.push(...listSourceFiles(path)); + continue; + } + if (path.endsWith(".tsx")) { + files.push(path); + } + } + return files; +} + +describe("admin page animation policy", () => { + it("does not use long enter fade animations on stable page containers", () => { + const sourceRoot = join(process.cwd(), "src"); + const offenders = listSourceFiles(sourceRoot).filter((file) => + readFileSync(file, "utf8").includes("animate-in fade-in duration-500"), + ); + + expect(offenders.map((file) => file.replace(`${sourceRoot}/`, ""))).toEqual( + [], + ); + }); +}); diff --git a/adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx b/adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx index 4fb0dd45..51369b73 100644 --- a/adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx +++ b/adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx @@ -29,6 +29,7 @@ const members = [ vi.mock("../../lib/i18n", () => createI18nMock()); vi.mock("../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => ({ id: "admin-user", role: "super_admin" })), fetchTenant: vi.fn(async () => tenant), fetchUsers: vi.fn(async () => ({ items: [ diff --git a/adminfront/src/features/coverage/adminTenantTabs.test.tsx b/adminfront/src/features/coverage/adminTenantTabs.test.tsx index 722494d9..efa5ea33 100644 --- a/adminfront/src/features/coverage/adminTenantTabs.test.tsx +++ b/adminfront/src/features/coverage/adminTenantTabs.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createI18nMock } from "../../test/i18nMock"; import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab"; +import { TenantFineGrainedPermissionsTab } from "../tenants/routes/TenantFineGrainedPermissionsTab"; import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab"; const exportUsersCSVMock = vi.hoisted(() => @@ -15,6 +16,7 @@ const exportUsersCSVMock = vi.hoisted(() => filename: "users_export_20260609.csv", })), ); +const bulkUpdateUsersMock = vi.hoisted(() => vi.fn(async () => ({ results: [] }))); const tenants = [ { @@ -59,7 +61,7 @@ const users = [ id: "user-owner", name: "Owner User", email: "owner@example.com", - role: "tenant_admin", + role: "super_admin", status: "active", }, { @@ -93,12 +95,29 @@ vi.mock("react-oidc-context", () => ({ })); vi.mock("../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => users[0]), + fetchTenant: vi.fn(async (tenantId) => ({ + id: tenantId, + name: "Test Tenant", + slug: "test-tenant", + userPermissions: { view: true, manage: true, manage_admins: true }, + })), fetchTenantOwners: vi.fn(async () => [users[0]]), fetchTenantAdmins: vi.fn(async () => [users[1]]), addTenantOwner: vi.fn(async () => undefined), addTenantAdmin: vi.fn(async () => undefined), removeTenantOwner: vi.fn(async () => undefined), removeTenantAdmin: vi.fn(async () => undefined), + fetchTenantRelations: vi.fn(async () => [ + { + userId: "user-relation-1", + name: "Relation User", + email: "relation@example.com", + relations: ["profile_managers", "schema_viewers"], + }, + ]), + addTenantRelation: vi.fn(async () => undefined), + removeTenantRelation: vi.fn(async () => undefined), fetchUsers: vi.fn(async () => ({ items: users, total: users.length, @@ -109,6 +128,7 @@ vi.mock("../../lib/adminApi", () => ({ })), updateTenant: vi.fn(async () => tenants[2]), updateUser: vi.fn(async () => users[2]), + bulkUpdateUsers: bulkUpdateUsersMock, exportTenantsCSV: vi.fn(async () => ({ blob: new Blob(["name,slug"]), filename: "tenants.csv", @@ -158,6 +178,22 @@ describe("admin tenant tab coverage smoke", () => { expect(screen.getByText("admin@example.com")).toBeInTheDocument(); }); + it("renders tenant fine-grained relations list", async () => { + renderWithProviders( + + } + /> + , + "/tenants/tenant-company/relations", + ); + + expect(await screen.findByText("Relation User")).toBeInTheDocument(); + expect(screen.getByText("relation@example.com")).toBeInTheDocument(); + expect(screen.getByText("세부 권한 설정 (Fine-grained Permissions)")).toBeInTheDocument(); + }); + it("renders tenant hierarchy and selected organization members", async () => { renderWithProviders( @@ -193,4 +229,48 @@ describe("admin tenant tab coverage smoke", () => { expect(exportUsersCSVMock).toHaveBeenCalledWith("", "gpdtdc", false); }); }); + + it("queues searched users and bulk adds them to the selected organization", async () => { + renderWithProviders( + + } + /> + , + "/tenants/tenant-company/organization", + ); + + expect(await screen.findByText("Member User")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /멤버 추가/ })); + fireEvent.change(screen.getByTestId("tenant-org-member-search-input"), { + target: { value: "user" }, + }); + fireEvent.click(screen.getByTestId("tenant-org-member-search-btn")); + + fireEvent.click( + await screen.findByTestId("tenant-org-member-search-result-user-owner"), + ); + fireEvent.click( + await screen.findByTestId("tenant-org-member-search-result-user-admin"), + ); + + expect(screen.getByTestId("tenant-org-member-add-queue")).toHaveTextContent( + "Owner User", + ); + expect(screen.getByTestId("tenant-org-member-add-queue")).toHaveTextContent( + "Admin User", + ); + + fireEvent.click(screen.getByTestId("tenant-org-member-add-submit-btn")); + + await waitFor(() => { + expect(bulkUpdateUsersMock).toHaveBeenCalledWith({ + userIds: ["user-owner", "user-admin"], + tenantSlug: "gpdtdc", + isAddTenant: true, + }); + }); + }); }); diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx index e7f1e56c..0405ce6f 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -5,9 +5,7 @@ import { deleteOrphanUserLoginIDs, fetchDataIntegrityReport, fetchMe, - fetchOrySSOTSystemStatus, fetchOrphanUserLoginIDs, - flushIdentityCache, } from "../../lib/adminApi"; import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics"; import { createI18nMock } from "../../test/i18nMock"; @@ -63,29 +61,6 @@ vi.mock("../../lib/adminApi", () => ({ ], total: 1, })), - fetchOrySSOTSystemStatus: vi.fn(async () => ({ - userProjection: { - name: "kratos_users", - status: "ready", - ready: true, - lastSyncedAt: "2026-05-11T03:00:00Z", - updatedAt: "2026-05-11T03:00:10Z", - projectedUsers: 152, - }, - identityCache: { - status: "ready", - redisReady: true, - observedCount: 151, - keyCount: 153, - lastRefreshedAt: "2026-05-11T03:00:00Z", - updatedAt: "2026-05-11T03:00:10Z", - }, - })), - flushIdentityCache: vi.fn(async () => ({ - status: "success", - flushedKeys: 153, - updatedAt: "2026-05-11T03:02:00Z", - })), deleteOrphanUserLoginIDs: vi.fn(async () => ({ deletedCount: 1, deleted: [ @@ -129,12 +104,6 @@ describe("DataIntegrityPage", () => { renderPage(); expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument(); - expect( - screen.getByRole("tab", { name: "정합성 검사" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("tab", { name: "Ory SSOT 시스템" }), - ).toBeInTheDocument(); expect( await screen.findByText( "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.", @@ -146,28 +115,6 @@ describe("DataIntegrityPage", () => { expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1); }); - it("renders Ory SSOT cache management inside data integrity", async () => { - renderPage(); - - fireEvent.click( - await screen.findByRole("tab", { name: "Ory SSOT 시스템" }), - ); - - expect( - (await screen.findAllByText("Ory SSOT 시스템")).length, - ).toBeGreaterThan(0); - expect(await screen.findByText("Redis identity cache")).toBeInTheDocument(); - expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0); - expect(screen.getByText("152")).toBeInTheDocument(); - expect(screen.getByText("151")).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ })); - await waitFor(() => { - expect(flushIdentityCache).toHaveBeenCalledTimes(1); - }); - expect(fetchOrySSOTSystemStatus).toHaveBeenCalled(); - }); - it("shows orphan login ID targets and deletes selected rows", async () => { vi.spyOn(window, "confirm").mockReturnValue(true); const { container } = renderPage(); diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx index 0159aea0..bb75cb55 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -19,7 +19,6 @@ import { } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { getAdminDateLocale } from "../../lib/locale"; -import { UserProjectionContent } from "../projections/UserProjectionPage"; function statusLabel(status: DataIntegrityStatus) { switch (status) { @@ -188,14 +187,6 @@ function recheckStatusText(status: "idle" | "running" | "success" | "error") { } } -function pageTabClassName(active: boolean) { - return `relative px-6 py-3 text-sm font-medium transition-colors ${ - active - ? "border-b-2 border-primary text-primary" - : "text-muted-foreground hover:text-foreground" - }`; -} - function OrphanLoginIDTable({ items, selectedIds, @@ -294,9 +285,6 @@ function OrphanLoginIDTable({ function DataIntegrityContent() { const queryClient = useQueryClient(); - const [activeTab, setActiveTab] = useState<"integrity" | "projection">( - "integrity", - ); const [selectedOrphanIds, setSelectedOrphanIds] = useState([]); const [recheckStatus, setRecheckStatus] = useState< "idle" | "running" | "success" | "error" @@ -373,243 +361,210 @@ function DataIntegrityContent() {

- {activeTab === "integrity" ? ( -
- + {recheckMessage ? ( + - - {isManualRechecking - ? t("ui.admin.integrity.recheck.running", "검사 중") - : t("ui.admin.integrity.recheck.run", "다시 검사")} - - {recheckMessage ? ( - - {recheckMessage} - - ) : null} -
- ) : null} + {recheckMessage} + + ) : null} + -
- - -
- - {activeTab === "integrity" ? ( -
- {isError ? ( -
- {(error as Error)?.message || - t( - "msg.admin.integrity.report.load_error", - "정합성 리포트를 불러오지 못했습니다.", - )} -
- ) : null} - -
-
-
-

- {t( - "ui.admin.integrity.read_model.title", - "Read model integrity", - )} -

-

- {t( - "msg.admin.integrity.read_model.description", - "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.", - )} -

-
- {data ? ( - - {statusLabel(data.status)} - - ) : null} -
- - {isLoading ? ( -
- {t("ui.admin.integrity.loading", "불러오는 중")} -
- ) : ( -
-
-
- {t("ui.admin.integrity.summary.total_checks", "검사 항목")} -
-
- {data?.summary.totalChecks ?? 0} -
-
-
-
- {t("ui.admin.integrity.summary.passed", "정상")} -
-
- {data?.summary.passed ?? 0} -
-
-
-
- {t("ui.admin.integrity.summary.failures", "실패 건수")} -
-
- {data?.summary.failures ?? 0} -
-
-
-
- {t("ui.admin.integrity.summary.checked_at", "검사 시각")} -
-
- {formatDateTime(data?.checkedAt)} -
-
-
- )} +
+ {isError ? ( +
+ {(error as Error)?.message || + t( + "msg.admin.integrity.report.load_error", + "정합성 리포트를 불러오지 못했습니다.", + )}
+ ) : null} -
- {(data?.sections ?? []).map((section) => ( -
-
-
-

- {integritySectionLabel(section.key, section.label)} -

-

- {integritySectionDescription(section.key)} -

-
- - {statusLabel(section.status)} - -
-
- {section.checks.map((check) => ( -
-
- -
-
- {integrityCheckLabel(check.key, check.label)} -
-

- {integrityCheckDescription( - check.key, - check.description, - )} -

-
-
-
- - {statusLabel(check.status)} - - - {check.count} - -
-
- ))} -
-
- ))} +
+
+
+

+ {t( + "ui.admin.integrity.read_model.title", + "Read model integrity", + )} +

+

+ {t( + "msg.admin.integrity.read_model.description", + "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.", + )} +

+
+ {data ? ( + + {statusLabel(data.status)} + + ) : null}
-
-
-
-

- {t( - "ui.admin.integrity.orphan_login_ids.title", - "유령 로그인 ID 정리", - )} -

-

- {t( - "msg.admin.integrity.orphan_login_ids.description", - "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.", - )} -

-
- + {isLoading ? ( +
+ {t("ui.admin.integrity.loading", "불러오는 중")}
- {orphanLoginIDsQuery.isError ? ( -
- {t( - "msg.admin.integrity.orphan_login_ids.load_error", - "유령 로그인 ID 대상을 불러오지 못했습니다.", - )} + ) : ( +
+
+
+ {t("ui.admin.integrity.summary.total_checks", "검사 항목")} +
+
+ {data?.summary.totalChecks ?? 0} +
- ) : null} - {deleteMutation.data ? ( -
- {t( - "msg.admin.integrity.orphan_login_ids.delete_success", - "{{count}}개의 유령 로그인 ID를 삭제했습니다.", - { count: deleteMutation.data.deletedCount }, - )} +
+
+ {t("ui.admin.integrity.summary.passed", "정상")} +
+
+ {data?.summary.passed ?? 0} +
- ) : null} - -
+
+
+ {t("ui.admin.integrity.summary.failures", "실패 건수")} +
+
+ {data?.summary.failures ?? 0} +
+
+
+
+ {t("ui.admin.integrity.summary.checked_at", "검사 시각")} +
+
+ {formatDateTime(data?.checkedAt)} +
+
+ + )} +
+ +
+ {(data?.sections ?? []).map((section) => ( +
+
+
+

+ {integritySectionLabel(section.key, section.label)} +

+

+ {integritySectionDescription(section.key)} +

+
+ + {statusLabel(section.status)} + +
+
+ {section.checks.map((check) => ( +
+
+ +
+
+ {integrityCheckLabel(check.key, check.label)} +
+

+ {integrityCheckDescription( + check.key, + check.description, + )} +

+
+
+
+ + {statusLabel(check.status)} + + + {check.count} + +
+
+ ))} +
+
+ ))}
- ) : ( -
- -
- )} + +
+
+
+

+ {t( + "ui.admin.integrity.orphan_login_ids.title", + "유령 로그인 ID 정리", + )} +

+

+ {t( + "msg.admin.integrity.orphan_login_ids.description", + "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.", + )} +

+
+ +
+ {orphanLoginIDsQuery.isError ? ( +
+ {t( + "msg.admin.integrity.orphan_login_ids.load_error", + "유령 로그인 ID 대상을 불러오지 못했습니다.", + )} +
+ ) : null} + {deleteMutation.data ? ( +
+ {t( + "msg.admin.integrity.orphan_login_ids.delete_success", + "{{count}}개의 유령 로그인 ID를 삭제했습니다.", + { count: deleteMutation.data.deletedCount }, + )} +
+ ) : null} + +
+
); } diff --git a/adminfront/src/features/projections/UserProjectionPage.test.tsx b/adminfront/src/features/ory-ssot/OrySSOTPage.test.tsx similarity index 55% rename from adminfront/src/features/projections/UserProjectionPage.test.tsx rename to adminfront/src/features/ory-ssot/OrySSOTPage.test.tsx index febccde2..65503e53 100644 --- a/adminfront/src/features/projections/UserProjectionPage.test.tsx +++ b/adminfront/src/features/ory-ssot/OrySSOTPage.test.tsx @@ -2,11 +2,12 @@ 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 { + fetchMe, fetchOrySSOTSystemStatus, flushIdentityCache, } from "../../lib/adminApi"; import { createI18nMock } from "../../test/i18nMock"; -import UserProjectionPage from "./UserProjectionPage"; +import OrySSOTPage from "./OrySSOTPage"; vi.mock("../../lib/i18n", () => createI18nMock()); @@ -15,21 +16,13 @@ let currentRole = "super_admin"; vi.mock("../../lib/adminApi", () => ({ fetchMe: vi.fn(async () => ({ role: currentRole })), fetchOrySSOTSystemStatus: vi.fn(async () => ({ - userProjection: { - name: "kratos_users", - status: "ready", - ready: true, - lastSyncedAt: "2026-05-11T03:00:00Z", - updatedAt: "2026-05-11T03:00:10Z", - projectedUsers: 152, - }, identityCache: { status: "ready", redisReady: true, observedCount: 151, + keyCount: 153, lastRefreshedAt: "2026-05-11T03:00:00Z", updatedAt: "2026-05-11T03:00:10Z", - keyCount: 153, }, })), flushIdentityCache: vi.fn(async () => ({ @@ -49,12 +42,12 @@ function renderPage() { return render( - + , ); } -describe("UserProjectionPage", () => { +describe("OrySSOTPage", () => { beforeEach(() => { currentRole = "super_admin"; vi.clearAllMocks(); @@ -62,36 +55,22 @@ describe("UserProjectionPage", () => { window.localStorage.setItem("locale", "ko"); }); - it("renders Ory SSOT and Redis identity cache status for super_admin", async () => { + it("renders identity cache status and flushes cache", async () => { renderPage(); - expect(await screen.findByText("Ory SSOT 시스템")).toBeInTheDocument(); expect( - await screen.findByText( - "Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.", - ), - ).toBeInTheDocument(); + (await screen.findAllByText("Ory SSOT 시스템")).length, + ).toBeGreaterThan(0); expect(await screen.findByText("Redis identity cache")).toBeInTheDocument(); expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0); - expect(screen.getByText("관측 identity")).toBeInTheDocument(); - expect(screen.getByText("152")).toBeInTheDocument(); expect(screen.getByText("151")).toBeInTheDocument(); - expect(fetchOrySSOTSystemStatus).toHaveBeenCalled(); - }); - it("flushes only the Redis identity cache for super_admin", async () => { - renderPage(); - - await screen.findByText("Ory SSOT 시스템"); - expect(screen.queryByRole("button", { name: /재동기화/ })).toBeNull(); - expect( - screen.queryByRole("button", { name: /초기화 후 재구축/ }), - ).toBeNull(); fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ })); await waitFor(() => { expect(flushIdentityCache).toHaveBeenCalledTimes(1); }); + expect(fetchOrySSOTSystemStatus).toHaveBeenCalled(); }); it("blocks non-super admins", async () => { @@ -100,21 +79,7 @@ describe("UserProjectionPage", () => { renderPage(); expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument(); - expect(screen.queryByText("Ory SSOT 시스템")).not.toBeInTheDocument(); + expect(fetchMe).toHaveBeenCalled(); expect(fetchOrySSOTSystemStatus).not.toHaveBeenCalled(); }); - - it("renders localized labels in English", async () => { - window.localStorage.setItem("locale", "en"); - renderPage(); - - expect(await screen.findByText("Ory SSOT System")).toBeInTheDocument(); - expect( - await screen.findByText( - "Review Kratos source-of-truth and Redis identity cache status separately.", - ), - ).toBeInTheDocument(); - expect(screen.getByText("Redis cache flush")).toBeInTheDocument(); - expect((await screen.findAllByText("ready")).length).toBeGreaterThan(0); - }); }); diff --git a/adminfront/src/features/projections/UserProjectionPage.tsx b/adminfront/src/features/ory-ssot/OrySSOTPage.tsx similarity index 61% rename from adminfront/src/features/projections/UserProjectionPage.tsx rename to adminfront/src/features/ory-ssot/OrySSOTPage.tsx index c53fe0bb..bc0eb48f 100644 --- a/adminfront/src/features/projections/UserProjectionPage.tsx +++ b/adminfront/src/features/ory-ssot/OrySSOTPage.tsx @@ -42,11 +42,7 @@ function StatusBadge({ ready, status }: { ready: boolean; status: string }) { ); } -export function UserProjectionContent({ - embedded = false, -}: { - embedded?: boolean; -}) { +function OrySSOTContent() { const queryClient = useQueryClient(); const { data, isLoading, isError, error } = useQuery({ queryKey: ["ory-ssot-system-status"], @@ -72,50 +68,41 @@ export function UserProjectionContent({ if (confirmed) flushMutation.mutate(); }; - const projection = data?.userProjection; const identityCache = data?.identityCache; - const header = ( -
-
-
- + return ( +
+
+
+
+ +
+
+

+ {t("ui.admin.ory_ssot.title", "Ory SSOT System")} +

+

+ {t( + "msg.admin.ory_ssot.subtitle", + "Review Kratos source-of-truth and Redis identity cache status separately.", + )} +

+
-
-

- {t("ui.admin.ory_ssot.title", "Ory SSOT System")} -

-

- {t( - "msg.admin.ory_ssot.subtitle", - "Review Kratos source-of-truth and Redis identity cache status separately.", - )} -

-
-
- -
- ); + + - const body = ( - <> {isError ? (
{(error as Error)?.message || @@ -146,79 +133,6 @@ export function UserProjectionContent({
) : null} -
-
-
-

- {t( - "ui.admin.ory_ssot.projection_card.title", - "Backend user read model", - )} -

-

- {t( - "ui.admin.ory_ssot.projection_card.description", - "PostgreSQL read model status used by admin search and statistics.", - )} -

-
-
- - {isLoading ? ( -
- {t("ui.admin.ory_ssot.loading", "Loading")} -
- ) : ( -
-
-
- {t("ui.admin.ory_ssot.summary.status", "Status")} -
-
- -
-
-
-
- {t("ui.admin.ory_ssot.summary.local_users", "Local users")} -
-
- {projection?.projectedUsers ?? 0} -
-
-
-
- {t( - "ui.admin.ory_ssot.summary.last_synced", - "Last read-model refresh", - )} -
-
- {formatDateTime(projection?.lastSyncedAt)} -
-
-
-
- {t("ui.admin.ory_ssot.summary.updated_at", "Updated at")} -
-
- {formatDateTime(projection?.updatedAt)} -
-
-
- )} - - {projection?.lastError ? ( -
- - {projection.lastError} -
- ) : null} -
-
@@ -294,27 +208,11 @@ export function UserProjectionContent({
) : null}
- - ); - - if (embedded) { - return ( -
- {header} - {body} -
- ); - } - - return ( -
- {header} - {body}
); } -export default function UserProjectionPage() { +export default function OrySSOTPage() { return ( } > - + ); } diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 2b9095ef..b8f10730 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -506,7 +506,7 @@ function GlobalOverviewPage() { ); return ( -
+
diff --git a/adminfront/src/features/tenants/components/DomainTagInput.test.tsx b/adminfront/src/features/tenants/components/DomainTagInput.test.tsx index d7834013..b7654149 100644 --- a/adminfront/src/features/tenants/components/DomainTagInput.test.tsx +++ b/adminfront/src/features/tenants/components/DomainTagInput.test.tsx @@ -1,11 +1,9 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { fireEvent, render, screen } from "@testing-library/react"; 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(); @@ -34,10 +32,9 @@ describe("DomainTagInput", () => { />, ); - await user.type( - screen.getByPlaceholderText("example.com"), - "samaneng.com ", - ); + const input = screen.getByPlaceholderText("example.com"); + fireEvent.change(input, { target: { value: "samaneng.com" } }); + fireEvent.keyDown(input, { key: " " }); expect( await screen.findByText( @@ -45,7 +42,7 @@ describe("DomainTagInput", () => { ), ).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "계속 진행" })); + fireEvent.click(screen.getByRole("button", { name: "계속 진행" })); expect(onChange).toHaveBeenCalledWith(["samaneng.com"]); expect(onConfirmedConflictsChange).toHaveBeenCalledWith(["samaneng.com"]); diff --git a/adminfront/src/features/tenants/components/DomainTagInput.tsx b/adminfront/src/features/tenants/components/DomainTagInput.tsx index ecfc5513..f2dc39eb 100644 --- a/adminfront/src/features/tenants/components/DomainTagInput.tsx +++ b/adminfront/src/features/tenants/components/DomainTagInput.tsx @@ -29,6 +29,7 @@ type DomainTagInputProps = { confirmedConflicts?: string[]; onConfirmedConflictsChange?: (domains: string[]) => void; placeholder?: string; + disabled?: boolean; }; export function DomainTagInput({ @@ -40,6 +41,7 @@ export function DomainTagInput({ confirmedConflicts = [], onConfirmedConflictsChange, placeholder, + disabled = false, }: DomainTagInputProps) { const [input, setInput] = useState(""); const [pendingConflict, setPendingConflict] = useState( @@ -107,14 +109,16 @@ export function DomainTagInput({ className="gap-1 rounded-md" > {domain} - + {!disabled && ( + + )} ))} diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx index 51197cf4..d91d5de3 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx @@ -46,8 +46,10 @@ describe("ParentTenantSelector picker", () => { fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ })); expect(screen.getByRole("dialog")).toBeInTheDocument(); - const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src"); - expect(pickerSrc).toContain("/login"); + const pickerSrc = screen + .getByTestId("parent-tenant-picker-frame") + .getAttribute("src"); + expect(pickerSrc).toContain("http://localhost:5175/login"); expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker"); fireEvent( @@ -71,6 +73,30 @@ describe("ParentTenantSelector picker", () => { await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1")); }); + it("scopes the org-chart picker to the requested tenant root", () => { + const onChange = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "한맥가족에서 선택" })); + + const pickerSrc = screen + .getByTestId("parent-tenant-picker-frame") + .getAttribute("src"); + expect(decodeURIComponent(pickerSrc ?? "")).toContain("tenantId=group-1"); + }); + it("keeps the current tenant out of picker message selections", async () => { const onChange = vi.fn(); diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index 3b8830b7..21dc1310 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -31,10 +31,12 @@ type ParentTenantSelectorProps = { labelAction?: ReactNode; contextLabel?: string; orgChartPickerLabel?: string; + orgChartTenantId?: string; localPickerLabel?: string; localTenantFilter?: (tenant: TenantSummary) => boolean; compact?: boolean; controlTestId?: string; + disabled?: boolean; }; export function ParentTenantSelector({ @@ -49,10 +51,12 @@ export function ParentTenantSelector({ labelAction, contextLabel, orgChartPickerLabel, + orgChartTenantId, localPickerLabel, localTenantFilter, compact = false, controlTestId, + disabled = false, }: ParentTenantSelectorProps) { const [pickerOpen, setPickerOpen] = useState(false); const [localPickerOpen, setLocalPickerOpen] = useState(false); @@ -66,6 +70,9 @@ export function ParentTenantSelector({ ); const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( import.meta.env.ORGFRONT_URL, + { + tenantId: orgChartTenantId, + }, ); useEffect(() => { @@ -112,6 +119,7 @@ export function ParentTenantSelector({ variant="outline" size="sm" className={compact ? "h-8 shrink-0 px-2" : undefined} + disabled={disabled} > {orgChartPickerLabel ?? @@ -135,13 +143,19 @@ export function ParentTenantSelector({ title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")} src={pickerUrl} className="h-[600px] w-full rounded-md border" + data-testid="parent-tenant-picker-frame" /> {localPickerLabel && ( - @@ -228,6 +242,7 @@ export function ParentTenantSelector({ className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"} onClick={() => onChange("")} aria-label={noneLabel} + disabled={disabled} > diff --git a/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx b/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx new file mode 100644 index 00000000..20cd7132 --- /dev/null +++ b/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx @@ -0,0 +1,29 @@ +import type React from "react"; +import { + type TenantPermissionKey, + useTenantPermission, +} from "../hooks/useTenantPermission"; + +interface TenantPermissionGuardProps { + tenantId: string; + relation: TenantPermissionKey; + fallback?: React.ReactNode; + children: React.ReactNode; +} + +export function TenantPermissionGuard({ + tenantId, + relation, + fallback = null, + children, +}: TenantPermissionGuardProps) { + const { hasPermission, isLoading } = useTenantPermission(tenantId); + + if (isLoading) return null; + + if (!hasPermission(relation)) { + return <>{fallback}; + } + + return <>{children}; +} diff --git a/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx b/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx new file mode 100644 index 00000000..5026242d --- /dev/null +++ b/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx @@ -0,0 +1,184 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, renderHook, screen, waitFor } from "@testing-library/react"; +import type React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { + fetchMe, + fetchTenant, + type TenantSummary, + type UserProfileResponse, +} from "../../../lib/adminApi"; +import { TenantPermissionGuard } from "../components/TenantPermissionGuard"; +import { useTenantPermission } from "./useTenantPermission"; + +vi.mock("../../../lib/adminApi", () => ({ + fetchMe: vi.fn(), + fetchTenant: vi.fn(), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +function mockProfile( + overrides: Partial, +): UserProfileResponse { + return { + id: "user-id", + email: "user@example.com", + name: "Test User", + phone: "", + role: "user", + department: "", + affiliationType: "general", + ...overrides, + }; +} + +function mockTenant(overrides: Partial): TenantSummary { + return { + id: "tenant-id", + type: "COMPANY", + name: "Test Tenant", + slug: "test-tenant", + description: "", + status: "active", + memberCount: 0, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("useTenantPermission", () => { + it("returns true for all permissions if user is super_admin", async () => { + vi.mocked(fetchMe).mockResolvedValue( + mockProfile({ + id: "user-super", + role: "super_admin", + }), + ); + + vi.mocked(fetchTenant).mockResolvedValue( + mockTenant({ + id: "tenant-1", + name: "Super Tenant", + userPermissions: { view: false, manage: false, manage_admins: false }, + }), + ); + + const { result } = renderHook(() => useTenantPermission("tenant-1"), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.hasPermission("view")).toBe(true); + expect(result.current.hasPermission("manage")).toBe(true); + expect(result.current.hasPermission("manage_admins")).toBe(true); + }); + + it("returns permissions mapped from userPermissions for normal admins/users", async () => { + vi.mocked(fetchMe).mockResolvedValue( + mockProfile({ + id: "user-admin", + role: "tenant_admin", + }), + ); + + vi.mocked(fetchTenant).mockResolvedValue( + mockTenant({ + id: "tenant-2", + name: "Tenant Admin Corp", + userPermissions: { view: true, manage: true, manage_admins: false }, + }), + ); + + const { result } = renderHook(() => useTenantPermission("tenant-2"), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.hasPermission("view")).toBe(true); + expect(result.current.hasPermission("manage")).toBe(true); + expect(result.current.hasPermission("manage_admins")).toBe(false); + }); +}); + +describe("TenantPermissionGuard", () => { + it("renders children when user has permission", async () => { + vi.mocked(fetchMe).mockResolvedValue( + mockProfile({ + id: "user-admin", + role: "tenant_admin", + }), + ); + + vi.mocked(fetchTenant).mockResolvedValue( + mockTenant({ + id: "tenant-3", + userPermissions: { view: true, manage: true, manage_admins: false }, + }), + ); + + render( + Access Denied
} + > +
Access Granted
+ , + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(screen.getByText("Access Granted")).toBeInTheDocument(); + }); + expect(screen.queryByText("Access Denied")).not.toBeInTheDocument(); + }); + + it("renders fallback when user lacks permission", async () => { + vi.mocked(fetchMe).mockResolvedValue( + mockProfile({ + id: "user-admin", + role: "tenant_admin", + }), + ); + + vi.mocked(fetchTenant).mockResolvedValue( + mockTenant({ + id: "tenant-4", + userPermissions: { view: true, manage: false, manage_admins: false }, + }), + ); + + render( + Access Denied
} + > +
Access Granted
+ , + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(screen.getByText("Access Denied")).toBeInTheDocument(); + }); + expect(screen.queryByText("Access Granted")).not.toBeInTheDocument(); + }); +}); diff --git a/adminfront/src/features/tenants/hooks/useTenantPermission.ts b/adminfront/src/features/tenants/hooks/useTenantPermission.ts new file mode 100644 index 00000000..19241fd7 --- /dev/null +++ b/adminfront/src/features/tenants/hooks/useTenantPermission.ts @@ -0,0 +1,41 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchMe, fetchTenant } from "../../../lib/adminApi"; +import { normalizeAdminRole } from "../../../lib/roles"; + +export type TenantPermissionKey = + | "view" + | "manage" + | "manage_admins" + | "view_profile" + | "manage_profile" + | "view_permissions" + | "manage_permissions" + | "view_organization" + | "manage_organization" + | "view_schema" + | "manage_schema" + | "view_worksmobile" + | "manage_worksmobile"; + +export function useTenantPermission(tenantId: string) { + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); + + const { data: tenant } = useQuery({ + queryKey: ["tenant", tenantId], + queryFn: () => fetchTenant(tenantId), + enabled: !!tenantId, + }); + + const hasPermission = (requiredRelation: TenantPermissionKey): boolean => { + // Super Admin always has full bypass access + if (normalizeAdminRole(profile?.role) === "super_admin") { + return true; + } + return !!tenant?.userPermissions?.[requiredRelation]; + }; + + return { hasPermission, isLoading: !tenant }; +} diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index fc78435d..c58857a2 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -49,6 +49,7 @@ import { type TenantAdmin, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { useTenantPermission } from "../hooks/useTenantPermission"; type DialogMode = "owner" | "admin"; @@ -69,6 +70,10 @@ export function TenantAdminsAndOwnersTab() { const _currentUserId = auth.user?.profile.sub; const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>(); const tenantId = tenantIdParam ?? ""; + const { hasPermission } = useTenantPermission(tenantId); + const isWritable = + hasPermission("manage_permissions") || hasPermission("manage_admins"); + const canView = hasPermission("view_permissions") || hasPermission("view"); const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); const [dialogMode, setDialogMode] = useState(null); @@ -338,6 +343,16 @@ export function TenantAdminsAndOwnersTab() { if (!tenantId) return null; + if (!canView) { + return ( +
+

+ {t("msg.common.forbidden", "접근 권한이 없습니다.")} +

+
+ ); + } + const serverOwners = ownersQuery.data || []; const serverAdmins = adminsQuery.data || []; const currentOwners = mergePendingMembers(serverOwners, pendingOwners); @@ -362,7 +377,7 @@ export function TenantAdminsAndOwnersTab() { ); return ( -
+
{/* Owners Card */} @@ -382,6 +397,7 @@ export function TenantAdminsAndOwnersTab() {
{/* Outlet for nested routes */} -
+
diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.superAdmin.test.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.superAdmin.test.tsx new file mode 100644 index 00000000..2458bfcf --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.superAdmin.test.tsx @@ -0,0 +1,164 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type React from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createI18nMock } from "../../../test/i18nMock"; +import { TenantFineGrainedPermissionsPage } from "./TenantFineGrainedPermissionsPage"; + +const fetchUsersMock = vi.hoisted(() => vi.fn()); +const bulkUpdateUsersMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../../lib/i18n", () => createI18nMock()); + +vi.mock("../../../lib/adminApi", () => ({ + addSystemRelation: vi.fn(async () => undefined), + addTenantRelation: vi.fn(async () => undefined), + bulkUpdateUsers: bulkUpdateUsersMock, + fetchAllTenants: vi.fn(async () => ({ items: [], total: 0 })), + fetchMe: vi.fn(async () => ({ + id: "current-admin", + name: "Current Admin", + email: "current@example.com", + role: "super_admin", + })), + fetchSystemRelations: vi.fn(async () => []), + fetchTenantRelations: vi.fn(async () => []), + fetchUsers: fetchUsersMock, + removeSystemRelation: vi.fn(async () => undefined), + removeTenantRelation: vi.fn(async () => undefined), +})); + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + {ui} + , + ); +} + +describe("TenantFineGrainedPermissionsPage Super Admin role tab", () => { + beforeEach(() => { + vi.clearAllMocks(); + bulkUpdateUsersMock.mockResolvedValue({ results: [] }); + fetchUsersMock.mockResolvedValue({ + items: [ + { + id: "current-admin", + name: "Current Admin", + email: "current@example.com", + role: "super_admin", + status: "active", + createdAt: "2026-06-17T00:00:00Z", + updatedAt: "2026-06-17T00:00:00Z", + }, + { + id: "bootstrap-admin", + name: "Bootstrap Admin", + email: "env-admin@example.com", + role: "super_admin", + status: "active", + metadata: { bootstrapSuperAdmin: true }, + createdAt: "2026-06-17T00:00:00Z", + updatedAt: "2026-06-17T00:00:00Z", + }, + { + id: "delegated-admin", + name: "Delegated Admin", + email: "delegated@example.com", + role: "super_admin", + status: "active", + createdAt: "2026-06-17T00:00:00Z", + updatedAt: "2026-06-17T00:00:00Z", + }, + { + id: "regular-user", + name: "Regular User", + email: "regular@example.com", + phone: "010-0000-0001", + role: "user", + status: "active", + createdAt: "2026-06-17T00:00:00Z", + updatedAt: "2026-06-17T00:00:00Z", + }, + ], + total: 4, + limit: 1000, + offset: 0, + }); + }); + + it("shows revocable super admin users even when they have no direct system relations", async () => { + renderWithProviders( + + } + /> + , + ); + + fireEvent.click( + await screen.findByRole("tab", { name: "Super Admin 역할" }), + ); + + expect(await screen.findByText("Delegated Admin")).toBeInTheDocument(); + expect(screen.getByText("delegated@example.com")).toBeInTheDocument(); + expect(screen.queryByText("Current Admin")).not.toBeInTheDocument(); + expect(screen.queryByText("Bootstrap Admin")).not.toBeInTheDocument(); + expect(screen.queryByText("Regular User")).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByTestId("super-admin-role-user-delegated-admin"), + ); + fireEvent.click(screen.getByRole("button", { name: "Super Admin 회수" })); + + await waitFor(() => + expect(bulkUpdateUsersMock).toHaveBeenCalledWith({ + userIds: ["delegated-admin"], + role: "user", + }), + ); + }); + + it("searches regular users and grants the Super Admin role from the target queue", async () => { + renderWithProviders( + + } + /> + , + ); + + fireEvent.click( + await screen.findByRole("tab", { name: "Super Admin 역할" }), + ); + + fireEvent.change( + await screen.findByPlaceholderText("UUID, 이름, 이메일, 전화번호 검색"), + { target: { value: "010-0000-0001" } }, + ); + fireEvent.click(screen.getByRole("button", { name: "부여 대상 추가" })); + + expect( + screen.getByTestId("super-admin-grant-target-regular-user"), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Super Admin 부여" })); + + await waitFor(() => + expect(bulkUpdateUsersMock).toHaveBeenCalledWith({ + userIds: ["regular-user"], + role: "super_admin", + }), + ); + }); +}); diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx new file mode 100644 index 00000000..25972609 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx @@ -0,0 +1,1693 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { + Building2, + Database, + Key, + KeyRound, + LayoutDashboard, + Network, + NotebookTabs, + Search, + Share2, + Shield, + ShieldCheck, + Trash2, + Users, + X, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../../../components/ui/dialog"; +import { Input } from "../../../components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { toast } from "../../../components/ui/use-toast"; +import { + addSystemRelation, + addTenantRelation, + bulkUpdateUsers, + fetchAllTenants, + fetchMe, + fetchSystemRelations, + fetchTenantRelations, + fetchUsers, + removeSystemRelation, + removeTenantRelation, + type TenantRelation, + type UserSummary, +} from "../../../lib/adminApi"; +import { t } from "../../../lib/i18n"; +import { + buildAuthenticatedOrgChartUserMultiPickerUrl, + parseOrgChartUserSelections, +} from "../../users/orgChartPicker"; + +const protectedSystemMenuRelations = new Set([ + "ory_ssot", + "data_integrity", + "permissions_direct", +]); + +function isBootstrapSuperAdminUser(user: UserSummary) { + return user.metadata?.bootstrapSuperAdmin === true; +} + +function normalizeUserSearchText(value: string | undefined) { + return (value ?? "").trim().toLowerCase(); +} + +function matchesUserIdentitySearch(user: UserSummary, normalizedTerm: string) { + if (!normalizedTerm) { + return false; + } + + return [user.id, user.name, user.email, user.phone] + .map((value) => normalizeUserSearchText(value)) + .some((value) => value.includes(normalizedTerm)); +} + +export function TenantFineGrainedPermissionsPage() { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [activeTab, _setActiveTab] = useState<"tenant" | "system">("system"); + const [activePermissionTab, setActivePermissionTab] = useState< + "direct" | "super-admin" + >("direct"); + const [_selectedTenantId, _setSelectedTenantId] = useState(""); + const [targetTenantId, setTargetTenantId] = useState(""); + const [queuedTargetUsers, setQueuedTargetUsers] = useState([]); + const [bulkRelationMode, setBulkRelationMode] = useState< + "page" | "target-action" + >("page"); + const [bulkPageRelation, setBulkPageRelation] = useState("overview_viewers"); + const [bulkTenantPage, setBulkTenantPage] = useState("profile"); + const [bulkAction, setBulkAction] = useState<"read" | "manage">("read"); + const [tenantPickerOpen, setTenantPickerOpen] = useState(false); + const [tenantPickerSearch, setTenantPickerSearch] = useState(""); + const [selectedSuperAdminUserIds, setSelectedSuperAdminUserIds] = useState< + string[] + >([]); + const [superAdminGrantSearch, setSuperAdminGrantSearch] = useState(""); + const [queuedSuperAdminGrantUsers, setQueuedSuperAdminGrantUsers] = useState< + UserSummary[] + >([]); + const [assignmentSearchTerm, setAssignmentSearchTerm] = useState(""); + const [assignmentSort, setAssignmentSort] = useState< + "user" | "relation" | "level" + >("user"); + const orgChartMemberPickerUrl = useMemo( + () => + buildAuthenticatedOrgChartUserMultiPickerUrl( + import.meta.env.ORGFRONT_URL, + ), + [], + ); + + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); + + const isSuperAdmin = profile?.role === "super_admin"; + + const tenantsQuery = useQuery({ + queryKey: ["tenants", "list-all"], + queryFn: () => fetchAllTenants(), + enabled: isSuperAdmin, + }); + + const tenants = isSuperAdmin + ? (tenantsQuery.data?.items ?? []) + : (profile?.manageableTenants ?? []); + + // System Relations (Admin Control) Queries & Mutations + const systemRelationsQuery = useQuery({ + queryKey: ["system-relations"], + queryFn: fetchSystemRelations, + enabled: isSuperAdmin && activeTab === "system", + }); + const systemRelations = systemRelationsQuery.data ?? []; + + const superAdminUsersQuery = useQuery({ + queryKey: ["admin-users", "super-admin-role-candidates"], + queryFn: () => fetchUsers(10000, 0), + enabled: isSuperAdmin && activePermissionTab === "super-admin", + }); + + const revocableSuperAdminUsers = useMemo(() => { + const currentAdminId = profile?.id ?? ""; + const currentAdminEmail = (profile?.email ?? "").trim().toLowerCase(); + + return (superAdminUsersQuery.data?.items ?? []).filter((user) => { + if (user.role !== "super_admin") { + return false; + } + if (user.id === currentAdminId) { + return false; + } + if (user.email.trim().toLowerCase() === currentAdminEmail) { + return false; + } + return !isBootstrapSuperAdminUser(user); + }); + }, [profile?.email, profile?.id, superAdminUsersQuery.data?.items]); + + const queuedSuperAdminGrantUserIds = useMemo( + () => new Set(queuedSuperAdminGrantUsers.map((user) => user.id)), + [queuedSuperAdminGrantUsers], + ); + + const superAdminGrantSearchResults = useMemo(() => { + const normalizedTerm = normalizeUserSearchText(superAdminGrantSearch); + if (!normalizedTerm) { + return []; + } + + return (superAdminUsersQuery.data?.items ?? []) + .filter((user) => user.role !== "super_admin") + .filter((user) => !queuedSuperAdminGrantUserIds.has(user.id)) + .filter((user) => matchesUserIdentitySearch(user, normalizedTerm)) + .slice(0, 20); + }, [ + queuedSuperAdminGrantUserIds, + superAdminGrantSearch, + superAdminUsersQuery.data?.items, + ]); + + const tenantRelationsQuery = useQuery({ + queryKey: ["tenant-relations", targetTenantId], + queryFn: () => fetchTenantRelations(targetTenantId), + enabled: + isSuperAdmin && + activePermissionTab === "direct" && + bulkRelationMode === "target-action" && + targetTenantId.length > 0, + }); + const tenantRelations = tenantRelationsQuery.data ?? []; + + const addSystemRelationMutation = useMutation({ + mutationFn: (payload: { userId: string; relation: string }) => + addSystemRelation(payload.userId, payload.relation), + onMutate: async (newRelation) => { + await queryClient.cancelQueries({ queryKey: ["system-relations"] }); + const previousRelations = queryClient.getQueryData([ + "system-relations", + ]); + + queryClient.setQueryData( + ["system-relations"], + (old) => { + if (!old) return []; + return old.map((user) => { + if (user.userId === newRelation.userId) { + return { + ...user, + relations: user.relations.includes(newRelation.relation) + ? user.relations + : [...user.relations, newRelation.relation], + }; + } + return user; + }); + }, + ); + + return { previousRelations }; + }, + onError: (err: AxiosError<{ error?: string }>, _, context) => { + if (context?.previousRelations) { + queryClient.setQueryData( + ["system-relations"], + context.previousRelations, + ); + } + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + onSuccess: () => { + // Quiet mutate + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["system-relations"] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["system-relations"] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + }, 500); + }, + }); + + const removeSystemRelationMutation = useMutation({ + mutationFn: (payload: { userId: string; relation: string }) => + removeSystemRelation(payload.userId, payload.relation), + onMutate: async (targetRelation) => { + await queryClient.cancelQueries({ queryKey: ["system-relations"] }); + const previousRelations = queryClient.getQueryData([ + "system-relations", + ]); + + queryClient.setQueryData( + ["system-relations"], + (old) => { + if (!old) return []; + return old.map((user) => { + if (user.userId === targetRelation.userId) { + return { + ...user, + relations: user.relations.filter( + (r) => r !== targetRelation.relation, + ), + }; + } + return user; + }); + }, + ); + + return { previousRelations }; + }, + onError: (err: AxiosError<{ error?: string }>, _, context) => { + if (context?.previousRelations) { + queryClient.setQueryData( + ["system-relations"], + context.previousRelations, + ); + } + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + onSuccess: () => { + // Quiet mutate + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["system-relations"] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["system-relations"] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + }, 500); + }, + }); + + const addTenantRelationMutation = useMutation({ + mutationFn: (payload: { + tenantId: string; + userId: string; + relation: string; + }) => addTenantRelation(payload.tenantId, payload.userId, payload.relation), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["tenant-relations", variables.tenantId], + }); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const removeTenantRelationMutation = useMutation({ + mutationFn: (payload: { + tenantId: string; + userId: string; + relation: string; + }) => + removeTenantRelation(payload.tenantId, payload.userId, payload.relation), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["tenant-relations", variables.tenantId], + }); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const updateUserRoleMutation = useMutation({ + mutationFn: (payload: { userIds: string[]; role: string }) => + bulkUpdateUsers(payload), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["admin-users"] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + toast.success( + variables.role === "super_admin" + ? t( + "msg.admin.permissions_direct.super_admin_grant_success", + "Super Admin 역할이 부여되었습니다.", + ) + : t( + "msg.admin.permissions_direct.super_admin_revoke_success", + "Super Admin 역할을 회수했습니다.", + ), + ); + setSelectedSuperAdminUserIds([]); + if (variables.role === "super_admin") { + setQueuedSuperAdminGrantUsers([]); + setSuperAdminGrantSearch(""); + } + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const toggleSuperAdminUser = (userId: string, checked: boolean) => { + setSelectedSuperAdminUserIds((current) => + checked + ? [...new Set([...current, userId])] + : current.filter((id) => id !== userId), + ); + }; + + const queueSuperAdminGrantUser = (user: UserSummary) => { + setQueuedSuperAdminGrantUsers((current) => { + if (current.some((queuedUser) => queuedUser.id === user.id)) { + return current; + } + return [...current, user]; + }); + }; + + const removeQueuedSuperAdminGrantUser = (userId: string) => { + setQueuedSuperAdminGrantUsers((current) => + current.filter((user) => user.id !== userId), + ); + }; + + const resolveBulkRelation = () => { + if (bulkRelationMode === "page") { + return bulkPageRelation; + } + return `${bulkTenantPage}_${bulkAction === "manage" ? "managers" : "viewers"}`; + }; + + const handleBulkRelationSubmit = async () => { + if (queuedTargetUsers.length === 0) { + toast.error( + t( + "msg.admin.permissions_direct.bulk_users_required", + "권한을 적용할 사용자를 하나 이상 선택하세요.", + ), + ); + return; + } + + const relation = resolveBulkRelation(); + if ( + bulkRelationMode === "page" && + relation.startsWith("permissions_direct_") + ) { + toast.error( + t( + "msg.admin.permissions_direct.protected_relation", + "권한 부여 화면 접근 권한은 Super Admin 전용입니다.", + ), + ); + return; + } + if (bulkRelationMode === "target-action" && !targetTenantId) { + toast.error( + t( + "msg.admin.permissions_direct.target_tenant_required", + "대상 테넌트를 선택하세요.", + ), + ); + return; + } + + for (const user of queuedTargetUsers) { + if (bulkRelationMode === "page") { + await addSystemRelationMutation.mutateAsync({ + userId: user.id, + relation, + }); + } else { + const currentSystemRelations = + systemRelations.find((item) => item.userId === user.id)?.relations ?? + []; + const requiredPageAccess = + bulkAction === "manage" ? "tenants_managers" : "tenants_viewers"; + if (!currentSystemRelations.includes(requiredPageAccess)) { + await addSystemRelationMutation.mutateAsync({ + userId: user.id, + relation: requiredPageAccess, + }); + } + await addTenantRelationMutation.mutateAsync({ + tenantId: targetTenantId, + userId: user.id, + relation, + }); + } + } + + toast.success( + t( + "msg.admin.permissions_direct.bulk_grant_success", + "선택 사용자에게 권한을 부여했습니다.", + ), + ); + setQueuedTargetUsers([]); + }; + + const handleRevokeSuperAdminRole = () => { + if (selectedSuperAdminUserIds.length === 0) { + toast.error( + t( + "msg.admin.permissions_direct.super_admin_users_required", + "회수할 Super Admin 사용자를 하나 이상 선택하세요.", + ), + ); + return; + } + updateUserRoleMutation.mutate({ + userIds: selectedSuperAdminUserIds, + role: "user", + }); + }; + + const handleGrantSuperAdminRole = () => { + if (queuedSuperAdminGrantUsers.length === 0) { + toast.error( + t( + "msg.admin.permissions_direct.super_admin_grant_users_required", + "부여할 사용자를 하나 이상 추가하세요.", + ), + ); + return; + } + updateUserRoleMutation.mutate({ + userIds: queuedSuperAdminGrantUsers.map((user) => user.id), + role: "super_admin", + }); + }; + + const queueTargetUsers = useCallback((users: UserSummary[]) => { + setQueuedTargetUsers((current) => { + const next = [...current]; + const ids = new Set(current.map((user) => user.id)); + for (const user of users) { + if (ids.has(user.id)) continue; + ids.add(user.id); + next.push(user); + } + return next; + }); + }, []); + + const removeQueuedTargetUser = (userId: string) => { + setQueuedTargetUsers((current) => + current.filter((user) => user.id !== userId), + ); + }; + + useEffect(() => { + if (activePermissionTab !== "direct") return; + + const onMessage = (event: MessageEvent) => { + const selections = parseOrgChartUserSelections(event.data); + if (selections.length === 0) return; + + queueTargetUsers( + selections.map((selection) => ({ + id: selection.id, + name: selection.name, + email: selection.email, + tenantSlug: selection.leafTenantName, + tenant: selection.leafTenantName + ? { + id: "", + type: "ORGANIZATION", + slug: "", + name: selection.leafTenantName, + description: "", + status: "active", + memberCount: 0, + createdAt: "", + updatedAt: "", + } + : undefined, + metadata: { + rootTenantName: selection.rootTenantName, + leafTenantName: selection.leafTenantName, + }, + role: "user", + status: "active", + createdAt: "", + updatedAt: "", + })), + ); + }; + + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [activePermissionTab, queueTargetUsers]); + + // Categorized system menus with descriptions and icons + const systemMenuCategories = [ + { + title: t( + "ui.admin.permissions_direct.cat_dashboard", + "핵심 대시보드 및 분석", + ), + menus: [ + { + label: t("ui.admin.nav.overview", "개요"), + relation: "overview", + desc: t( + "msg.admin.permissions_direct.desc_overview", + "바론 전체 사양 및 시스템 상태 개요 정보", + ), + icon: LayoutDashboard, + }, + { + label: t("ui.admin.nav.audit_logs", "감사 로그"), + relation: "audit_logs", + desc: t( + "msg.admin.permissions_direct.desc_audit_logs", + "시스템 전역 보안 감사 및 접속 이력 로그", + ), + icon: NotebookTabs, + }, + ], + }, + { + title: t("ui.admin.permissions_direct.cat_resources", "핵심 리소스 관리"), + menus: [ + { + label: t("ui.admin.nav.tenants", "테넌트"), + relation: "tenants", + desc: t( + "msg.admin.permissions_direct.desc_tenants", + "고객 테넌트 목록, 신규 부모-자식 테넌트 관리", + ), + icon: Building2, + }, + { + label: t("ui.admin.nav.org_chart", "조직도"), + relation: "org_chart", + desc: t( + "msg.admin.permissions_direct.desc_org_chart", + "조직도 가시화 및 트리 배치 확인", + ), + icon: Network, + }, + { + label: t("ui.admin.nav.users", "사용자"), + relation: "users", + desc: t( + "msg.admin.permissions_direct.desc_users", + "가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입", + ), + icon: Users, + }, + ], + }, + { + title: t( + "ui.admin.permissions_direct.cat_integrations", + "인프라 연동 및 보안", + ), + menus: [ + { + label: t("ui.admin.nav.worksmobile", "Worksmobile"), + relation: "worksmobile", + desc: t( + "msg.admin.permissions_direct.desc_worksmobile", + "라인웍스 연동 및 사내 임직원 패스워드 강제 동기화", + ), + icon: Share2, + }, + { + label: t("ui.admin.nav.api_keys", "API 키"), + relation: "api_keys", + desc: t( + "msg.admin.permissions_direct.desc_api_keys", + "조직도 연동을 위한 전역 서드파티 토큰 관리", + ), + icon: Key, + }, + ], + }, + { + title: t( + "ui.admin.permissions_direct.cat_system", + "아이덴티티 및 게이트 관리", + ), + menus: [ + { + label: t("ui.admin.nav.ory_ssot", "Ory SSOT 시스템"), + relation: "ory_ssot", + desc: t( + "msg.admin.permissions_direct.desc_ory_ssot", + "Redis 아이덴티티 미러 캐시 및 PostgreSQL read model 정합성 갱신", + ), + icon: Database, + }, + { + label: t("ui.admin.nav.data_integrity", "데이터 정합성"), + relation: "data_integrity", + desc: t( + "msg.admin.permissions_direct.desc_data_integrity", + "고아 레코드 검출 및 DB 정합성 최종 검증기", + ), + icon: ShieldCheck, + }, + { + label: t("ui.admin.nav.auth_guard", "인증 가드"), + relation: "auth_guard", + desc: t( + "msg.admin.permissions_direct.desc_auth_guard", + "정책엔진 기준으로 Keto ReBAC 관계 검증 시뮬레이터", + ), + icon: KeyRound, + }, + { + label: t("ui.admin.nav.permissions_direct", "권한 부여"), + relation: "permissions_direct", + desc: t( + "msg.admin.permissions_direct.desc_permissions_direct", + "본 사이드바 메뉴 세부 권한 격자 및 테넌트 인가 설정 패널", + ), + icon: Shield, + }, + ], + }, + ]; + + const grantableSystemMenus = systemMenuCategories.flatMap((category) => + category.menus.filter( + (menu) => !protectedSystemMenuRelations.has(menu.relation), + ), + ); + const menuByRelation = new Map( + systemMenuCategories + .flatMap((category) => category.menus) + .map((menu) => [menu.relation, menu]), + ); + const pageRelationOptions = grantableSystemMenus.flatMap((menu) => [ + { + label: `${menu.label} - ${t("ui.common.read", "조회")}`, + value: `${menu.relation}_viewers`, + }, + { + label: `${menu.label} - ${t("ui.common.write", "수정")}`, + value: `${menu.relation}_managers`, + }, + ]); + const tenantPermissionPages = [ + { + value: "profile", + label: t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필"), + }, + { + value: "permissions", + label: t("ui.admin.tenants.detail.tab_permissions", "권한 관리"), + }, + { + value: "organization", + label: t("ui.admin.tenants.detail.tab_organization", "조직 관리"), + }, + { + value: "schema", + label: t("ui.admin.tenants.detail.tab_schema", "사용자 스키마"), + }, + ]; + const selectedTargetTenant = tenants.find( + (tenant) => tenant.id === targetTenantId, + ); + const tenantPickerCandidates = tenants.filter((tenant) => { + const query = tenantPickerSearch.trim().toLowerCase(); + if (!query) return true; + return ( + tenant.name.toLowerCase().includes(query) || + tenant.slug.toLowerCase().includes(query) + ); + }); + const permissionAssignmentRows = systemRelations.flatMap((user) => + user.relations.map((relation) => { + const level = relation.endsWith("_managers") ? "write" : "read"; + const target = relation.replace(/_(viewers|managers)$/, ""); + const menu = menuByRelation.get(target); + return { + scope: "system" as const, + user, + relation, + target, + level, + label: menu?.label ?? target, + tenantId: "", + tenantName: t("ui.admin.permissions_direct.scope_system", "전역"), + protected: protectedSystemMenuRelations.has(target), + }; + }), + ); + const tenantPermissionPageByValue = new Map( + tenantPermissionPages.map((page) => [page.value, page.label]), + ); + const tenantPermissionAssignmentRows = tenantRelations.flatMap((user) => + user.relations.map((relation) => { + const level = relation.endsWith("_managers") ? "write" : "read"; + const target = relation.replace(/_(viewers|managers)$/, ""); + return { + scope: "tenant" as const, + user, + relation, + target, + level, + label: tenantPermissionPageByValue.get(target) ?? target, + tenantId: targetTenantId, + tenantName: + selectedTargetTenant?.name ?? + t("ui.admin.permissions_direct.scope_tenant", "테넌트"), + protected: false, + }; + }), + ); + const allPermissionAssignmentRows = + bulkRelationMode === "target-action" + ? tenantPermissionAssignmentRows + : permissionAssignmentRows; + const filteredPermissionAssignmentRows = allPermissionAssignmentRows + .filter((row) => { + const query = assignmentSearchTerm.trim().toLowerCase(); + if (!query) return true; + return ( + row.user.name.toLowerCase().includes(query) || + row.user.email.toLowerCase().includes(query) || + row.relation.toLowerCase().includes(query) || + row.label.toLowerCase().includes(query) + ); + }) + .sort((a, b) => { + if (assignmentSort === "relation") { + const relationCompare = a.label.localeCompare(b.label); + if (relationCompare !== 0) return relationCompare; + } + if (assignmentSort === "level") { + const levelCompare = a.level.localeCompare(b.level); + if (levelCompare !== 0) return levelCompare; + } + return a.user.name.localeCompare(b.user.name); + }); + + const handleAssignmentLevelChange = async ( + scope: "system" | "tenant", + tenantId: string, + userId: string, + relation: string, + nextLevel: "none" | "read" | "write", + ) => { + const target = relation.replace(/_(viewers|managers)$/, ""); + if (scope === "system" && protectedSystemMenuRelations.has(target)) return; + + if (scope === "system") { + await removeSystemRelationMutation.mutateAsync({ userId, relation }); + } else { + await removeTenantRelationMutation.mutateAsync({ + tenantId, + userId, + relation, + }); + } + if (nextLevel === "read") { + if (scope === "system") { + await addSystemRelationMutation.mutateAsync({ + userId, + relation: `${target}_viewers`, + }); + } else { + await addTenantRelationMutation.mutateAsync({ + tenantId, + userId, + relation: `${target}_viewers`, + }); + } + } else if (nextLevel === "write") { + if (scope === "system") { + await addSystemRelationMutation.mutateAsync({ + userId, + relation: `${target}_managers`, + }); + } else { + await addTenantRelationMutation.mutateAsync({ + tenantId, + userId, + relation: `${target}_managers`, + }); + } + } + }; + + const handleAssignmentRemove = async ( + scope: "system" | "tenant", + tenantId: string, + userId: string, + relation: string, + ) => { + if (scope === "system") { + await removeSystemRelationMutation.mutateAsync({ userId, relation }); + } else { + await removeTenantRelationMutation.mutateAsync({ + tenantId, + userId, + relation, + }); + } + }; + + if (profile && !isSuperAdmin) { + return ( +
+

+ {t("msg.admin.common.forbidden", "접근 권한이 없습니다.")} +

+ +
+ ); + } + + return ( +
+
+

+ + {t("ui.admin.nav.permissions_direct", "권한 부여")} +

+

+ {t( + "msg.admin.permissions_direct.description", + "테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다.", + )} +

+
+ +
+ + {isSuperAdmin && ( + + )} +
+ + {activePermissionTab === "direct" && ( + <> +
+
+

+ {t( + "ui.admin.permissions_direct.bulk_title", + "다중 사용자 권한 부여 및 회수", + )} +

+

+ {t( + "msg.admin.permissions_direct.bulk_description", + "페이지별 접근 권한 또는 대상+액션 권한을 선택한 사용자들에게 동시에 적용합니다.", + )} +

+
+
+
+
+

+ {t("ui.admin.permissions_direct.bulk_users", "적용 대상")} +

+ + {queuedTargetUsers.length} + {t("ui.admin.permissions_direct.bulk_selected", "명 선택")} + +
+
+
+