diff --git a/.env.sample b/.env.sample index d39170df..af98d230 100644 --- a/.env.sample +++ b/.env.sample @@ -36,6 +36,34 @@ CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token +# --- NAVER WORKS Drive backup upload --- +# Drive API 업로드에는 `file` scope가 필요합니다. +# 운영에서는 Drive 권한이 위임된 사용자/OAuth access token을 우선 사용하세요. +# 서비스 계정 JWT 방식은 WORKS 앱 정책에서 Drive API scope 위임이 허용된 경우에만 사용할 수 있습니다. +WORKS_DRIVE_TARGET=sharedrive +WORKS_DRIVE_SHARED_DRIVE_ID= +WORKS_DRIVE_PARENT_FILE_ID= +WORKS_DRIVE_USER_ID=me +WORKS_DRIVE_GROUP_ID= +WORKS_DRIVE_SHARED_FOLDER_ID= +WORKS_DRIVE_ACCESS_TOKEN= +WORKS_DRIVE_ACCESS_TOKEN_FILE= +WORKS_DRIVE_ACCESS_TOKEN_CMD= +WORKS_DRIVE_OAUTH_SCOPE=file +WORKS_DRIVE_OAUTH_CLIENT_ID= +WORKS_DRIVE_OAUTH_CLIENT_SECRET= +WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT= +WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE=./config/worksmobile-driveapp-private-key.pem +WORKS_DRIVE_OAUTH_REFRESH_TOKEN= +WORKS_DRIVE_OAUTH_REDIRECT_URI= +WORKS_DRIVE_SPLIT_SIZE=9000M +WORKS_DRIVE_MAX_SINGLE_FILE_BYTES=0 +WORKS_DRIVE_FORCE_SPLIT=false +WORKS_DRIVE_OVERWRITE=false +WORKS_DRIVE_DRY_RUN=false +WORKS_DRIVE_UPLOAD_REPORTS=true +WORKS_DRIVE_REPORT_FOLDER_NAME=reports + # Audit System Configuration AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수 diff --git a/.gitea/workflows/staging_build_check.yml b/.gitea/workflows/staging_build_check.yml new file mode 100644 index 00000000..c569137b --- /dev/null +++ b/.gitea/workflows/staging_build_check.yml @@ -0,0 +1,83 @@ +name: Staging Build Check + +on: + pull_request: + paths: + - ".gitea/workflows/staging_build_check.yml" + - "docker/staging_pull_compose.template.yaml" + - "adminfront/**" + - "devfront/**" + - "userfront/**" + - "backend/**" + - "common/**" + - "scripts/**" + - "locales/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + workflow_dispatch: + +jobs: + build-check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - service: adminfront + - service: devfront + - service: userfront + - service: backend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Prepare staging build inputs + run: | + set -euo pipefail + + cat <<'EOF' > .env + APP_ENV=stage + TZ=Asia/Seoul + IDP_PROVIDER=ory + ADMINFRONT_URL=https://adminfront.staging.example.com + DEVFRONT_URL=https://devfront.staging.example.com + USERFRONT_URL=https://userfront.staging.example.com + ORGFRONT_URL=https://orgfront.staging.example.com + BACKEND_URL=https://backend.staging.example.com + BACKEND_PUBLIC_URL=https://backend.staging.example.com + VITE_OIDC_AUTHORITY=https://sso.staging.example.com/oidc + WORKS_ADMIN_API_BASE_URL=https://works-admin.staging.example.com/api + WORKS_ADMIN_OAUTH_TOKEN_URL=https://works-admin.staging.example.com/oauth/token + ORY_POSTGRES_USER=ory + ORY_POSTGRES_PASSWORD=ory-password + COOKIE_SECRET=staging-build-cookie-secret + JWT_SECRET=staging-build-jwt-secret + NAVER_CLOUD_ACCESS_KEY=dummy + NAVER_CLOUD_SECRET_KEY=dummy + NAVER_CLOUD_SERVICE_ID=dummy + NAVER_SENDER_PHONE_NUMBER=00000000000 + AWS_REGION=ap-northeast-2 + AWS_ACCESS_KEY_ID=dummy + AWS_SECRET_ACCESS_KEY=dummy + AWS_SES_SENDER=dummy@example.com + REDIS_ADDR=redis:6389 + CLICKHOUSE_PORT_NATIVE=9000 + CLICKHOUSE_USER=baron + CLICKHOUSE_PASSWORD=password + HYDRA_PUBLIC_URL=https://hydra.staging.example.com + KRATOS_BROWSER_URL=https://sso.staging.example.com + KRATOS_ADMIN_URL=http://kratos:4434 + KRATOS_UI_URL=https://sso.staging.example.com + EOF + + cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml + + - name: Build ${{ matrix.service }} with staging compose + env: + DOCKER_BUILDKIT: "1" + COMPOSE_DOCKER_CLI_BUILD: "1" + run: | + set -euo pipefail + docker compose -f staging_pull_compose.yaml build --pull --progress=plain "${{ matrix.service }}" diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index bf9665a3..b0ea8b80 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -133,6 +133,8 @@ jobs: ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }} KRATOS_ALLOWED_RETURN_URLS_JSON=${{ vars.KRATOS_ALLOWED_RETURN_URLS_JSON }} KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.KRATOS_ALLOWED_RETURN_URLS_EXTRA }} + STAGING_PUBLIC_HEALTH_URL=${{ vars.STAGING_PUBLIC_HEALTH_URL }} + STAGING_PUBLIC_HEALTH_MAX_ATTEMPTS=${{ vars.STAGING_PUBLIC_HEALTH_MAX_ATTEMPTS }} # OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }} # OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} @@ -195,7 +197,7 @@ jobs: max="${FRONTEND_HEALTH_MAX_ATTEMPTS:-60}" i=1 while [ "${i}" -le "${max}" ]; do - if docker exec "${name}" node -e "fetch('http://127.0.0.1:${port}/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then + if docker exec "${name}" sh -c "if command -v wget >/dev/null 2>&1; then wget -qO- 'http://127.0.0.1:${port}/' >/dev/null; elif command -v node >/dev/null 2>&1; then node -e \"fetch('http://127.0.0.1:${port}/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"; else exit 127; fi" >/dev/null 2>&1; then echo "Frontend ready: ${name}:${port}" return 0 fi @@ -208,9 +210,55 @@ jobs: return 1 } + check_container_url() { + name="$1" + url="$2" + max="${FRONTEND_HEALTH_MAX_ATTEMPTS:-60}" + i=1 + while [ "${i}" -le "${max}" ]; do + if docker exec "${name}" sh -c "if command -v wget >/dev/null 2>&1; then wget -qO- '${url}' >/dev/null; elif command -v node >/dev/null 2>&1; then node -e \"fetch('${url}').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"; else exit 127; fi" >/dev/null 2>&1; then + echo "Container URL ready: ${name} ${url}" + return 0 + fi + echo "Waiting for container URL: ${name} ${url} (${i}/${max})" + i=$((i + 1)) + sleep 2 + done + echo "ERROR: container URL not ready: ${name} ${url}" >&2 + docker logs "${name}" --tail 200 >&2 || true + return 1 + } + + check_public_http() { + url="$1" + if [ -z "${url}" ]; then + echo "ERROR: STAGING_PUBLIC_HEALTH_URL is required." >&2 + return 1 + fi + max="${STAGING_PUBLIC_HEALTH_MAX_ATTEMPTS:-30}" + i=1 + while [ "${i}" -le "${max}" ]; do + if curl -fsS --max-time 10 "${url}" >/dev/null; then + echo "Public staging URL ready: ${url}" + return 0 + fi + echo "Waiting for public staging URL: ${url} (${i}/${max})" + i=$((i + 1)) + sleep 2 + done + echo "ERROR: public staging URL not ready: ${url}" >&2 + docker compose -f staging_pull_compose.yaml ps >&2 || true + docker logs baron_gateway --tail 200 >&2 || true + return 1 + } + + check_container_url baron_backend http://127.0.0.1:3000/health + check_container_http baron_userfront 5000 + check_container_http baron_gateway 5000 check_container_http baron_adminfront 5173 check_container_http baron_devfront 5173 check_container_http baron_orgfront 5175 + check_public_http "${STAGING_PUBLIC_HEALTH_URL}" echo "===== INIT-RP LOGS =====" docker compose -f staging_pull_compose.yaml logs init-rp || true diff --git a/Makefile b/Makefile index d207fcb9..99d92498 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,22 @@ ifneq (,$(wildcard ./.env)) COMPOSE_DROP_ENV_ARGS += --env-file .env endif -.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app +DUMP_SERVICES ?= all +RESTORE_SERVICES ?= all +DUMP_MODE ?= maintenance +BACKUP_USE_DOCKER ?= true +BACKUP_TOOLS_IMAGE ?= baron-sso-backup-tools:local +BACKUP_TOOLS_DOCKERFILE ?= docker/backup-tools/Dockerfile +BACKUP_DOCKER_ENV_ARGS := +ifneq (,$(wildcard ./.env)) +BACKUP_DOCKER_ENV_ARGS += --env-file .env +endif +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) + +.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 # --- 인증 설정 빌드/검증 --- build-auth-config: @@ -188,6 +203,56 @@ logs-ory: logs-app: docker compose -f $(COMPOSE_APP) logs -f +# --- 백업/복구 --- +backup-tools-build: + docker build -f $(BACKUP_TOOLS_DOCKERFILE) -t $(BACKUP_TOOLS_IMAGE) . + +ifeq ($(BACKUP_USE_DOCKER),true) +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' + +dump-verify: backup-tools-build + $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh' + +restore-verify: backup-tools-build + $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh' + +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' + +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' +else +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 + +dump-verify: + BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh + +restore-verify: + BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh + +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 + +upload-cloud: + WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh +endif + +dump-upload-cloud: dump upload-cloud + # --- 로컬 통합 코드 체크 --- PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE diff --git a/README.md b/README.md index 66c161b6..6a41a4a1 100644 --- a/README.md +++ b/README.md @@ -527,6 +527,155 @@ docker compose --env-file .env --env-file config/.generated/auth-config.env -f d - **Hydra Public**: http://localhost:4444 - **Kratos UI (UserFront)**: http://localhost:5000 +### 전체 백업/복구 + +전체 백업/복구는 CSV export/import가 아니라 Baron SSO와 Ory Stack 저장소를 같은 시점의 재해 복구 단위로 보존하는 절차입니다. 사용자 UUID, Kratos identity ID, Hydra/Keto 원장, WORKS 연동 mapping이 어긋나면 안 되므로 운영 복구는 DB dump와 설정 snapshot을 함께 다룹니다. + +#### 백업 실행 +```bash +# 전체 백업 +make dump + +# 출력 위치를 직접 지정 +make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ + +# 일부 서비스만 백업 +make dump DUMP_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config +make dump DUMP_SERVICES=ory-postgres,ory-clickhouse + +# 생성된 백업 검증 +make dump-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ + +# WORKS Drive로 외부 분산 저장 +make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ + +# 지정 경로로 dump 후 바로 WORKS Drive 업로드 +make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ + +# 로컬 백업 목록 +make dump-list +``` + +기본값은 `DUMP_SERVICES=all`, `DUMP_MODE=maintenance`입니다. `DUMP_SERVICES`는 다음 값을 콤마로 조합할 수 있습니다. + +| 값 | 대상 | +| --- | --- | +| `postgres` | Baron Postgres (`baron_postgres`, `${DB_NAME:-baron_sso}`) | +| `ory-postgres` | Ory Postgres의 `${KRATOS_DB:-ory_kratos}`, `${HYDRA_DB:-ory_hydra}`, `${KETO_DB:-ory_keto}` | +| `clickhouse` | Baron ClickHouse (`baron_clickhouse`) | +| `ory-clickhouse` | Ory ClickHouse (`ory_clickhouse`) | +| `config` | `.env` redacted copy, generated Ory config, gateway, 주요 compose 파일 | + +백업 산출물은 기본적으로 `backups/baron-sso-backup-YYYYMMDD-HHMMSSZ/` 아래에 생성됩니다. + +```text +manifest.json +checksums.sha256 +postgres/ +clickhouse/ +config/ +reports/ +``` + +#### WORKS Drive 외부 업로드 + +`make dump`, `make restore`, `make upload-cloud`는 기본적으로 `docker/backup-tools/Dockerfile`에서 빌드한 `baron-sso-backup-tools:local` 컨테이너 안에서 실행됩니다. 호스트에는 Docker와 Docker socket 접근 권한만 필요하고, `zstd`, `jq`, `curl`, `openssl`, `postgresql-client` 같은 백업/복구 도구는 backup-tools image에 포함됩니다. + +`make upload-cloud`는 기존 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 묶은 뒤 WORKS Drive에 업로드합니다. 압축 포맷은 `.tar.zst`로 고정되어 있고, 압축/해제는 backup-tools 컨테이너 내부의 `zstd`로 수행합니다. + +백업이 완료되면 `reports/backup-report.md`도 생성됩니다. 이 report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록됩니다. `make upload-cloud`는 `reports/*.md`만 WORKS Drive 대상 폴더 아래의 `reports` 하위 폴더로 업로드하며, 업로드 파일명은 `backup-report-YYYYMMDD-HHMMSSZ.md`처럼 업로드 시각을 붙입니다. `reports/cloud-upload.json`은 로컬 업로드 실행 기록으로만 남기고 Drive에는 업로드하지 않습니다. + +```bash +# 권장: 백업 경로를 명시해서 dump와 upload를 분리 +make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ +make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ + +# 또는 같은 BACKUP 경로로 연속 실행 +make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ + +# 실제 업로드 전 endpoint와 target만 확인 +make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ WORKS_DRIVE_DRY_RUN=true + +# 예외적으로 호스트 도구로 직접 실행 +make restore BACKUP_USE_DOCKER=false BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ CONFIRM_RESTORE=baron-sso +``` + +주요 변수: + +| 변수 | 설명 | +| --- | --- | +| `WORKS_DRIVE_TARGET` | `sharedrive`, `mydrive`, `group`, `sharedfolder` 중 하나. 기본값은 `sharedrive`입니다. | +| `WORKS_DRIVE_SHARED_DRIVE_ID` | `WORKS_DRIVE_TARGET=sharedrive`일 때 공용 드라이브 ID입니다. | +| `WORKS_DRIVE_PARENT_FILE_ID` | 업로드할 대상 폴더의 WORKS Drive `fileId`입니다. 폴더 이름이나 경로가 아니며, 비우면 대상 drive/folder root에 업로드합니다. | +| `WORKS_DRIVE_USER_ID` | `mydrive` 또는 `sharedfolder` 대상 사용자 ID입니다. 기본값은 `me`입니다. | +| `WORKS_DRIVE_GROUP_ID` | `WORKS_DRIVE_TARGET=group`일 때 조직/그룹 ID입니다. | +| `WORKS_DRIVE_SHARED_FOLDER_ID` | `WORKS_DRIVE_TARGET=sharedfolder`일 때 공유받은 폴더 ID입니다. | +| `WORKS_DRIVE_ACCESS_TOKEN` | Drive API 호출용 Bearer token입니다. Drive API는 `file` scope가 필요합니다. | +| `WORKS_DRIVE_ACCESS_TOKEN_FILE` | access token을 파일에서 읽을 때 사용합니다. | +| `WORKS_DRIVE_ACCESS_TOKEN_CMD` | access token을 명령 출력으로 주입할 때 사용합니다. | +| `WORKS_DRIVE_OAUTH_SCOPE` | Drive 업로드 앱 OAuth token에 사용할 scope입니다. 기본값은 `file`입니다. | +| `WORKS_DRIVE_OAUTH_CLIENT_ID` | Drive 업로드 앱의 OAuth client ID입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_CLIENT_ID`와 분리합니다. | +| `WORKS_DRIVE_OAUTH_CLIENT_SECRET` | Drive 업로드 앱의 OAuth client secret입니다. | +| `WORKS_DRIVE_OAUTH_REFRESH_TOKEN` | Drive 업로드 앱의 refresh token입니다. 명시 access token이 없으면 이 값으로 access token을 갱신합니다. | +| `WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT` | Drive 업로드 앱의 service account입니다. JWT `sub`에 들어갑니다. | +| `WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE` | Drive 업로드 앱 private key 파일입니다. 예: `./config/worksmobile-driveapp-private-key.pem` | +| `WORKS_DRIVE_SPLIT_SIZE` | 분할 업로드 시 part 크기입니다. 기본값은 `9000M`입니다. | +| `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` | 이 값보다 archive가 크면 split part로 나눕니다. 기본값 `0`은 자동 분할 비활성입니다. | +| `WORKS_DRIVE_FORCE_SPLIT` | `true`이면 크기와 무관하게 split part로 업로드합니다. | +| `WORKS_DRIVE_OVERWRITE` | WORKS Drive upload URL 생성 요청의 overwrite 플래그입니다. 기본값은 `false`입니다. | +| `WORKS_DRIVE_UPLOAD_REPORTS` | `true`이면 `reports/*.md`를 Drive의 report 폴더로 함께 업로드합니다. 기본값은 `true`입니다. | +| `WORKS_DRIVE_REPORT_FOLDER_NAME` | Markdown report를 업로드할 하위 폴더 이름입니다. 기본값은 `reports`입니다. | + +Drive API는 업로드 URL 생성 후 해당 URL에 multipart `Filedata`로 실제 파일을 전송하는 2단계 방식입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 Drive 업로드용 `WORKS_DRIVE_OAUTH_*`는 서로 다른 앱/키로 관리합니다. token 우선순위는 `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 서비스 계정 JWT fallback 순서입니다. 운영에서는 Drive API 권한과 `file` scope 위임 정책을 먼저 확인해야 합니다. + +#### 복구 계획과 복구 실행 +```bash +# 복구 전 계획 확인 +make restore-plan BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \ + RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \ + CONFIRM_RESTORE=baron-sso + +# 복구 실행 +make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \ + RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \ + CONFIRM_RESTORE=baron-sso + +# .tar.zst archive를 직접 복구 입력으로 사용 +make restore DUMP_FILE=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ.tar.zst \ + RESTORE_SERVICES=all \ + CONFIRM_RESTORE=baron-sso + +# report 경로를 명시 +make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \ + CONFIRM_RESTORE=baron-sso \ + RESTORE_REPORT=reports/restore/baron-sso-restore-report.json + +# 복구 후 기본 검증 +make restore-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ +``` + +복구는 반드시 빈 volume 또는 restore 전용 stack에서 수행하는 것을 기본 정책으로 합니다. `make restore`는 `BACKUP` 또는 `DUMP_FILE` 중 하나와 `CONFIRM_RESTORE=baron-sso`가 없으면 실패하고, 기본적으로 non-empty Postgres 대상에는 복구하지 않습니다. 승인된 restore rehearsal에서만 `ALLOW_NON_EMPTY_RESTORE=true`를 사용하세요. `DUMP_FILE=.tar.zst` 해제도 backup-tools 컨테이너에서 수행하므로 호스트 `zstd` 설치에 의존하지 않습니다. + +`make restore`는 복구 report를 JSON과 Markdown으로 남깁니다. `BACKUP` 디렉터리 입력의 기본 JSON report는 `/reports/restore-report.json`이고, `DUMP_FILE` archive 입력의 기본 JSON report는 `reports/restore/-restore-report.json`입니다. 같은 경로에 `.md` 확장자의 Markdown 요약도 함께 생성됩니다. `RESTORE_REPORT`로 직접 지정할 수 있습니다. report에는 입력 archive, 복구 서비스, checksum 검증 상태, 복구 후 대상 row count 비교 결과가 기록됩니다. + +`config` 복구는 운영 파일을 직접 덮어쓰지 않고 `config-restored/`에 풀어 수동 검토하도록 합니다. migration은 자동 실행하지 않으며, Ory Stack과 backend 기동 후 super admin login, 대표 OIDC login, WORKS comparison dry-run을 통과하기 전까지 WORKS relay를 자동 재개하지 않습니다. + +#### 백업/복구 범위 + +필수 백업 대상: +- Baron Postgres: users, tenants, user_login_ids, user_groups, RP metadata, WORKS mapping/outbox 등 +- Ory Postgres: Kratos identity/credentials/session, Hydra client/consent/token state, Keto relation tuple +- Baron ClickHouse: 감사 로그와 RP usage event +- Ory ClickHouse: Oathkeeper/Ory 계열 접근 로그 +- 설정 snapshot: `.env` redacted copy, generated Ory config, gateway, compose 파일 + +기본 제외 대상: +- Redis: pending login, short code, cache 등 휘발성 데이터이므로 복구 후 재수렴 대상으로 봅니다. +- 프론트 빌드 산출물: 소스와 이미지 태그로 재생성합니다. +- coverage, reports, test-results 같은 로컬 개발 산출물 + +상세 설계와 운영 정책은 `docs/backup-restore-design.md`를 기준으로 유지합니다. + ### MCP 서버 (Hydra/Kratos/Keto) MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다. 프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요. diff --git a/adminfront/src/components/layout/AppLayout.test.tsx b/adminfront/src/components/layout/AppLayout.test.tsx index 366e3f10..6634a80a 100644 --- a/adminfront/src/components/layout/AppLayout.test.tsx +++ b/adminfront/src/components/layout/AppLayout.test.tsx @@ -127,6 +127,22 @@ describe("admin AppLayout", () => { expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull(); }); + it("toggles the sidebar and persists the collapsed state", async () => { + renderLayout(); + + const collapseButton = await screen.findByRole("button", { + name: "사이드바 접기", + }); + fireEvent.click(collapseButton); + + expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe( + "true", + ); + expect( + screen.getByRole("button", { name: "사이드바 펼치기" }), + ).toBeInTheDocument(); + }); + it("opens profile menu, navigates, toggles theme/session, and logs out", async () => { renderLayout(); diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 23d8a065..929a0846 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -26,11 +26,13 @@ import { buildShellProfileSummary, buildShellSessionStatus, readShellSessionExpiryEnabled, + readShellSidebarCollapsed, readShellTheme, type ShellSidebarNavItem, type ShellTranslator, shellLayoutClasses, writeShellSessionExpiryEnabled, + writeShellSidebarCollapsed, } from "../../../../common/shell"; import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess"; import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; @@ -165,6 +167,9 @@ function AppLayout() { const isDevelopmentRuntime = import.meta.env.MODE === "development"; const [theme, setTheme] = useState<"light" | "dark">(readShellTheme); const [isProfileOpen, setIsProfileOpen] = useState(false); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => + readShellSidebarCollapsed(false), + ); const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => readShellSessionExpiryEnabled(!isDevelopmentRuntime), ); @@ -508,10 +513,18 @@ function AppLayout() { return next; }); }; + const handleSidebarToggle = () => { + setIsSidebarCollapsed((prev) => { + const next = !prev; + writeShellSidebarCollapsed(next); + return next; + }); + }; const sidebarNavContent = (
{navItems.map((item) => { const { labelKey, labelFallback, to, icon: Icon, isExternal } = item; + const label = t(labelKey, labelFallback); if (isExternal) { return ( @@ -522,11 +535,18 @@ function AppLayout() { rel="noopener noreferrer" className={[ shellLayoutClasses.navItemBase, + isSidebarCollapsed + ? shellLayoutClasses.navItemBaseCollapsed + : "", shellLayoutClasses.navItemIdle, ].join(" ")} + title={label} + aria-label={label} > - {t(labelKey, labelFallback)} + + {label} + ); } @@ -539,6 +559,9 @@ function AppLayout() { className={({ isActive }) => [ shellLayoutClasses.navItemBase, + isSidebarCollapsed + ? shellLayoutClasses.navItemBaseCollapsed + : "", item.isActive !== undefined ? item.isActive ? shellLayoutClasses.navItemActive @@ -548,9 +571,11 @@ function AppLayout() { : shellLayoutClasses.navItemIdle, ].join(" ") } + title={label} + aria-label={label} > - {t(labelKey, labelFallback)} + {label} ); })} @@ -561,10 +586,17 @@ function AppLayout() {
); @@ -578,13 +610,23 @@ function AppLayout() { } return ( -
+
} navContent={sidebarNavContent} footerContent={sidebarFooterContent} + collapsed={isSidebarCollapsed} + onToggleCollapsed={handleSidebarToggle} + collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")} + expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")} />
@@ -785,7 +827,7 @@ function AppLayout() {
- +
diff --git a/adminfront/src/features/auth/AuthGuard.test.tsx b/adminfront/src/features/auth/AuthGuard.test.tsx new file mode 100644 index 00000000..ce90319c --- /dev/null +++ b/adminfront/src/features/auth/AuthGuard.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import AuthGuard from "./AuthGuard"; + +const authState = { + activeNavigator: undefined, + error: undefined as Error | undefined, + isAuthenticated: false, + isLoading: false, + removeUser: vi.fn(async () => undefined), +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +function renderAuthGuard(initialEntry = "/users") { + return render( + + + }> + Users outlet} /> + + Login outlet} /> + + , + ); +} + +describe("AuthGuard", () => { + beforeEach(() => { + ( + window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } + )._IS_TEST_MODE = false; + authState.activeNavigator = undefined; + authState.error = undefined; + authState.isAuthenticated = false; + authState.isLoading = false; + authState.removeUser.mockClear(); + window.localStorage.clear(); + }); + + it("clears stale auth state and returns to login when OIDC reports an error", async () => { + window.localStorage.setItem("admin_session", "stale-token"); + authState.error = new Error("stale session"); + + renderAuthGuard(); + + await waitFor(() => { + expect(authState.removeUser).toHaveBeenCalled(); + }); + await screen.findByText("Login outlet"); + expect(window.localStorage.getItem("admin_session")).toBeNull(); + }); +}); diff --git a/adminfront/src/features/auth/AuthGuard.tsx b/adminfront/src/features/auth/AuthGuard.tsx index d809a7de..701c73c2 100644 --- a/adminfront/src/features/auth/AuthGuard.tsx +++ b/adminfront/src/features/auth/AuthGuard.tsx @@ -1,13 +1,31 @@ +import { useEffect, useRef } from "react"; import { useAuth } from "react-oidc-context"; -import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom"; +import { clearStoredAdminAuthSession } from "../../lib/auth"; export default function AuthGuard() { const auth = useAuth(); const location = useLocation(); + const navigate = useNavigate(); + const handledAuthErrorRef = useRef(false); const isTest = (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) ._IS_TEST_MODE === true; + useEffect(() => { + if (!auth.error || handledAuthErrorRef.current || isTest) { + return; + } + + handledAuthErrorRef.current = true; + clearStoredAdminAuthSession(); + void Promise.resolve( + auth.removeUser ? auth.removeUser() : undefined, + ).finally(() => { + navigate("/login", { replace: true }); + }); + }, [auth, auth.error, isTest, navigate]); + if (isTest) { return ; } diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index cc3e8af1..8f9900fe 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -1,9 +1,4 @@ -import { - type UseMutationResult, - useInfiniteQuery, - useMutation, - useQuery, -} from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { AxiosError } from "axios"; import { @@ -25,7 +20,7 @@ import { Upload, } from "lucide-react"; import * as React from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useOutletContext } from "react-router-dom"; import { PageHeader } from "../../../../../common/core/components/page"; import { type SortConfig, @@ -33,6 +28,7 @@ import { sortItems, toggleSort, } from "../../../../../common/core/utils"; +import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar"; import { commonStickyTableHeaderClass } from "../../../../../common/ui/table"; import { RoleGuard } from "../../../components/auth/RoleGuard"; import { Badge } from "../../../components/ui/badge"; @@ -68,7 +64,6 @@ import { SelectTrigger, SelectValue, } from "../../../components/ui/select"; -import { Switch } from "../../../components/ui/switch"; import { Table, TableBody, @@ -79,7 +74,6 @@ import { } from "../../../components/ui/table"; import { Tabs, TabsList, TabsTrigger } from "../../../components/ui/tabs"; import { toast } from "../../../components/ui/use-toast"; -import type { UserProfileResponse } from "../../../lib/adminApi"; import { deleteTenantsBulk, exportTenantsCSV, @@ -124,6 +118,10 @@ const tenantCSVTemplate = const tenantPageSize = 500; const _tenantVirtualizationThreshold = 250; const _tenantEstimatedRowHeight = 73; +const tenantTableHeadClassName = + "h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap"; +const tenantTableHeadInteractiveClassName = `${tenantTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`; +const tenantTableHeadContentClassName = "flex h-full items-center gap-1"; const _tenantLoadAheadPx = 360; const _tenantLoadAheadRows = 30; @@ -141,6 +139,70 @@ const getTenantIcon = (type?: string) => { } }; +function getTenantTypeLabel(type?: string) { + if (!type) return "-"; + return t(`domain.tenant_type.${type.toLowerCase()}`, type); +} + +function splitTenantTypeLabel(label: string) { + const match = label.match(/^(.*?)\s*(\(.+\))$/); + if (!match) { + return { primary: label, secondary: null as string | null }; + } + return { + primary: match[1].trim(), + secondary: match[2].trim(), + }; +} + +function abbreviateUuid(value: string) { + const parts = value.split("-"); + if (parts.length < 4) { + return value; + } + return `${parts.slice(0, 4).join("-")}-...`; +} + +function getTenantTypeTextClass(type?: string) { + switch (type?.toUpperCase()) { + case "COMPANY_GROUP": + return "text-sky-700"; + case "COMPANY": + return "text-violet-700"; + case "ORGANIZATION": + return "text-emerald-700"; + case "USER_GROUP": + return "text-amber-700"; + case "PERSONAL": + return "text-slate-700"; + default: + return "text-muted-foreground"; + } +} + +function buildTenantParentPathMap(tenants: TenantSummary[]) { + const tenantById = new Map(tenants.map((tenant) => [tenant.id, tenant])); + const pathMap = new Map(); + + for (const tenant of tenants) { + const names: string[] = []; + const visited = new Set(); + let currentParentId = tenant.parentId; + + while (currentParentId && !visited.has(currentParentId)) { + visited.add(currentParentId); + const parent = tenantById.get(currentParentId); + if (!parent) break; + names.unshift(parent.name); + currentParentId = parent.parentId; + } + + pathMap.set(tenant.id, names); + } + + return pathMap; +} + const noImportParentRef = "__none__"; function tenantParentRef(tenantId: string) { @@ -338,19 +400,6 @@ function TenantListPage() { }, }); - const statusMutation = useMutation({ - mutationFn: ({ tenantId, status }: { tenantId: string; status: string }) => - updateTenant(tenantId, { status }), - onSuccess: () => { - query.refetch(); - }, - onError: () => { - toast.error( - t("msg.admin.tenants.status_error", "테넌트 상태 변경에 실패했습니다."), - ); - }, - }); - const bulkUpdateStatusMutation = useMutation({ mutationFn: async ({ tenantIds, @@ -450,7 +499,6 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const tenantTotal = query.data?.pages[0]?.total ?? 0; const hanmacFamilyTenantId = React.useMemo(() => { const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; if (typeof envTenantId === "string" && envTenantId.trim()) { @@ -708,174 +756,187 @@ function TenantListPage() { "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.", )} actions={ - <> -
-
- - setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - query.refetch(); - } - }} - /> -
+
+ +
+ + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + query.refetch(); + } + }} + /> +
-
- - -
- - - {scopeTenantId ? ( - - ) : null} - - - - - +
- - - setViewMode("table")} + data-testid="tenant-view-table-btn" > - - {t("ui.admin.tenants.csv_template", "템플릿 다운로드")} - - - fileInputRef.current?.click()} - disabled={importMutation.isPending} - data-testid="tenant-import-menu-item" - className="cursor-pointer" - > - - {t("ui.admin.tenants.import", "CSV 가져오기")} - - - exportMutation.mutate(false)} - disabled={exportMutation.isPending} - data-testid="tenant-export-menu-item" - className="cursor-pointer" - > - - {t( - "ui.admin.tenants.export_without_ids", - "UUID 제외 내보내기", - )} - - exportMutation.mutate(true)} - disabled={exportMutation.isPending} - data-testid="tenant-export-with-ids-menu-item" - className="cursor-pointer" - > - - {t( - "ui.admin.tenants.export_with_ids", - "UUID 포함 내보내기", - )} - - - - + + {t("ui.admin.tenants.view.table", "평면")} + +
- - - - -
+ + {scopeTenantId ? ( + + ) : null} + + } + actions={ + <> + + + + + + + + + + {t( + "ui.admin.tenants.csv_template", + "템플릿 다운로드", + )} + + + fileInputRef.current?.click()} + disabled={importMutation.isPending} + data-testid="tenant-import-menu-item" + className="cursor-pointer" + > + + {t("ui.admin.tenants.import", "CSV 가져오기")} + + + exportMutation.mutate(false)} + disabled={exportMutation.isPending} + data-testid="tenant-export-menu-item" + className="cursor-pointer" + > + + {t( + "ui.admin.tenants.export_without_ids", + "UUID 제외 내보내기", + )} + + exportMutation.mutate(true)} + disabled={exportMutation.isPending} + data-testid="tenant-export-with-ids-menu-item" + className="cursor-pointer" + > + + {t( + "ui.admin.tenants.export_with_ids", + "UUID 포함 내보내기", + )} + + + + + + + + + + + } + /> {importMessage ? (
) : null} - +
} /> @@ -900,7 +961,9 @@ function TenantListPage() { "msg.admin.tenants.registry.count", "총 {{count}}개의 테넌트가 등록되어 있습니다.", { - count: scopeTenantId ? scopedTenants.length : tenantTotal, + count: scopeTenantId + ? scopedTenants.length + : allTenants.length, }, )} @@ -921,8 +984,6 @@ function TenantListPage() { onSelectAll={handleSelectAll} search={search} deletableTenants={deletableTenants} - statusMutation={statusMutation} - profile={profile} sortConfig={sortConfig} requestSort={requestSort} getSortIcon={getSortIcon} @@ -1499,13 +1560,6 @@ const TenantHierarchyView: React.FC<{ onSelectAll: (checked: boolean) => void; search: string; deletableTenants: TenantSummary[]; - statusMutation: UseMutationResult< - TenantSummary, - Error, - { tenantId: string; status: string }, - unknown - >; - profile: UserProfileResponse | undefined; sortConfig: SortConfig | null; requestSort: (key: TenantSortKey) => void; getSortIcon: (key: TenantSortKey) => React.ReactNode; @@ -1522,8 +1576,6 @@ const TenantHierarchyView: React.FC<{ onSelectAll, search, deletableTenants, - statusMutation, - profile, sortConfig, requestSort, getSortIcon, @@ -1535,15 +1587,28 @@ const TenantHierarchyView: React.FC<{ isLoading, }) => { const parentRef = React.useRef(null); + const isSidebarCollapsed = useOutletContext() ?? false; const isTest = (typeof process !== "undefined" && process.env.NODE_ENV === "test") || (typeof window !== "undefined" && (window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE); + const tenantTableGridTemplateColumns = React.useMemo( + () => + isSidebarCollapsed + ? "48px minmax(380px, 1fr) 310px 140px 240px 120px 120px 110px" + : "48px minmax(500px, 1fr) 240px 130px 226px 100px 100px 110px", + [isSidebarCollapsed], + ); + const tenantTableMinWidth = "100%"; const { subTree } = React.useMemo( () => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search), [scopeTenantId, tenants, search], ); + const tenantParentPathMap = React.useMemo( + () => buildTenantParentPathMap(tenants), + [tenants], + ); // Initial expanded state: everything open const [expandedIds, setExpandedIds] = React.useState>(() => { @@ -1639,6 +1704,7 @@ const TenantHierarchyView: React.FC<{ }); const virtualRows = rowVirtualizer.getVirtualItems(); + const shouldVirtualizeRows = !(isTest && flattenedRows.length < 100); React.useEffect(() => { if (isTest) return; @@ -1668,6 +1734,22 @@ const TenantHierarchyView: React.FC<{ const visibleSelectedCount = selectedIds.filter((id) => visibleSelectableIds.has(id), ).length; + const normalizedSearch = search.trim(); + const emptyMessage = React.useMemo(() => { + if (normalizedSearch) { + return t( + "msg.admin.tenants.empty_search", + "검색 조건에 맞는 테넌트가 없습니다.", + ); + } + if (scopeTenantId) { + return t( + "msg.admin.tenants.empty_scope", + "선택한 범위에 표시할 하위 테넌트가 없습니다.", + ); + } + return t("msg.admin.tenants.empty", "아직 등록된 테넌트가 없습니다."); + }, [normalizedSearch, scopeTenantId]); const renderRow = ( node: TenantViewRow, @@ -1693,8 +1775,19 @@ const TenantHierarchyView: React.FC<{ )} style={ virtualRow - ? { transform: `translateY(${virtualRow.start}px)` } - : undefined + ? { + display: "grid", + gridTemplateColumns: tenantTableGridTemplateColumns, + minWidth: tenantTableMinWidth, + position: "absolute", + transform: `translateY(${virtualRow.start}px)`, + width: "100%", + } + : { + display: "grid", + gridTemplateColumns: tenantTableGridTemplateColumns, + minWidth: tenantTableMinWidth, + } } > @@ -1742,182 +1835,249 @@ const TenantHierarchyView: React.FC<{ className="mr-2 flex-shrink-0 text-muted-foreground" /> -
- - {node.name} - - {isSeedTenant(node) && ( - +
+ - {t("ui.admin.tenants.seed_badge", "초기 설정")} - - )} + {node.name} + + {isSeedTenant(node) && ( + + {t("ui.admin.tenants.seed_badge", "초기 설정")} + + )} +
+ {(() => { + const parentPath = tenantParentPathMap.get(node.id) ?? []; + return ( +

+ {parentPath.length > 0 + ? parentPath.join(" / ") + : t("ui.admin.tenants.path.root", "최상위")} +

+ ); + })()}
- {node.id} + + {abbreviateUuid(node.id)} + + + + {(() => { + const { primary, secondary } = splitTenantTypeLabel( + getTenantTypeLabel(node.type), + ); + return ( +
+ + {primary} + + {secondary ? ( + + {secondary} + + ) : null} +
+ ); + })()} +
+ + + {node.slug} + - - {node.type} + + {node.status === "active" + ? t("ui.common.status.active", "활성") + : t("ui.common.status.inactive", "비활성")} - {node.slug} - -
- - statusMutation.mutate({ - tenantId: node.id, - status: checked ? "active" : "inactive", - }) - } - disabled={ - statusMutation.isPending || - node.id === profile?.tenantId || - isSeedTenant(node) - } - aria-label={t( - "ui.admin.tenants.toggle_status", - "{{name}} 활성 상태", - { name: node.name }, - )} - /> - - {t(`ui.common.status.${node.status}`, node.status)} + +
+ + {t("ui.admin.tenants.table.members_count", "{{count}}명", { + count: node.recursiveMemberCount, + })} + + + {t("ui.admin.tenants.table.members_recursive", "하위 포함")}
- - {node.recursiveMemberCount} - - - {node.updatedAt - ? new Date(node.updatedAt).toLocaleString("ko-KR") - : "-"} + + {node.updatedAt ? ( +
+ + {new Date(node.updatedAt).toLocaleDateString("ko-KR")} + + + {new Date(node.updatedAt).toLocaleTimeString("ko-KR")} + +
+ ) : ( + - + )}
); }; return ( -
+
- +
- - - 0 && - visibleSelectedCount === deletableTenants.length - } - onCheckedChange={(checked) => onSelectAll(!!checked)} - /> + + +
+ 0 && + visibleSelectedCount === deletableTenants.length + } + onCheckedChange={(checked) => onSelectAll(!!checked)} + /> +
requestSort("name")} > -
+
{t("ui.admin.tenants.table.name", "NAME")} {getSortIcon("name")}
requestSort("id")} > -
+
{t("ui.admin.tenants.table.id", "ID")} {getSortIcon("id")}
requestSort("type")} > -
+
{t("ui.admin.tenants.table.type", "TYPE")} {getSortIcon("type")}
requestSort("slug")} > -
+
{t("ui.admin.tenants.table.slug", "SLUG")} {getSortIcon("slug")}
requestSort("status")} > -
+
{t("ui.admin.tenants.table.status", "STATUS")} {getSortIcon("status")}
requestSort("recursiveMemberCount")} > -
+
{t("ui.admin.tenants.table.members", "MEMBERS")} {getSortIcon("recursiveMemberCount")}
requestSort("updatedAt")} > -
+
{t("ui.admin.tenants.table.updated", "UPDATED")} {getSortIcon("updatedAt")}
- - {rowVirtualizer.getTotalSize() > 0 && - virtualRows.length > 0 && - !(isTest && flattenedRows.length < 100) && ( -
- - )} - + {flattenedRows.length === 0 && !isLoading && ( - + - {t( - "msg.admin.tenants.empty", - "아직 등록된 테넌트가 없습니다.", - )} + {emptyMessage} )} - {isTest && flattenedRows.length < 100 + {!shouldVirtualizeRows ? flattenedRows.map((row, index) => renderRow(row, index)) : virtualRows.map((virtualRow) => renderRow( @@ -1927,20 +2087,14 @@ const TenantHierarchyView: React.FC<{ ), )} - {rowVirtualizer.getTotalSize() > 0 && - virtualRows.length > 0 && - !(isTest && flattenedRows.length < 100) && ( - - - )} - {isFetchingNextPage && ( - +
diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index c5dd9622..9b71b3c1 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -49,7 +49,11 @@ import { type UserCreateResponse, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; -import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles"; +import { + canManageTenantScopedUsers, + isSuperAdminRole, + normalizeAdminRole, +} from "../../lib/roles"; import { buildAuthenticatedOrgChartTenantPickerUrl, filterNonHanmacFamilyTenants, @@ -154,6 +158,7 @@ function UserCreatePage() { queryFn: fetchMe, }); const profileRole = normalizeAdminRole(profile?.role); + const canManageUsers = canManageTenantScopedUsers(profile); const { register, @@ -204,8 +209,12 @@ function UserCreatePage() { // Lock company for non-super_admin React.useEffect(() => { - if (profileRole !== "super_admin" && profile?.tenantSlug) { - setValue("tenantSlug", profile.tenantSlug); + if (profileRole !== "super_admin") { + const delegatedTenantSlug = + profile?.tenantSlug || profile?.manageableTenants?.[0]?.slug; + if (delegatedTenantSlug) { + setValue("tenantSlug", delegatedTenantSlug); + } } }, [profile, profileRole, setValue]); @@ -524,8 +533,7 @@ function UserCreatePage() { } }; - // Access Control: Only super_admin can create users - if (profile && profileRole !== "super_admin") { + if (profile && !canManageUsers) { return (
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 7b473159..fcd625b7 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -75,7 +75,10 @@ import { updateUser, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; -import { normalizeAdminRole } from "../../lib/roles"; +import { + canManageUserInTenantScope, + normalizeAdminRole, +} from "../../lib/roles"; import { generateSecurePassword } from "../../lib/utils"; import { buildAuthenticatedOrgChartTenantPickerUrl, @@ -472,6 +475,7 @@ function UserDetailPage() { const profileRole = normalizeAdminRole(profile?.role); const isAdmin = profileRole === "super_admin"; const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id); + const canManageCurrentUser = canManageUserInTenantScope({ profile, user }); const watchedStatus = watch("status"); const [newSubEmail, setNewSubEmail] = React.useState(""); @@ -999,8 +1003,7 @@ function UserDetailPage() { ); } - // Access Control: Only super_admin or self can view details - if (!isAdmin && !isSelf) { + if (!isAdmin && !isSelf && !canManageCurrentUser) { return (
diff --git a/adminfront/src/features/users/UserListPage.render.test.tsx b/adminfront/src/features/users/UserListPage.render.test.tsx index d5b5408d..4975d072 100644 --- a/adminfront/src/features/users/UserListPage.render.test.tsx +++ b/adminfront/src/features/users/UserListPage.render.test.tsx @@ -127,7 +127,7 @@ describe("UserListPage search rendering", () => { renderUserListPage(); await screen.findByText("User 0"); - const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색..."); + const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색"); const renderCountBeforeTyping = selectRenderCounter.count; fireEvent.change(searchInput, { target: { value: "u" } }); @@ -179,7 +179,7 @@ describe("UserListPage search rendering", () => { renderUserListPage(); await screen.findByText("User 0"); - const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색..."); + const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색"); const startedAt = performance.now(); fireEvent.change(searchInput, { target: { value: "user 19" } }); @@ -189,4 +189,19 @@ describe("UserListPage search rendering", () => { expect(screen.queryByText("User 0")).not.toBeInTheDocument(); expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs); }); + + it("keeps rendered form fields identifiable for browser autofill diagnostics", async () => { + const { container } = renderUserListPage(); + + await screen.findByText("User 0"); + const anonymousFields = Array.from( + container.querySelectorAll("input, select, textarea"), + ).filter( + (field) => + !field.getAttribute("id")?.trim() && + !field.getAttribute("name")?.trim(), + ); + + expect(anonymousFields).toHaveLength(0); + }); }); diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 13fd7975..63d22d17 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -204,12 +204,14 @@ const UserListSearchControls = React.memo(function UserListSearchControls({ -
+
{user.name} diff --git a/adminfront/src/lib/apiClient.ts b/adminfront/src/lib/apiClient.ts index 7232f454..b61cb14c 100644 --- a/adminfront/src/lib/apiClient.ts +++ b/adminfront/src/lib/apiClient.ts @@ -1,7 +1,7 @@ import axios from "axios"; import { shouldStartLoginRedirect } from "../../../common/core/auth"; import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session"; -import { userManager } from "./auth"; +import { clearAdminAuthSession, userManager } from "./auth"; let isRedirectingToLogin = false; @@ -50,12 +50,7 @@ apiClient.interceptors.response.use( "[apiClient] 401 Unauthorized detected. Clearing session state.", ); - // 로컬 스토리지의 세션 키 제거 - window.localStorage.removeItem("admin_session"); - - // oidc-client의 유저 상태도 제거하여 isAuthenticated를 false로 만듭니다. - // 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다. - await userManager.removeUser(); + await clearAdminAuthSession(); if ( shouldStartLoginRedirect({ diff --git a/adminfront/src/lib/auth.ts b/adminfront/src/lib/auth.ts index 58224b6b..24eb1a7d 100644 --- a/adminfront/src/lib/auth.ts +++ b/adminfront/src/lib/auth.ts @@ -21,3 +21,31 @@ export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({ export const userManager = new UserManager( buildCommonUserManagerSettings(oidcConfig), ); + +export function clearStoredAdminAuthSession( + storage: Storage = window.localStorage, +) { + const keysToRemove: string[] = []; + + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if ( + key && + (key === "admin_session" || + key.startsWith("oidc.user:") || + key.startsWith("oidc.state") || + key.startsWith("oidc.signin")) + ) { + keysToRemove.push(key); + } + } + + for (const key of keysToRemove) { + storage.removeItem(key); + } +} + +export async function clearAdminAuthSession() { + clearStoredAdminAuthSession(); + await userManager.removeUser(); +} diff --git a/adminfront/src/lib/roles.test.ts b/adminfront/src/lib/roles.test.ts index 7e549553..4d13c86e 100644 --- a/adminfront/src/lib/roles.test.ts +++ b/adminfront/src/lib/roles.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + canManageTenantScopedUsers, + canManageUserInTenantScope, isSuperAdminRole, normalizeAdminRole, ROLE_SUPER_ADMIN, @@ -32,4 +34,43 @@ describe("admin role helpers", () => { expect(isSuperAdminRole("admin")).toBe(false); expect(isSuperAdminRole(undefined)).toBe(false); }); + + it("allows delegated tenant admins with manageable tenants to manage scoped users", () => { + const profile = { + id: "admin-user", + role: "user", + manageableTenants: [{ id: "tenant-1", slug: "tenant-a" }], + }; + + expect(canManageTenantScopedUsers(profile)).toBe(true); + expect( + canManageUserInTenantScope({ + profile, + user: { id: "user-1", tenantSlug: "tenant-a" }, + }), + ).toBe(true); + expect( + canManageUserInTenantScope({ + profile, + user: { id: "user-2", tenantSlug: "tenant-b" }, + }), + ).toBe(false); + }); + + it("does not treat ordinary tenant membership as delegated user management", () => { + const profile = { + id: "member-user", + role: "user", + tenantSlug: "tenant-a", + manageableTenants: [], + }; + + expect(canManageTenantScopedUsers(profile)).toBe(false); + expect( + canManageUserInTenantScope({ + profile, + user: { id: "user-1", tenantSlug: "tenant-a" }, + }), + ).toBe(false); + }); }); diff --git a/adminfront/src/lib/roles.ts b/adminfront/src/lib/roles.ts index 20dd93c6..6c89f8bc 100644 --- a/adminfront/src/lib/roles.ts +++ b/adminfront/src/lib/roles.ts @@ -3,6 +3,21 @@ export const ROLE_USER = "user"; export type AdminRole = typeof ROLE_SUPER_ADMIN | typeof ROLE_USER; +export type TenantAccessSubject = { + id?: string | null; + role?: string | null; + tenantId?: string | null; + tenantSlug?: string | null; + tenant?: { + id?: string | null; + slug?: string | null; + } | null; + manageableTenants?: Array<{ + id?: string | null; + slug?: string | null; + }> | null; +}; + export function normalizeAdminRole(role?: string | null): AdminRole { const normalized = role?.trim().toLowerCase() ?? ""; @@ -30,3 +45,60 @@ export function normalizeAdminRole(role?: string | null): AdminRole { export function isSuperAdminRole(role?: string | null) { return normalizeAdminRole(role) === ROLE_SUPER_ADMIN; } + +function normalizeTenantAccessKey(value?: string | null) { + const normalized = value?.trim().toLowerCase(); + return normalized ? normalized : null; +} + +export function getManageableTenantAccessKeys( + profile?: TenantAccessSubject | null, +) { + const keys = new Set(); + for (const tenant of profile?.manageableTenants ?? []) { + const id = normalizeTenantAccessKey(tenant.id); + const slug = normalizeTenantAccessKey(tenant.slug); + if (id) keys.add(id); + if (slug) keys.add(slug); + } + return keys; +} + +export function canManageTenantScopedUsers( + profile?: TenantAccessSubject | null, +) { + return ( + isSuperAdminRole(profile?.role) || + getManageableTenantAccessKeys(profile).size > 0 + ); +} + +export function canManageUserInTenantScope({ + profile, + user, +}: { + profile?: TenantAccessSubject | null; + user?: TenantAccessSubject | null; +}) { + if (isSuperAdminRole(profile?.role)) { + return true; + } + + if (profile?.id && user?.id && profile.id === user.id) { + return true; + } + + const manageableKeys = getManageableTenantAccessKeys(profile); + if (manageableKeys.size === 0) { + return false; + } + + const userTenantKeys = [ + normalizeTenantAccessKey(user?.tenantId), + normalizeTenantAccessKey(user?.tenantSlug), + normalizeTenantAccessKey(user?.tenant?.id), + normalizeTenantAccessKey(user?.tenant?.slug), + ]; + + return userTenantKeys.some((key) => key !== null && manageableKeys.has(key)); +} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index ffb10bb1..c4915449 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -1071,6 +1071,7 @@ user = "General User (Tenant Member)" [ui.admin.tenants] add = "Add Tenant" csv_template = "Template" +data_mgmt = "Data Management" delete_selected = "Delete Selected" export_with_ids = "Include UUIDs" export_without_ids = "Export without UUIDs" @@ -1267,10 +1268,21 @@ name = "NAME" slug = "SLUG" status = "STATUS" +[ui.admin.tenants.view] +list = "List" +table = "Table" +tree = "Tree" + +[ui.admin.tenants.scope] +active = "{{name}} descendants" +pick = "Select parent scope" + [ui.admin.tenants.table] actions = "ACTIONS" id = "ID" +members_count = "{{count}} members" members = "Members" +members_recursive = "Includes descendants" name = "NAME" slug = "SLUG" status = "STATUS" @@ -1389,7 +1401,7 @@ change_status = "Change {{name}} status" empty = "No users found." fetch_error = "Failed to fetch user list." search_label = "Search Users" -search_placeholder = "Search by name or email..." +search_placeholder = "Search by name or email" subtitle = "View and manage system users." toggle_status = "{{name}} active status" title = "User Management" @@ -1424,7 +1436,7 @@ remove_success = "Successfully excluded from organization." [ui.admin.tenants.list] search_label = "Search Tenants" -search_placeholder = "Search by name or slug..." +search_placeholder = "Search by name, slug, or ID" title = "Tenant List" [ui.admin.users.list.breadcrumb] @@ -1442,12 +1454,18 @@ count = "Registered users" title = "User Registry" [ui.admin.users.list.table] -actions = "ACTIONS" -created = "CREATED" -name_email = "NAME / EMAIL" -role = "ROLE" -status = "STATUS" -tenant_dept = "TENANT / DEPT" +actions = "Actions" +created = "Created" +email = "Email" +id = "ID" +name = "Name" +phone = "Phone" +role = "Role" +status = "Status" +tenant_dept = "Tenant / Dept" + +[ui.admin.users] +data_mgmt = "Data Management" [ui.admin.users.table] email = "Email" @@ -1531,6 +1549,10 @@ unknown_name = "Unknown User" logout = "Logout" profile = "My Profile" +[ui.shell.sidebar] +collapse = "Collapse sidebar" +expand = "Expand sidebar" + [ui.shell.role] rp_admin = "Service Administrator (RP Admin)" super_admin = "System Administrator (Super Admin)" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 03f6502b..1e788dfb 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -1074,6 +1074,7 @@ user = "일반 사용자 (Tenant Member)" [ui.admin.tenants] add = "테넌트 추가" csv_template = "템플릿" +data_mgmt = "데이터 관리" delete_selected = "선택 삭제" export_with_ids = "UUID 포함" export_without_ids = "UUID 제외 내보내기" @@ -1270,15 +1271,26 @@ name = "NAME" slug = "SLUG" status = "STATUS" +[ui.admin.tenants.view] +list = "평면 목록" +table = "평면" +tree = "트리" + +[ui.admin.tenants.scope] +active = "{{name}} 하위" +pick = "상위 범위 선택" + [ui.admin.tenants.table] actions = "ACTIONS" id = "ID" +members_count = "{{count}}명" members = "멤버수" -name = "NAME" -slug = "SLUG" -status = "STATUS" +members_recursive = "하위 포함" +name = "이름" +slug = "슬러그" +status = "상태" type = "유형" -updated = "UPDATED" +updated = "수정일" [ui.admin.users] csv_template = "템플릿 다운로드" @@ -1392,7 +1404,7 @@ change_status = "{{name}} 상태 변경" empty = "검색 결과가 없습니다." fetch_error = "사용자 목록 조회에 실패했습니다." search_label = "사용자 검색" -search_placeholder = "이름 또는 이메일 검색..." +search_placeholder = "이름 또는 이메일 검색" subtitle = "시스템 사용자를 조회하고 관리합니다." toggle_status = "{{name}} 활성 상태" title = "사용자 관리" @@ -1427,7 +1439,7 @@ remove_success = "조직에서 제외되었습니다." [ui.admin.tenants.list] search_label = "테넌트 검색" -search_placeholder = "테넌트 이름 또는 슬러그 검색..." +search_placeholder = "이름 또는 슬러그, ID 검색" title = "테넌트 목록" [ui.admin.users.list.breadcrumb] @@ -1445,12 +1457,18 @@ count = "총 {{count}}명의 사용자가 등록되어 있습니다." title = "사용자 레지스트리" [ui.admin.users.list.table] -actions = "ACTIONS" -created = "CREATED" -name_email = "NAME / EMAIL" -role = "ROLE" -status = "STATUS" -tenant_dept = "TENANT / DEPT" +actions = "액션" +created = "등록일" +email = "이메일" +id = "ID" +name = "이름" +phone = "전화번호" +role = "역할" +status = "상태" +tenant_dept = "테넌트 / 부서" + +[ui.admin.users] +data_mgmt = "데이터 관리" [ui.admin.users.table] email = "이메일" @@ -1534,6 +1552,10 @@ unknown_name = "Unknown User" logout = "Logout" profile = "내 정보" +[ui.shell.sidebar] +collapse = "사이드바 접기" +expand = "사이드바 펼치기" + [ui.shell.role] rp_admin = "서비스 관리자 (RP Admin)" super_admin = "시스템 관리자 (Super Admin)" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 83582b7d..4808dab7 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -1291,6 +1291,8 @@ slug = "" status = "" [ui.admin.tenants.table] +members_count = "" +members_recursive = "" actions = "" id = "" members = "" @@ -1426,11 +1428,17 @@ title = "" [ui.admin.users.list.table] actions = "" created = "" -name_email = "" +email = "" +id = "" +name = "" +phone = "" role = "" status = "" tenant_dept = "" +[ui.admin.users] +data_mgmt = "" + [ui.admin.users.table] email = "" name = "" @@ -1513,6 +1521,10 @@ unknown_name = "" logout = "" profile = "" +[ui.shell.sidebar] +collapse = "" +expand = "" + [ui.shell.role] rp_admin = "" super_admin = "" diff --git a/adminfront/tests/auth.spec.ts b/adminfront/tests/auth.spec.ts index 632ef2f0..30222f8e 100644 --- a/adminfront/tests/auth.spec.ts +++ b/adminfront/tests/auth.spec.ts @@ -126,7 +126,7 @@ test.describe("Authentication", () => { await page.goto("/"); await expect(page.getByRole("link", { name: "조직도" })).toHaveAttribute( "href", - "http://localhost:5175/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue", + /\/login\?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue$/, ); }); diff --git a/adminfront/tests/security_roles.spec.ts b/adminfront/tests/security_roles.spec.ts index 207b5dae..fd42a5fe 100644 --- a/adminfront/tests/security_roles.spec.ts +++ b/adminfront/tests/security_roles.spec.ts @@ -5,7 +5,11 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자 page.on("console", (msg) => console.log(`[PAGE] ${msg.text()}`)); }); - const setupAuth = async (page, role: string) => { + const setupAuth = async ( + page, + role: string, + profileOverrides: Record = {}, + ) => { // 1. Inject initial state and mock tokens await page.addInitScript( ({ role }) => { @@ -76,6 +80,7 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자 email: "test@example.com", role: role, manageableTenants: [], + ...profileOverrides, }, headers: { "Access-Control-Allow-Origin": "*" }, }); @@ -95,6 +100,28 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자 }, headers: { "Access-Control-Allow-Origin": "*" }, }); + } else if (url.match(/\/admin\/users\/u1$/)) { + await route.fulfill({ + json: { + id: "u1", + name: "사용자 1", + email: "u1@example.com", + role: "user", + status: "active", + tenantId: "t1", + tenantSlug: "t1", + tenant: { + id: "t1", + name: "테넌트 1", + slug: "t1", + status: "active", + type: "COMPANY", + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); } else if (url.includes("/rp-history")) { await route.fulfill({ json: [], @@ -218,4 +245,52 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자 ).toBeVisible(); }); }); + + test.describe("테넌트 관리자 권한", () => { + test.beforeEach(async ({ page }) => { + await setupAuth(page, "tenant_admin", { + tenantId: "t1", + tenantSlug: "t1", + manageableTenants: [ + { + id: "t1", + name: "테넌트 1", + slug: "t1", + status: "active", + type: "COMPANY", + }, + ], + }); + await page.goto("/"); + await expect(page.locator("aside")).toBeVisible({ timeout: 10000 }); + }); + + test("사용자 관리 목록에 접근 가능해야 함", async ({ page }) => { + await page.goto("/users"); + + await expect( + page.getByTestId("page-title").filter({ hasText: /사용자 관리/i }), + ).toBeVisible(); + await expect(page.getByText("사용자 1")).toBeVisible(); + }); + + test("사용자 생성 화면에 접근 가능해야 함", async ({ page }) => { + await page.goto("/users/new"); + + await expect( + page.getByRole("heading", { name: "사용자 추가" }), + ).toBeVisible(); + }); + + test("관리 대상 테넌트 사용자 상세에 접근 가능해야 함", async ({ + page, + }) => { + await page.goto("/users/u1"); + + await expect(page.getByText("사용자 1")).toBeVisible(); + await expect( + page.getByText(/이 작업을 수행할 권한이 없습니다/i), + ).not.toBeVisible(); + }); + }); }); diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 71bbe54f..054398e8 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -107,9 +107,11 @@ test.describe("Tenants Management", () => { await expect(page.locator("table")).toContainText("Tenant A", { timeout: 10000, }); - await expect(page.locator("table")).toContainText(internalTenantId); + await expect( + page.getByTestId(`tenant-internal-id-${internalTenantId}`), + ).toHaveText("c5839444-2de0-4a37-99b0-..."); await expect(page.locator("table")).toContainText("COMPANY"); - await expect(page.locator("table")).not.toContainText("일반 기업"); + await expect(page.locator("table")).toContainText("일반 기업"); const headerWhiteSpace = await page .locator("table thead th") @@ -188,16 +190,14 @@ test.describe("Tenants Management", () => { await page.goto("/tenants"); await page - .getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) + .getByPlaceholder(/이름 또는 슬러그, ID 검색|search/i) .fill("team-1"); await expect(page.locator("table")).toContainText("Platform"); + await page.getByPlaceholder(/이름 또는 슬러그, ID 검색|search/i).fill(""); await page - .getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) - .fill(""); - await page - .locator("tbody tr") - .filter({ hasText: "Planning" }) + .getByTestId("tenant-internal-id-dept-1") + .locator("xpath=ancestor::tr") .getByRole("checkbox") .click(); @@ -291,8 +291,8 @@ test.describe("Tenants Management", () => { await page.getByPlaceholder(/UUID|슬러그|slug/i).fill(""); await page.keyboard.press("Enter"); await page - .locator("tbody tr") - .filter({ hasText: "Acme" }) + .getByTestId("tenant-internal-id-company-1") + .locator("xpath=ancestor::tr") .getByRole("checkbox") .click(); @@ -363,7 +363,7 @@ test.describe("Tenants Management", () => { await page.goto("/tenants"); await expect( - page.getByText("총 501개의 테넌트가 등록되어 있습니다."), + page.getByText("총 500개의 테넌트가 등록되어 있습니다."), ).toBeVisible(); await expect(page.getByRole("button", { name: "더 불러오기" })).toHaveCount( 0, diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index b54ab46b..6e86b030 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -602,11 +602,11 @@ test.describe("User Management", () => { await expect(page.getByText("Load User 0")).toBeVisible(); const initialMs = performance.now() - initialStartedAt; - const searchInput = page.getByPlaceholder("이름 또는 이메일 검색..."); + const searchInput = page.getByPlaceholder("이름 또는 이메일 검색"); await searchInput.fill("Load User 19999"); const searchMs = await page.evaluate(async () => { const input = Array.from(document.querySelectorAll("input")).find( - (candidate) => candidate.placeholder === "이름 또는 이메일 검색...", + (candidate) => candidate.placeholder === "이름 또는 이메일 검색", ); if (!input) { diff --git a/common/core/components/audit/AuditLogTable.tsx b/common/core/components/audit/AuditLogTable.tsx index 30d94c0c..42c4371e 100644 --- a/common/core/components/audit/AuditLogTable.tsx +++ b/common/core/components/audit/AuditLogTable.tsx @@ -1,7 +1,9 @@ import { ChevronDown, ChevronUp, Copy } from "lucide-react"; import * as React from "react"; -import { getCommonBadgeClasses } from "../../../ui/badge"; -import type { CommonBadgeVariant } from "../../../ui/badge"; +import { + getCommonBadgeClasses, + type CommonBadgeVariant, +} from "../../../ui/badge"; import { getCommonButtonClasses } from "../../../ui/button"; import { commonStickyTableHeaderClass, diff --git a/common/shell/AppSidebar.tsx b/common/shell/AppSidebar.tsx index d49f56cc..30ef40f5 100644 --- a/common/shell/AppSidebar.tsx +++ b/common/shell/AppSidebar.tsx @@ -1,3 +1,4 @@ +import { Menu, SquareMenu } from "lucide-react"; import type { ComponentType, ReactNode } from "react"; import { shellLayoutClasses } from "./layout"; @@ -14,9 +15,13 @@ export type ShellSidebarNavItem = { type ShellSidebarProps = { brandLabel: string; brandTitle: string; - brandIcon: ReactNode; + brandIcon?: ReactNode; navContent: ReactNode; footerContent: ReactNode; + collapsed?: boolean; + onToggleCollapsed?: () => void; + collapseLabel?: string; + expandLabel?: string; }; export function AppSidebar({ @@ -25,14 +30,57 @@ export function AppSidebar({ brandIcon, navContent, footerContent, + collapsed = false, + onToggleCollapsed, + collapseLabel = "Collapse sidebar", + expandLabel = "Expand sidebar", }: ShellSidebarProps) { return ( -
-
-