forked from baron/baron-sso
Compare commits
56 Commits
feature/rb
...
feature/df
| Author | SHA1 | Date | |
|---|---|---|---|
| 52046e4a66 | |||
| e9af231fb0 | |||
| 85c2eb1690 | |||
| 4c9d219fd4 | |||
| 2234986abd | |||
| b919f600e1 | |||
| 437a3ad98d | |||
| 3ed9e912e6 | |||
| 0f11173739 | |||
| 41e755b1c7 | |||
| 894feb20f1 | |||
| c880b3c333 | |||
| 28478309fa | |||
| cad1162597 | |||
| 1341f07ef9 | |||
| 107406d113 | |||
| 67af52d8e2 | |||
| 48048a24fe | |||
| 4eb4c5af34 | |||
| f61c56cfde | |||
| 2671ebda27 | |||
| 2405961375 | |||
| ae97950108 | |||
| f726463a6c | |||
| badcabb644 | |||
| aa2848c3b6 | |||
| 9be833d2e0 | |||
| 4e81e214a3 | |||
| 561659f333 | |||
| 0b48fe22c7 | |||
| b8c1b116b1 | |||
| 57c05c9241 | |||
| 9478944197 | |||
| c9cf7d6c67 | |||
| 06d2b71e25 | |||
| 9803108de2 | |||
| fe176c6912 | |||
| 01cd7a0ad3 | |||
| 87a45f0e76 | |||
| 5670288616 | |||
| 3ab9d28c9d | |||
| 2dedeb66b6 | |||
| 1f47abb860 | |||
| a6f9d89477 | |||
| 729a9890a6 | |||
| b4883bc9eb | |||
| d54d258117 | |||
| f3e9ca52be | |||
| 1596342d03 | |||
| f6c7cb3b22 | |||
| 47d2f15283 | |||
| 29038254dd | |||
| 4bae1dd00d | |||
| ded9dfc56b | |||
| 3f4138e3a0 | |||
| ba3e9103f2 |
32
.env.sample
32
.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 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
|
||||
@@ -152,3 +180,7 @@ DEVFRONT_URL=http://localhost:5174
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
||||
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback
|
||||
VITE_ORGCHART_URL=
|
||||
|
||||
# promtail에서 로그를 전송받을 Loki 서버 엔드포인트 URL
|
||||
LOKI_URL=http://loki:3100/loki/api/v1/push
|
||||
|
||||
|
||||
@@ -18,6 +18,30 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y jq curl
|
||||
|
||||
- name: Validate RC build configuration
|
||||
env:
|
||||
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
|
||||
|
||||
required_action_env="
|
||||
HARBOR_ENDPOINT HARBOR_HOSTNAME HARBOR_ROBOT_ACCOUNT HARBOR_ROBOT_KEY
|
||||
ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY
|
||||
"
|
||||
for key in ${required_action_env}; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
echo "::error::Missing required RC build value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -93,6 +117,11 @@ jobs:
|
||||
file: ./adminfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.rc_calculator.outputs.new_rc_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
|
||||
|
||||
@@ -103,6 +132,10 @@ jobs:
|
||||
file: ./devfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
build-args: |
|
||||
VITE_DEVFRONT_PUBLIC_URL=${{ vars.DEVFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=devfront
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
@@ -113,14 +146,19 @@ jobs:
|
||||
file: ./orgfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_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: Build and push userfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./userfront
|
||||
context: .
|
||||
file: ./userfront/Dockerfile
|
||||
target: production
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
provenance: false
|
||||
|
||||
@@ -695,6 +695,7 @@ jobs:
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd userfront
|
||||
rm -rf build/web
|
||||
flutter build web --wasm --release 2>&1 | tee ../reports/userfront-e2e-build.log
|
||||
build_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
|
||||
@@ -42,19 +42,13 @@ jobs:
|
||||
sudo apt-get update -y && sudo apt-get install -y skopeo
|
||||
fi
|
||||
|
||||
# Re-tag backend image
|
||||
echo "Re-tagging backend image..."
|
||||
skopeo copy --preserve-digests \
|
||||
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
|
||||
--src-tls-verify=false --dest-tls-verify=false \
|
||||
"docker://${HARBOR_HOSTNAME}/baron_sso/backend:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/backend:${RE_TAG}"
|
||||
|
||||
# Re-tag userfront image
|
||||
echo "Re-tagging userfront image..."
|
||||
skopeo copy --preserve-digests \
|
||||
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
|
||||
--src-tls-verify=false --dest-tls-verify=false \
|
||||
"docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${RE_TAG}"
|
||||
for image in backend userfront adminfront devfront orgfront; do
|
||||
echo "Re-tagging ${image} image..."
|
||||
skopeo copy --preserve-digests \
|
||||
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
|
||||
--src-tls-verify=false --dest-tls-verify=false \
|
||||
"docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${RE_TAG}"
|
||||
done
|
||||
|
||||
echo "final_image_tag=${RE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -68,6 +62,9 @@ jobs:
|
||||
IMAGE_TAG: ${{ steps.retag.outputs.final_image_tag }}
|
||||
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
|
||||
DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
|
||||
PROD_HOST: ${{ vars.PROD_HOST }}
|
||||
PROD_USER: ${{ vars.PROD_USER }}
|
||||
@@ -101,8 +98,12 @@ jobs:
|
||||
"CLICKHOUSE_PORT_NATIVE=${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }}" \
|
||||
"CLICKHOUSE_USER=${{ vars.PROD_CLICKHOUSE_USER }}" \
|
||||
"CLICKHOUSE_PASSWORD=${{ secrets.PROD_CLICKHOUSE_PASSWORD }}" \
|
||||
"BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
|
||||
"USERFRONT_PORT=${{ vars.PROD_USERFRONT_PORT }}" \
|
||||
"PROD_BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
|
||||
"BACKEND_PORT=3000" \
|
||||
"USERFRONT_PORT=${{ vars.PROD_FRONTEND_PORT }}" \
|
||||
"ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}" \
|
||||
"DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}" \
|
||||
"ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}" \
|
||||
"DB_USER=${{ vars.PROD_DB_USER }}" \
|
||||
"DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}" \
|
||||
"DB_NAME=${{ vars.PROD_DB_NAME }}" \
|
||||
@@ -117,10 +118,33 @@ jobs:
|
||||
"AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}" \
|
||||
"AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}" \
|
||||
"AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}" \
|
||||
"USERFRONT_URL=${{ vars.PROD_USERFRONT_URL }}" \
|
||||
"USERFRONT_URL=${{ vars.PROD_FRONTEND_URL }}" \
|
||||
"ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}" \
|
||||
"DEVFRONT_URL=${{ vars.DEVFRONT_URL }}" \
|
||||
"ORGFRONT_URL=${{ vars.ORGFRONT_URL }}" \
|
||||
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
|
||||
"VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}" \
|
||||
"ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}" \
|
||||
"DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}" \
|
||||
"ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}" \
|
||||
> .env
|
||||
|
||||
required_dotenv_keys="
|
||||
APP_ENV TZ DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_USER CLICKHOUSE_PASSWORD
|
||||
PROD_BACKEND_PORT BACKEND_PORT USERFRONT_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT
|
||||
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
|
||||
ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
|
||||
"
|
||||
for key in ${required_dotenv_keys}; do
|
||||
if ! grep -Eq "^${key}=.+" .env; then
|
||||
echo "::error::Missing required production .env value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy compose template and .env file to the remote server
|
||||
scp adminfront/seed-tenant.csv "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/adminfront/"
|
||||
scp docker/docker-compose.template.yaml .env "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/"
|
||||
@@ -131,6 +155,9 @@ jobs:
|
||||
"export DEPLOY_PATH='${DEPLOY_PATH}'; \
|
||||
export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \
|
||||
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
|
||||
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
|
||||
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
|
||||
export ORGFRONT_IMAGE_NAME='${ORGFRONT_IMAGE_NAME}'; \
|
||||
export IMAGE_TAG='${IMAGE_TAG}'; \
|
||||
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
|
||||
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
|
||||
|
||||
83
.gitea/workflows/staging_build_check.yml
Normal file
83
.gitea/workflows/staging_build_check.yml
Normal file
@@ -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 }}"
|
||||
@@ -80,6 +80,7 @@ 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 }}
|
||||
@@ -135,8 +136,17 @@ jobs:
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.KRATOS_ALLOWED_RETURN_URLS_EXTRA }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
|
||||
# Monitoring & Alerts
|
||||
SMS_WEBHOOK_PORT=${{ vars.SMS_WEBHOOK_PORT || '8080' }}
|
||||
MONITOR_RECIPIENT_PHONES=${{ vars.MONITOR_RECIPIENT_PHONES || '01012345678,01098765432' }}
|
||||
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
|
||||
@@ -190,7 +200,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
|
||||
@@ -203,6 +213,28 @@ 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_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
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
|
||||
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
|
||||
CLICKHOUSE_PASSWORD=${{ vars.CLICKHOUSE_PASSWORD }}
|
||||
CLICKHOUSE_PASSWORD=${{ secrets.CLICKHOUSE_PASSWORD }}
|
||||
|
||||
|
||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||
@@ -90,6 +90,7 @@ 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 }}
|
||||
@@ -142,9 +143,37 @@ 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
|
||||
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
|
||||
"
|
||||
for key in ${required_dotenv_keys}; do
|
||||
if ! grep -Eq "^${key}=.+" .env; then
|
||||
echo "::error::Missing required staging .env value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 파일 복사
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker"
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/adminfront"
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/scripts"
|
||||
|
||||
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
|
||||
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
|
||||
@@ -158,9 +187,10 @@ jobs:
|
||||
fi
|
||||
|
||||
scp adminfront/seed-tenant.csv "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/adminfront/"
|
||||
scp scripts/render_ory_config.sh "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/scripts/"
|
||||
scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
|
||||
scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml"
|
||||
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
|
||||
scp compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
|
||||
|
||||
# 배포 실행
|
||||
echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \
|
||||
@@ -181,6 +211,9 @@ jobs:
|
||||
for net in baron_net public_net ory-net hydranet kratosnet; do
|
||||
docker network inspect \"\$net\" >/dev/null 2>&1 || docker network create \"\$net\"
|
||||
done
|
||||
|
||||
bash scripts/render_ory_config.sh; \
|
||||
chmod -R 777 config/.generated/ory || true; \
|
||||
|
||||
envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ jobs:
|
||||
- name: Build userfront WASM
|
||||
run: |
|
||||
cd userfront
|
||||
rm -rf build/web
|
||||
flutter build web --wasm --release
|
||||
cd ..
|
||||
node userfront/scripts/optimize-web-build.mjs userfront/build/web
|
||||
|
||||
67
Makefile
67
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
|
||||
|
||||
202
README.md
202
README.md
@@ -378,6 +378,59 @@ flowchart TD
|
||||
|
||||
Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비지니스로직은 Backend를 통해서, 기본 인증 로직은 Ory Stack을 통해 진행됩니다.
|
||||
|
||||
### SSOT 및 Redis Cache 전략
|
||||
|
||||
Baron SSO는 “하나의 DB가 모든 데이터의 원본”인 구조가 아닙니다. 데이터 성격별로 원장이 다르며, Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane입니다. Redis와 PostgreSQL projection은 성능과 운영 편의를 위한 read model/cache로만 사용하고, 원장과 불일치할 수 있다는 전제를 명시합니다.
|
||||
|
||||
#### 데이터별 원본 위치
|
||||
|
||||
| 데이터 | 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가 원장입니다. |
|
||||
| 권한/관계 | 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 사용자 상세에서만 관리합니다. |
|
||||
| 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`가 원본입니다. |
|
||||
| 로그인 코드, pending login, verification token | Redis short-lived key | 없음 | 만료 가능한 휘발성 상태입니다. 백업/복구 대상이 아닙니다. |
|
||||
|
||||
#### SSOT 보장 원칙
|
||||
|
||||
1. Kratos/Hydra/Keto/WORKS로 향하는 쓰기 command는 Backend를 통과합니다.
|
||||
2. Backend는 원장 write 성공 후 원장 ID를 기준으로 재조회하고, PostgreSQL read model 또는 Redis mirror를 write-through 갱신합니다.
|
||||
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를 전체처럼 보이게 만들 수 있으므로 신규 구현에서 금지합니다.
|
||||
7. Redis cache miss가 발생한 단건 조회는 가능한 경우 SSOT로 fallback하고, fallback 성공 시 Redis를 갱신합니다. 목록 조회는 mirror 상태가 `ready`가 아니면 화면/API에 경고 상태를 함께 전달해야 합니다.
|
||||
|
||||
#### Redis 사용 원칙
|
||||
|
||||
Redis는 원장이 아니라 cache/mirror 계층입니다. Redis 데이터 유실은 장애지만 데이터 유실 사고로 보지 않고, 원장 재조회와 refresh로 재수렴해야 합니다.
|
||||
|
||||
| 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: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 필수 | 만료 또는 유실 시 사용자가 흐름 재시작 |
|
||||
| 일반 API response cache | 선택적 성능 cache | 짧은 TTL, invalidation 우선 | miss 시 Backend DB 또는 Ory 원장 조회 |
|
||||
|
||||
운영 Redis 설정은 `maxmemory`와 `maxmemory_policy`가 명시되어야 합니다. identity mirror처럼 재수렴 가능한 데이터와 pending login처럼 사용자 흐름에 영향을 주는 단기 key가 같은 Redis를 공유하므로, eviction 발생 여부와 TTL 없는 key 증가를 운영 화면에서 볼 수 있어야 합니다.
|
||||
|
||||
#### Redis 모니터링 계획
|
||||
|
||||
Redis 적정 설정 판단에 필요한 운영 지표를 adminfront에 노출하는 후속 작업은 이슈 [#1046](https://gitea.hmac.kr/baron/baron-sso/issues/1046)으로 분리했습니다.
|
||||
|
||||
표시 대상은 Redis 연결/버전/uptime, `used_memory`, `maxmemory`, `maxmemory_policy`, keyspace hit/miss, expired/evicted keys, prefix별 key count, TTL 분포, `identity:mirror:state`, headless JWKS cache failure 요약입니다. 이 화면은 `super_admin` 전용으로 두고, Redis key value 자체는 노출하지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 시작하기 (Getting Started)
|
||||
@@ -527,6 +580,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는 `<BACKUP>/reports/restore-report.json`이고, `DUMP_FILE` archive 입력의 기본 JSON report는 `reports/restore/<archive-name>-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` 프로파일을 로컬에서만 켜세요.
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
FROM node:lts
|
||||
FROM node:lts AS build
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Set CI environment variable to true to avoid TTY issues with pnpm
|
||||
ENV CI=true
|
||||
ENV ADMINFRONT_BUILD_OUT_DIR=/workspace/adminfront/dist
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.5.2 --activate
|
||||
|
||||
# Copy workspace configs and common package
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY common ./common
|
||||
COPY adminfront ./adminfront
|
||||
|
||||
# Install dependencies for the workspace
|
||||
RUN pnpm install --filter adminfront... --filter baron-sso... --no-frozen-lockfile --ignore-scripts
|
||||
ARG VITE_ADMIN_PUBLIC_URL
|
||||
ARG VITE_OIDC_AUTHORITY
|
||||
ARG VITE_OIDC_CLIENT_ID
|
||||
ARG ORGFRONT_URL
|
||||
ENV VITE_ADMIN_PUBLIC_URL=$VITE_ADMIN_PUBLIC_URL
|
||||
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
|
||||
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
|
||||
ENV ORGFRONT_URL=$ORGFRONT_URL
|
||||
|
||||
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||
RUN npm install -g serve
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /workspace/adminfront
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV FRONTEND_DIST_DIR=/app/dist
|
||||
ENV PORT=5173
|
||||
|
||||
COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs
|
||||
COPY --from=build /workspace/adminfront/dist ./dist
|
||||
|
||||
# Vite 기본 포트
|
||||
EXPOSE 5173
|
||||
|
||||
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||
RUN chmod +x ./scripts/runtime-mode.sh
|
||||
CMD ["sh", "./scripts/runtime-mode.sh"]
|
||||
CMD ["node", "./serve_frontend_prod.mjs"]
|
||||
|
||||
@@ -51,14 +51,17 @@ ensure_frontend_dependencies() {
|
||||
if [ -n "$WORKSPACE_ROOT" ]; then
|
||||
WORKSPACE_DIR="$WORKSPACE_ROOT"
|
||||
LOCK_FILE="$WORKSPACE_ROOT/pnpm-lock.yaml"
|
||||
COMMON_PACKAGE_FILE="$WORKSPACE_ROOT/common/package.json"
|
||||
INSTALL_CMD="cd $WORKSPACE_ROOT && CI=true pnpm install --filter ${APP_PACKAGE_NAME}... --frozen-lockfile --ignore-scripts"
|
||||
elif [ -f "pnpm-lock.yaml" ]; then
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="pnpm-lock.yaml"
|
||||
COMMON_PACKAGE_FILE="/workspace/common/package.json"
|
||||
INSTALL_CMD="CI=true pnpm install --frozen-lockfile --ignore-scripts"
|
||||
else
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="package-lock.json"
|
||||
COMMON_PACKAGE_FILE="/workspace/common/package.json"
|
||||
INSTALL_CMD="npm ci"
|
||||
fi
|
||||
|
||||
@@ -100,9 +103,9 @@ ensure_frontend_dependencies() {
|
||||
}
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
fi
|
||||
deps_stamp="node_modules/.baron-deps-hash"
|
||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||
@@ -111,9 +114,9 @@ ensure_frontend_dependencies() {
|
||||
echo "Installing frontend dependencies..."
|
||||
acquire_install_lock
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
fi
|
||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||
if [ "$installed_hash" = "$deps_hash" ]; then
|
||||
|
||||
@@ -9,4 +9,5 @@ c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,j
|
||||
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr,,,
|
||||
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,개인 사용자 기본 루트 테넌트,,,,
|
||||
|
||||
|
@@ -16,10 +16,10 @@ describe("admin routes", () => {
|
||||
expect(matches?.at(-1)?.route.path).toBe("/auth/callback");
|
||||
});
|
||||
|
||||
it("registers the super-admin user projection management route", () => {
|
||||
const matches = matchRoutes(adminRoutes, "/system/projections/users");
|
||||
it("registers the super-admin Ory SSOT system route", () => {
|
||||
const matches = matchRoutes(adminRoutes, "/system/ory-ssot");
|
||||
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/ory-ssot");
|
||||
});
|
||||
|
||||
it("registers the super-admin data integrity management route", () => {
|
||||
@@ -28,6 +28,16 @@ describe("admin routes", () => {
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
|
||||
});
|
||||
|
||||
it("routes global custom claim settings before user detail id matching", () => {
|
||||
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(
|
||||
"GlobalCustomClaimsPage",
|
||||
);
|
||||
});
|
||||
|
||||
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];
|
||||
|
||||
@@ -19,6 +19,7 @@ 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";
|
||||
@@ -44,6 +45,7 @@ export const adminRoutes: RouteObject[] = [
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "auth", element: <AuthPage /> },
|
||||
{ path: "users", element: <UserListPage /> },
|
||||
{ path: "users/custom-claims", element: <GlobalCustomClaimsPage /> },
|
||||
{ path: "users/new", element: <UserCreatePage /> },
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
@@ -65,7 +67,7 @@ export const adminRoutes: RouteObject[] = [
|
||||
},
|
||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||
{ path: "system/projections/users", element: <UserProjectionPage /> },
|
||||
{ path: "system/ory-ssot", element: <UserProjectionPage /> },
|
||||
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -53,6 +53,8 @@ function LanguageSelector() {
|
||||
|
||||
return (
|
||||
<select
|
||||
id="admin-language-selector"
|
||||
name="admin-language-selector"
|
||||
value={locale}
|
||||
onChange={(event) => handleChange(event.target.value as Locale)}
|
||||
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("admin AppLayout", () => {
|
||||
expect(screen.getByText("Tenants")).toBeInTheDocument();
|
||||
expect(screen.getByText("Org Chart")).toBeInTheDocument();
|
||||
expect(screen.getByText("Worksmobile")).toBeInTheDocument();
|
||||
expect(screen.getByText("User Projection")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ory SSOT System")).toBeInTheDocument();
|
||||
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
|
||||
const navigation = screen.getByRole("navigation");
|
||||
const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) =>
|
||||
@@ -113,7 +113,7 @@ describe("admin AppLayout", () => {
|
||||
"Tenants",
|
||||
"Org Chart",
|
||||
"Worksmobile",
|
||||
"User Projection",
|
||||
"Ory SSOT System",
|
||||
"Data Integrity",
|
||||
"Users",
|
||||
"Auth Guard",
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
@@ -234,9 +239,9 @@ function AppLayout() {
|
||||
});
|
||||
}
|
||||
filteredItems.splice(4, 0, {
|
||||
labelKey: "ui.admin.nav.user_projection",
|
||||
labelFallback: "User Projection",
|
||||
to: "/system/projections/users",
|
||||
labelKey: "ui.admin.nav.ory_ssot",
|
||||
labelFallback: "Ory SSOT System",
|
||||
to: "/system/ory-ssot",
|
||||
icon: Database,
|
||||
});
|
||||
filteredItems.splice(5, 0, {
|
||||
@@ -508,10 +513,18 @@ function AppLayout() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleSidebarToggle = () => {
|
||||
setIsSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
writeShellSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const sidebarNavContent = (
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{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}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||
{label}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
@@ -561,10 +586,17 @@ function AppLayout() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.logoutButtonCollapsed
|
||||
: shellLayoutClasses.logoutButton
|
||||
}
|
||||
title={t("ui.shell.nav.logout", "Logout")}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||
{t("ui.shell.nav.logout", "Logout")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -578,13 +610,23 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<div
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.rootCollapsed
|
||||
: shellLayoutClasses.root
|
||||
}
|
||||
>
|
||||
<AppSidebar
|
||||
brandLabel={t("ui.admin.brand", "Baron 로그인")}
|
||||
brandTitle={t("ui.admin.title", "Admin Control")}
|
||||
brandIcon={<ShieldHalf size={20} />}
|
||||
navContent={sidebarNavContent}
|
||||
footerContent={sidebarFooterContent}
|
||||
collapsed={isSidebarCollapsed}
|
||||
onToggleCollapsed={handleSidebarToggle}
|
||||
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
|
||||
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
|
||||
/>
|
||||
|
||||
<div className={shellLayoutClasses.contentWide}>
|
||||
@@ -785,7 +827,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
<main className={shellLayoutClasses.mainMinWidth}>
|
||||
<Outlet />
|
||||
<Outlet context={isSidebarCollapsed} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
19
adminfront/src/components/ui/checkbox.test.tsx
Normal file
19
adminfront/src/components/ui/checkbox.test.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Checkbox } from "./checkbox";
|
||||
|
||||
describe("Checkbox Component", () => {
|
||||
it("adds a fallback id for browser autofill diagnostics", () => {
|
||||
render(<Checkbox aria-label="Select row" />);
|
||||
|
||||
expect(screen.getByRole("checkbox")).toHaveAttribute("id");
|
||||
});
|
||||
|
||||
it("keeps explicit id and name values", () => {
|
||||
render(<Checkbox id="explicit-checkbox" name="explicit-name" />);
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
|
||||
expect(checkbox).toHaveAttribute("id", "explicit-checkbox");
|
||||
expect(checkbox).toHaveAttribute("name", "explicit-name");
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,18 @@ export interface CheckboxProps
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, onCheckedChange, ...props }, ref) => {
|
||||
({ className, onCheckedChange, id, name, ...props }, ref) => {
|
||||
const fallbackId = React.useId();
|
||||
const fieldId = id ?? (name ? undefined : fallbackId);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onCheckedChange?.(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
id={fieldId}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary",
|
||||
|
||||
@@ -9,6 +9,20 @@ describe("Input Component", () => {
|
||||
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a fallback id for browser autofill diagnostics", () => {
|
||||
render(<Input placeholder="Enter text" />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Enter text")).toHaveAttribute("id");
|
||||
});
|
||||
|
||||
it("keeps explicit id and name values", () => {
|
||||
render(<Input id="explicit-id" name="explicit-name" />);
|
||||
const input = screen.getByRole("textbox");
|
||||
|
||||
expect(input).toHaveAttribute("id", "explicit-id");
|
||||
expect(input).toHaveAttribute("name", "explicit-name");
|
||||
});
|
||||
|
||||
it("handles value changes", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -6,9 +6,14 @@ export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
({ className, type, id, name, ...props }, ref) => {
|
||||
const fallbackId = React.useId();
|
||||
const fieldId = id ?? (name ? undefined : fallbackId);
|
||||
|
||||
return (
|
||||
<input
|
||||
id={fieldId}
|
||||
name={name}
|
||||
type={type}
|
||||
className={cn(commonInputClass, className)}
|
||||
ref={ref}
|
||||
|
||||
19
adminfront/src/components/ui/textarea.test.tsx
Normal file
19
adminfront/src/components/ui/textarea.test.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
describe("Textarea Component", () => {
|
||||
it("adds a fallback id for browser autofill diagnostics", () => {
|
||||
render(<Textarea aria-label="Description" />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("id");
|
||||
});
|
||||
|
||||
it("keeps explicit id and name values", () => {
|
||||
render(<Textarea id="explicit-textarea" name="explicit-name" />);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
|
||||
expect(textarea).toHaveAttribute("id", "explicit-textarea");
|
||||
expect(textarea).toHaveAttribute("name", "explicit-name");
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,14 @@ export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
({ className, id, name, ...props }, ref) => {
|
||||
const fallbackId = React.useId();
|
||||
const fieldId = id ?? (name ? undefined : fallbackId);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id={fieldId}
|
||||
name={name}
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
|
||||
@@ -159,6 +159,8 @@ function AuditLogsPage() {
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
id="audit-filter-status"
|
||||
name="audit-filter-status"
|
||||
data-testid="audit-filter-status"
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
|
||||
56
adminfront/src/features/auth/AuthGuard.test.tsx
Normal file
56
adminfront/src/features/auth/AuthGuard.test.tsx
Normal file
@@ -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(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AuthGuard />}>
|
||||
<Route path="users" element={<div>Users outlet</div>} />
|
||||
</Route>
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 <Outlet />;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ function PermissionChecker() {
|
||||
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
|
||||
</Label>
|
||||
<select
|
||||
id="permission-checker-namespace"
|
||||
name="permission-checker-namespace"
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
|
||||
@@ -143,6 +143,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
login_count: 3,
|
||||
},
|
||||
]),
|
||||
fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({ items: [] })),
|
||||
fetchPasswordPolicy: vi.fn(async () => ({
|
||||
minLength: 12,
|
||||
lowercase: true,
|
||||
@@ -196,6 +197,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
worksmobileId: "works-user-1",
|
||||
worksmobileName: "Engineer User",
|
||||
worksmobileEmail: "engineer@example.com",
|
||||
worksmobileDomainId: 1001,
|
||||
worksmobilePrimaryOrgId: "works-org-1",
|
||||
worksmobilePrimaryOrgName: "기술연구팀",
|
||||
status: "matched",
|
||||
@@ -380,17 +382,19 @@ describe("adminfront large page coverage smoke", () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
|
||||
target: { value: "InitialPassword!1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
expect.any(String),
|
||||
undefined,
|
||||
"InitialPassword!1",
|
||||
),
|
||||
);
|
||||
const credentialBatchId = vi.mocked(
|
||||
adminApi.enqueueWorksmobileUserSync,
|
||||
).mock.calls[0][2];
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -416,6 +420,10 @@ describe("adminfront large page coverage smoke", () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
|
||||
target: { value: "InitialPassword!1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
|
||||
@@ -424,21 +432,20 @@ describe("adminfront large page coverage smoke", () => {
|
||||
1,
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
expect.any(String),
|
||||
undefined,
|
||||
"InitialPassword!1",
|
||||
);
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tenant-company",
|
||||
"user-3",
|
||||
expect.any(String),
|
||||
undefined,
|
||||
"InitialPassword!1",
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("downloads or deletes Worksmobile credential batches from history", async () => {
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
it("renders and retries Worksmobile jobs from history", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -450,45 +457,20 @@ describe("adminfront large page coverage smoke", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "이력" }));
|
||||
await screen.findByText("credential-batch-1");
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-pending 비밀번호 CSV 다운로드",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 비밀번호 CSV 다운로드",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
adminApi.downloadWorksmobileInitialPasswordsCSV,
|
||||
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||
);
|
||||
expect((await screen.findAllByText("user-1")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("failed")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 비밀번호 값 삭제",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "" })[0]);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
adminApi.deleteWorksmobileCredentialBatchPasswords,
|
||||
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||
expect(adminApi.retryWorksmobileJob).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"job-1",
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 실패 사유 보기",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("failed-user@samaneng.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("worksmobile api failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enqueues Worksmobile password reset as a credential batch", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
it("opens Worksmobile password management for matched users", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -504,17 +486,21 @@ describe("adminfront large page coverage smoke", () => {
|
||||
await screen.findAllByText("Engineer User");
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Engineer User 비밀번호 재설정",
|
||||
name: "Engineer User 비밀번호 관리",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.resetWorksmobileUserPassword).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"user-1",
|
||||
expect.any(String),
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"https://auth.worksmobile.com/integrate/password/manage",
|
||||
),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
const [url] = openSpy.mock.calls[0] ?? [];
|
||||
const parsed = new URL(String(url));
|
||||
expect(parsed.searchParams.get("targetUserTenantId")).toBe("works-admin");
|
||||
expect(parsed.searchParams.get("targetUserDomainId")).toBe("1001");
|
||||
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
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";
|
||||
@@ -7,6 +7,15 @@ import { createI18nMock } from "../../test/i18nMock";
|
||||
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
||||
|
||||
const exportUsersCSVMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
blob: new Blob(["email,name\nmember@example.com,Member User\n"], {
|
||||
type: "text/csv",
|
||||
}),
|
||||
filename: "users_export_20260609.csv",
|
||||
})),
|
||||
);
|
||||
|
||||
const tenants = [
|
||||
{
|
||||
id: "tenant-root",
|
||||
@@ -104,6 +113,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
blob: new Blob(["name,slug"]),
|
||||
filename: "tenants.csv",
|
||||
})),
|
||||
exportUsersCSV: exportUsersCSVMock,
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||
@@ -125,6 +135,10 @@ describe("admin tenant tab coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
|
||||
"blob:tenant-users-export",
|
||||
);
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("renders tenant owners and admins lists", async () => {
|
||||
@@ -159,4 +173,24 @@ describe("admin tenant tab coverage smoke", () => {
|
||||
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText("Member User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exports selected organization users by tenant slug", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/organization"
|
||||
element={<TenantUserGroupsTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/organization",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Member User")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("tenant-current-users-export-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(exportUsersCSVMock).toHaveBeenCalledWith("", "gpdtdc", false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
deleteOrphanUserLoginIDs,
|
||||
fetchDataIntegrityReport,
|
||||
fetchMe,
|
||||
fetchOrySSOTSystemStatus,
|
||||
fetchOrphanUserLoginIDs,
|
||||
fetchUserProjectionStatus,
|
||||
reconcileUserProjection,
|
||||
resetUserProjection,
|
||||
flushIdentityCache,
|
||||
} from "../../lib/adminApi";
|
||||
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import DataIntegrityPage from "./DataIntegrityPage";
|
||||
|
||||
@@ -63,22 +63,27 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
fetchUserProjectionStatus: vi.fn(async () => ({
|
||||
name: "kratos_users",
|
||||
status: "ready",
|
||||
ready: true,
|
||||
lastSyncedAt: "2026-05-11T03:00:00Z",
|
||||
updatedAt: "2026-05-11T03:00:10Z",
|
||||
projectedUsers: 152,
|
||||
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",
|
||||
},
|
||||
})),
|
||||
reconcileUserProjection: vi.fn(async () => ({
|
||||
flushIdentityCache: vi.fn(async () => ({
|
||||
status: "success",
|
||||
syncedUsers: 152,
|
||||
updatedAt: "2026-05-11T03:01:00Z",
|
||||
})),
|
||||
resetUserProjection: vi.fn(async () => ({
|
||||
status: "success",
|
||||
syncedUsers: 152,
|
||||
flushedKeys: 153,
|
||||
updatedAt: "2026-05-11T03:02:00Z",
|
||||
})),
|
||||
deleteOrphanUserLoginIDs: vi.fn(async () => ({
|
||||
@@ -128,7 +133,7 @@ describe("DataIntegrityPage", () => {
|
||||
screen.getByRole("tab", { name: "정합성 검사" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "사용자 동기화" }),
|
||||
screen.getByRole("tab", { name: "Ory SSOT 시스템" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
@@ -141,35 +146,36 @@ describe("DataIntegrityPage", () => {
|
||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders user projection sync inside data integrity", async () => {
|
||||
it("renders Ory SSOT cache management inside data integrity", async () => {
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "사용자 동기화" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("tab", { name: "Ory SSOT 시스템" }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
|
||||
expect(screen.getByText("준비됨")).toBeInTheDocument();
|
||||
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: /재동기화/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
|
||||
await waitFor(() => {
|
||||
expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
|
||||
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
|
||||
await waitFor(() => {
|
||||
expect(resetUserProjection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(fetchUserProjectionStatus).toHaveBeenCalled();
|
||||
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows orphan login ID targets and deletes selected rows", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderPage();
|
||||
const { container } = renderPage();
|
||||
|
||||
expect(await screen.findByText("유령 로그인 ID 정리")).toBeInTheDocument();
|
||||
expect(await screen.findByText("EMP001")).toBeInTheDocument();
|
||||
expect(screen.getByText("삭제된 테넌트")).toBeInTheDocument();
|
||||
expectNoAnonymousFormFields(container);
|
||||
expect(fetchOrphanUserLoginIDs).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "EMP001 선택" }));
|
||||
|
||||
@@ -247,6 +247,7 @@ function OrphanLoginIDTable({
|
||||
<tr key={item.id}>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
name={`orphan-login-id-select-${item.id}`}
|
||||
type="checkbox"
|
||||
aria-label={t(
|
||||
"ui.admin.integrity.table.select_item",
|
||||
@@ -418,7 +419,7 @@ function DataIntegrityContent() {
|
||||
className={pageTabClassName(activeTab === "projection")}
|
||||
onClick={() => setActiveTab("projection")}
|
||||
>
|
||||
{t("ui.admin.integrity.tab_user_projection", "사용자 동기화")}
|
||||
{t("ui.admin.integrity.tab_ory_ssot", "Ory SSOT 시스템")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchUserProjectionStatus,
|
||||
reconcileUserProjection,
|
||||
resetUserProjection,
|
||||
fetchOrySSOTSystemStatus,
|
||||
flushIdentityCache,
|
||||
} from "../../lib/adminApi";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import UserProjectionPage from "./UserProjectionPage";
|
||||
@@ -15,22 +14,27 @@ let currentRole = "super_admin";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: currentRole })),
|
||||
fetchUserProjectionStatus: vi.fn(async () => ({
|
||||
name: "kratos_users",
|
||||
status: "ready",
|
||||
ready: true,
|
||||
lastSyncedAt: "2026-05-11T03:00:00Z",
|
||||
updatedAt: "2026-05-11T03:00:10Z",
|
||||
projectedUsers: 152,
|
||||
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,
|
||||
lastRefreshedAt: "2026-05-11T03:00:00Z",
|
||||
updatedAt: "2026-05-11T03:00:10Z",
|
||||
keyCount: 153,
|
||||
},
|
||||
})),
|
||||
reconcileUserProjection: vi.fn(async () => ({
|
||||
flushIdentityCache: vi.fn(async () => ({
|
||||
status: "success",
|
||||
syncedUsers: 152,
|
||||
updatedAt: "2026-05-11T03:01:00Z",
|
||||
})),
|
||||
resetUserProjection: vi.fn(async () => ({
|
||||
status: "success",
|
||||
syncedUsers: 152,
|
||||
flushedKeys: 153,
|
||||
updatedAt: "2026-05-11T03:02:00Z",
|
||||
})),
|
||||
}));
|
||||
@@ -58,35 +62,35 @@ describe("UserProjectionPage", () => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
});
|
||||
|
||||
it("renders projection status for super_admin", async () => {
|
||||
it("renders Ory SSOT and Redis identity cache status for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Ory SSOT 시스템")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
|
||||
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
|
||||
expect(screen.getByText("준비됨")).toBeInTheDocument();
|
||||
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(fetchUserProjectionStatus).toHaveBeenCalled();
|
||||
expect(screen.getByText("151")).toBeInTheDocument();
|
||||
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs reconcile and reset actions for super_admin", async () => {
|
||||
it("flushes only the Redis identity cache for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("사용자 동기화 관리");
|
||||
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
|
||||
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(reconcileUserProjection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetUserProjection).toHaveBeenCalledTimes(1);
|
||||
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,21 +100,21 @@ describe("UserProjectionPage", () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||
expect(screen.queryByText("사용자 동기화 관리")).not.toBeInTheDocument();
|
||||
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("Ory SSOT 시스템")).not.toBeInTheDocument();
|
||||
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("User Projection Management"),
|
||||
await screen.findByText(
|
||||
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Review and sync the Kratos user read model."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Re-sync")).toBeInTheDocument();
|
||||
expect(await screen.findByText("ready")).toBeInTheDocument();
|
||||
expect(screen.getByText("Redis cache flush")).toBeInTheDocument();
|
||||
expect((await screen.findAllByText("ready")).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,56 +1,43 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, RefreshCw, RotateCcw, Users } from "lucide-react";
|
||||
import { AlertTriangle, Database, Trash2 } from "lucide-react";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
fetchUserProjectionStatus,
|
||||
reconcileUserProjection,
|
||||
resetUserProjection,
|
||||
fetchOrySSOTSystemStatus,
|
||||
flushIdentityCache,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { getAdminDateLocale } from "../../lib/locale";
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(getAdminDateLocale(), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function ProjectionStatusBadge({
|
||||
ready,
|
||||
status,
|
||||
}: {
|
||||
ready: boolean;
|
||||
status: string;
|
||||
}) {
|
||||
function StatusBadge({ ready, status }: { ready: boolean; status: string }) {
|
||||
if (ready) {
|
||||
return (
|
||||
<Badge variant="success">
|
||||
{t("ui.admin.user_projection.status.ready", "ready")}
|
||||
{t("ui.admin.ory_ssot.status.ready", "ready")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === "failed") {
|
||||
return (
|
||||
<Badge variant="warning">
|
||||
{t("ui.admin.user_projection.status.failed", "failed")}
|
||||
{t("ui.admin.ory_ssot.status.failed", "failed")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{status
|
||||
? status
|
||||
: t("ui.admin.user_projection.status.not_ready", "not ready")}
|
||||
{status ? status : t("ui.admin.ory_ssot.status.not_ready", "not ready")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -62,41 +49,31 @@ export function UserProjectionContent({
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["user-projection-status"],
|
||||
queryFn: fetchUserProjectionStatus,
|
||||
queryKey: ["ory-ssot-system-status"],
|
||||
queryFn: fetchOrySSOTSystemStatus,
|
||||
});
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["user-projection-status"],
|
||||
});
|
||||
};
|
||||
|
||||
const reconcileMutation = useMutation({
|
||||
mutationFn: reconcileUserProjection,
|
||||
onSuccess: invalidate,
|
||||
const flushMutation = useMutation({
|
||||
mutationFn: flushIdentityCache,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["ory-ssot-system-status"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: resetUserProjection,
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const handleReset = () => {
|
||||
const handleFlush = () => {
|
||||
const confirmed = window.confirm(
|
||||
t(
|
||||
"msg.admin.user_projection.reset_confirm",
|
||||
"Rebuild user projection from the Kratos source of truth?",
|
||||
"msg.admin.ory_ssot.flush_confirm",
|
||||
"Flush only Redis identity cache keys?",
|
||||
),
|
||||
);
|
||||
if (confirmed) {
|
||||
resetMutation.mutate();
|
||||
}
|
||||
if (confirmed) flushMutation.mutate();
|
||||
};
|
||||
|
||||
const isWorking = reconcileMutation.isPending || resetMutation.isPending;
|
||||
const actionResult = reconcileMutation.data ?? resetMutation.data;
|
||||
const actionError = reconcileMutation.error ?? resetMutation.error;
|
||||
const projection = data?.userProjection;
|
||||
const identityCache = data?.identityCache;
|
||||
|
||||
const header = (
|
||||
<header
|
||||
@@ -108,40 +85,32 @@ export function UserProjectionContent({
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<Users size={20} />
|
||||
<Database size={20} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.user_projection.title", "User Projection Management")}
|
||||
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.user_projection.subtitle",
|
||||
"Review and sync the Kratos user read model.",
|
||||
"msg.admin.ory_ssot.subtitle",
|
||||
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => reconcileMutation.mutate()}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleReset}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleFlush}
|
||||
disabled={flushMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t(
|
||||
"ui.admin.ory_ssot.actions.flush_identity_cache",
|
||||
"Redis cache flush",
|
||||
)}
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -151,28 +120,28 @@ export function UserProjectionContent({
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message ||
|
||||
t(
|
||||
"msg.admin.user_projection.load_error",
|
||||
"Failed to load projection status.",
|
||||
"msg.admin.ory_ssot.load_error",
|
||||
"Failed to load Ory SSOT system status.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{actionResult ? (
|
||||
{flushMutation.data ? (
|
||||
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||
{t(
|
||||
"msg.admin.user_projection.action_success",
|
||||
"Refreshed the projection for {{count}} users.",
|
||||
{ count: actionResult.syncedUsers },
|
||||
"msg.admin.ory_ssot.flush_success",
|
||||
"Flushed {{count}} Redis identity cache keys.",
|
||||
{ count: flushMutation.data.flushedKeys },
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
{flushMutation.error ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(actionError as Error)?.message ||
|
||||
{(flushMutation.error as Error)?.message ||
|
||||
t(
|
||||
"msg.admin.user_projection.action_error",
|
||||
"Projection operation failed.",
|
||||
"msg.admin.ory_ssot.flush_error",
|
||||
"Redis identity cache flush failed.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
@@ -180,16 +149,16 @@ export function UserProjectionContent({
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-3 border-b border-border pb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
<h3 className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.user_projection.card.title",
|
||||
"Kratos users projection",
|
||||
"ui.admin.ory_ssot.projection_card.title",
|
||||
"Backend user read model",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.user_projection.card.description",
|
||||
"Current user read model state referenced by backend DB statistics.",
|
||||
"ui.admin.ory_ssot.projection_card.description",
|
||||
"PostgreSQL read model status used by admin search and statistics.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -197,58 +166,131 @@ export function UserProjectionContent({
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">
|
||||
{t("ui.admin.user_projection.loading", "Loading")}
|
||||
{t("ui.admin.ory_ssot.loading", "Loading")}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.user_projection.summary.status", "Status")}
|
||||
{t("ui.admin.ory_ssot.summary.status", "Status")}
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<ProjectionStatusBadge
|
||||
ready={data?.ready ?? false}
|
||||
status={data?.status ?? "unknown"}
|
||||
<StatusBadge
|
||||
ready={projection?.ready ?? false}
|
||||
status={projection?.status ?? "unknown"}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.summary.local_users", "Local users")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{projection?.projectedUsers ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.ory_ssot.summary.last_synced",
|
||||
"Last read-model refresh",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(projection?.lastSyncedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.summary.updated_at", "Updated at")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(projection?.updatedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{projection?.lastError ? (
|
||||
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
|
||||
<span>{projection.lastError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-3 border-b border-border pb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">
|
||||
{t("ui.admin.ory_ssot.cache_card.title", "Redis identity cache")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.ory_ssot.cache_card.description",
|
||||
"Redis mirror/cache status for Kratos identity list and lookup operations.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.loading", "Loading")}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.summary.status", "Status")}
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<StatusBadge
|
||||
ready={
|
||||
Boolean(identityCache?.redisReady) &&
|
||||
identityCache?.status === "ready"
|
||||
}
|
||||
status={identityCache?.status ?? "unknown"}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.user_projection.summary.projected_users",
|
||||
"Projected users",
|
||||
"ui.admin.ory_ssot.summary.observed_identities",
|
||||
"Observed identities",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.projectedUsers ?? 0}
|
||||
{identityCache?.observedCount ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.summary.cache_keys", "Cache keys")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{identityCache?.keyCount ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.user_projection.summary.last_synced",
|
||||
"Last synced",
|
||||
"ui.admin.ory_ssot.summary.last_refreshed",
|
||||
"Last refreshed",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.lastSyncedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.user_projection.summary.updated_at", "Updated at")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.updatedAt)}
|
||||
{formatDateTime(identityCache?.lastRefreshedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{data?.lastError ? (
|
||||
{identityCache?.lastError ? (
|
||||
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
|
||||
<span>{data.lastError}</span>
|
||||
<span>{identityCache.lastError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
@@ -280,11 +322,11 @@ export default function UserProjectionPage() {
|
||||
<main className="p-6 md:p-8">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("ui.admin.user_projection.forbidden.title", "Access denied")}
|
||||
{t("ui.admin.ory_ssot.forbidden.title", "Access denied")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.user_projection.forbidden.description",
|
||||
"msg.admin.ory_ssot.forbidden.description",
|
||||
"This screen is only available to super_admin users.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -161,6 +161,8 @@ export function ParentTenantSelector({
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
id="parent-tenant-local-search"
|
||||
name="parent-tenant-local-search"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={localSearch}
|
||||
onChange={(event) => setLocalSearch(event.target.value)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
filterTenantsByScope,
|
||||
getTenantSearchMatchIds,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
tenantMatchesListSearch,
|
||||
@@ -69,6 +70,7 @@ describe("TenantListPage tenant list helpers", () => {
|
||||
expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true);
|
||||
expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true);
|
||||
expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true);
|
||||
expect(tenantMatchesListSearch(tenants[2], "삼안")).toBe(false);
|
||||
});
|
||||
|
||||
it("can return tree rows or same-level table rows", () => {
|
||||
@@ -79,4 +81,20 @@ describe("TenantListPage tenant list helpers", () => {
|
||||
[0, 0, 0, 0],
|
||||
);
|
||||
});
|
||||
|
||||
it("marks only direct search matches when tree search includes ancestors", () => {
|
||||
const treeRows = getTenantViewRows(
|
||||
tenants.filter((item) => item.id !== "company-2"),
|
||||
"tree",
|
||||
"",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(treeRows.map((row) => row.id)).toEqual([
|
||||
"company-1",
|
||||
"dept-1",
|
||||
"team-1",
|
||||
]);
|
||||
expect(getTenantSearchMatchIds(treeRows, "platform")).toEqual(["team-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -343,6 +343,8 @@ export function TenantProfilePage() {
|
||||
)}
|
||||
</Label>
|
||||
<select
|
||||
id="tenant-org-unit-type"
|
||||
name="tenant-org-unit-type"
|
||||
data-testid="tenant-org-unit-type-select"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={orgUnitType}
|
||||
@@ -361,6 +363,8 @@ export function TenantProfilePage() {
|
||||
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
|
||||
</Label>
|
||||
<select
|
||||
id="tenant-visibility"
|
||||
name="tenant-visibility"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={tenantVisibility}
|
||||
onChange={(event) =>
|
||||
|
||||
@@ -205,6 +205,8 @@ export function TenantSchemaPage() {
|
||||
{t("ui.admin.tenants.schema.field.type", "유형")}
|
||||
</Label>
|
||||
<select
|
||||
id={`tenant-schema-field-type-${field.key || index}`}
|
||||
name={`tenant-schema-field-type-${field.key || index}`}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
|
||||
value={field.type}
|
||||
onChange={(e) => {
|
||||
@@ -266,6 +268,7 @@ export function TenantSchemaPage() {
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
name={`tenant-schema-field-required-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) =>
|
||||
@@ -279,6 +282,7 @@ export function TenantSchemaPage() {
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
name={`tenant-schema-field-admin-only-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.adminOnly}
|
||||
onChange={(e) =>
|
||||
@@ -295,6 +299,7 @@ export function TenantSchemaPage() {
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
name={`tenant-schema-field-login-id-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.isLoginId || false}
|
||||
onChange={(e) =>
|
||||
@@ -315,6 +320,7 @@ export function TenantSchemaPage() {
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
name={`tenant-schema-field-indexed-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.indexed || field.isLoginId || false}
|
||||
disabled={field.isLoginId}
|
||||
@@ -333,6 +339,7 @@ export function TenantSchemaPage() {
|
||||
{(field.type === "number" || field.type === "float") && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
name={`tenant-schema-field-unsigned-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.unsigned}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../../test/i18nMock";
|
||||
import TenantUsersPage from "./TenantUsersPage";
|
||||
|
||||
const exportUsersCSVMock = vi.hoisted(() => vi.fn());
|
||||
const updateUserMock = vi.hoisted(() => vi.fn());
|
||||
const fetchUsersMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../../lib/adminApi", () => ({
|
||||
fetchTenant: vi.fn(async () => ({
|
||||
id: "tenant-team-id",
|
||||
name: "기술기획팀",
|
||||
slug: "tech-planning",
|
||||
})),
|
||||
fetchUsers: fetchUsersMock,
|
||||
exportUsersCSV: exportUsersCSVMock,
|
||||
updateUser: updateUserMock,
|
||||
}));
|
||||
|
||||
function renderTenantUsersPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["/tenants/tenant-team-id/users"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/users"
|
||||
element={<TenantUsersPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TenantUsersPage export", () => {
|
||||
beforeEach(() => {
|
||||
exportUsersCSVMock.mockReset();
|
||||
updateUserMock.mockReset();
|
||||
fetchUsersMock.mockReset();
|
||||
fetchUsersMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
exportUsersCSVMock.mockResolvedValue({
|
||||
blob: new Blob(["email,name\nalice@example.com,Alice\n"], {
|
||||
type: "text/csv",
|
||||
}),
|
||||
filename: "users_export_20260609.csv",
|
||||
});
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
|
||||
"blob:tenant-users-export",
|
||||
);
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("exports only the currently opened tenant users by tenant slug", async () => {
|
||||
renderTenantUsersPage();
|
||||
|
||||
await screen.findByText("Alice");
|
||||
|
||||
fireEvent.click(screen.getByTestId("tenant-users-export-menu-item"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(exportUsersCSVMock).toHaveBeenCalledWith(
|
||||
"",
|
||||
"tech-planning",
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("queues searched users and adds all queued users to the tenant at once", async () => {
|
||||
fetchUsersMock
|
||||
.mockResolvedValueOnce({ items: [], total: 0 })
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: "user-2",
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "user-3",
|
||||
name: "Carol",
|
||||
email: "carol@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
})
|
||||
.mockResolvedValue({ items: [], total: 0 });
|
||||
updateUserMock.mockResolvedValue({});
|
||||
|
||||
renderTenantUsersPage();
|
||||
|
||||
const addButton = await screen.findByTestId(
|
||||
"tenant-member-add-existing-btn",
|
||||
);
|
||||
await waitFor(() => expect(addButton).not.toBeDisabled());
|
||||
fireEvent.click(addButton);
|
||||
fireEvent.change(screen.getByTestId("tenant-member-search-input"), {
|
||||
target: { value: "bo" },
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByText("Bob"));
|
||||
fireEvent.click(await screen.findByText("Carol"));
|
||||
|
||||
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
|
||||
"Bob",
|
||||
);
|
||||
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
|
||||
"Carol",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUserMock).toHaveBeenCalledWith("user-2", {
|
||||
tenantSlug: "tech-planning",
|
||||
isAddTenant: true,
|
||||
});
|
||||
expect(updateUserMock).toHaveBeenCalledWith("user-3", {
|
||||
tenantSlug: "tech-planning",
|
||||
isAddTenant: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Loader2, Mail, Plus, User, UserPlus } from "lucide-react";
|
||||
import {
|
||||
FileDown,
|
||||
Loader2,
|
||||
Mail,
|
||||
Plus,
|
||||
Search,
|
||||
User,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
@@ -11,6 +21,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -20,7 +39,13 @@ import {
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi";
|
||||
import {
|
||||
exportUsersCSV,
|
||||
fetchTenant,
|
||||
fetchUsers,
|
||||
type UserSummary,
|
||||
updateUser,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantUsersPage() {
|
||||
@@ -28,6 +53,9 @@ function TenantUsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
const [addMembersOpen, setAddMembersOpen] = React.useState(false);
|
||||
const [memberSearch, setMemberSearch] = React.useState("");
|
||||
const [queuedMembers, setQueuedMembers] = React.useState<UserSummary[]>([]);
|
||||
|
||||
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
|
||||
const tenantQuery = useQuery({
|
||||
@@ -45,6 +73,33 @@ function TenantUsersPage() {
|
||||
enabled: !!tenantSlug,
|
||||
});
|
||||
|
||||
const memberSearchTerm = memberSearch.trim();
|
||||
const memberSearchQuery = useQuery({
|
||||
queryKey: ["tenant-member-search", tenantSlug, memberSearchTerm],
|
||||
queryFn: () => fetchUsers(20, 0, memberSearchTerm),
|
||||
enabled: addMembersOpen && memberSearchTerm.length >= 2,
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: (includeIds: boolean) =>
|
||||
exportUsersCSV("", tenantSlug ?? "", includeIds),
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const removeTenantMutation = useMutation({
|
||||
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
|
||||
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
|
||||
@@ -66,6 +121,38 @@ function TenantUsersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const addMembersMutation = useMutation({
|
||||
mutationFn: async (members: UserSummary[]) => {
|
||||
if (!tenantSlug || members.length === 0) return;
|
||||
await Promise.all(
|
||||
members.map((member) =>
|
||||
updateUser(member.id, { tenantSlug, isAddTenant: true }),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
const count = queuedMembers.length;
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.tenants.members.add_success",
|
||||
"{{count}}명의 구성원이 추가되었습니다.",
|
||||
{ count },
|
||||
),
|
||||
);
|
||||
setQueuedMembers([]);
|
||||
setMemberSearch("");
|
||||
setAddMembersOpen(false);
|
||||
usersQuery.refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.admin.tenants.members.add_error", "구성원 추가 실패"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const _handleRemoveMember = (userId: string, userName: string) => {
|
||||
if (!tenantSlug) return;
|
||||
if (
|
||||
@@ -82,6 +169,28 @@ function TenantUsersPage() {
|
||||
};
|
||||
|
||||
const users = usersQuery.data?.items ?? [];
|
||||
const existingUserIds = React.useMemo(
|
||||
() => new Set(users.map((user) => user.id)),
|
||||
[users],
|
||||
);
|
||||
const queuedUserIds = React.useMemo(
|
||||
() => new Set(queuedMembers.map((user) => user.id)),
|
||||
[queuedMembers],
|
||||
);
|
||||
const searchResults = memberSearchQuery.data?.items ?? [];
|
||||
|
||||
const queueMember = (member: UserSummary) => {
|
||||
if (existingUserIds.has(member.id) || queuedUserIds.has(member.id)) {
|
||||
return;
|
||||
}
|
||||
setQueuedMembers((current) => [...current, member]);
|
||||
};
|
||||
|
||||
const removeQueuedMember = (memberId: string) => {
|
||||
setQueuedMembers((current) =>
|
||||
current.filter((member) => member.id !== memberId),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
@@ -92,12 +201,39 @@ function TenantUsersPage() {
|
||||
count: users.length,
|
||||
})}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Link to={`/users?addTenant=${tenantSlug}`}>
|
||||
<UserPlus size={16} />
|
||||
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={!tenantSlug || exportMutation.isPending}
|
||||
data-testid="tenant-users-export-menu-item"
|
||||
onClick={() => exportMutation.mutate(false)}
|
||||
>
|
||||
<FileDown size={16} />
|
||||
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={!tenantSlug || exportMutation.isPending}
|
||||
data-testid="tenant-users-export-with-ids-menu-item"
|
||||
onClick={() => exportMutation.mutate(true)}
|
||||
>
|
||||
<FileDown size={16} />
|
||||
{t("ui.common.export_with_ids", "UUID 포함 내보내기")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={!tenantSlug}
|
||||
data-testid="tenant-member-add-existing-btn"
|
||||
onClick={() => setAddMembersOpen(true)}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
|
||||
</Button>
|
||||
<Button size="sm" asChild className="gap-2">
|
||||
<Link to={`/users/new?tenantSlug=${tenantSlug}`}>
|
||||
@@ -107,6 +243,143 @@ function TenantUsersPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Dialog open={addMembersOpen} onOpenChange={setAddMembersOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"ui.admin.tenants.members.add_existing_description",
|
||||
"검색 결과를 선택해 추가 명단에 담은 뒤 한 번에 배정합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={memberSearch}
|
||||
onChange={(event) => setMemberSearch(event.target.value)}
|
||||
className="h-9 pl-9"
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.members.search_placeholder",
|
||||
"이름 또는 이메일 검색",
|
||||
)}
|
||||
data-testid="tenant-member-search-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<div className="max-h-56 overflow-auto">
|
||||
{memberSearchTerm.length < 2 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.members.search_min_length",
|
||||
"두 글자 이상 입력하세요.",
|
||||
)}
|
||||
</div>
|
||||
) : memberSearchQuery.isFetching ? (
|
||||
<div className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t("ui.common.searching", "검색 중...")}
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{t("ui.common.no_results", "검색 결과가 없습니다.")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{searchResults.map((user) => {
|
||||
const disabled =
|
||||
existingUserIds.has(user.id) ||
|
||||
queuedUserIds.has(user.id);
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onClick={() => queueMember(user)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</span>
|
||||
<Plus size={16} className="flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-20 rounded-md border bg-muted/20 p-3"
|
||||
data-testid="tenant-member-add-queue"
|
||||
>
|
||||
{queuedMembers.length === 0 ? (
|
||||
<div className="flex h-14 items-center justify-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.members.queue_empty",
|
||||
"추가할 구성원을 선택하세요.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{queuedMembers.map((user) => (
|
||||
<span
|
||||
key={user.id}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-2 py-1 text-sm"
|
||||
>
|
||||
<span className="max-w-52 truncate">{user.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => removeQueuedMember(user.id)}
|
||||
aria-label={t(
|
||||
"ui.admin.tenants.members.queue_remove",
|
||||
"추가 명단에서 제거",
|
||||
)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAddMembersOpen(false)}
|
||||
disabled={addMembersMutation.isPending}
|
||||
>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addMembersMutation.mutate(queuedMembers)}
|
||||
disabled={
|
||||
queuedMembers.length === 0 || addMembersMutation.isPending
|
||||
}
|
||||
data-testid="tenant-member-add-submit-btn"
|
||||
>
|
||||
{addMembersMutation.isPending && (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
)}
|
||||
{t("ui.admin.tenants.members.add_queued", "선택 구성원 추가")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedCreateUserIds,
|
||||
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
|
||||
getWorksmobileSelectedUpdateUserIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
isImmutableWorksmobileAccount,
|
||||
summarizeWorksmobileComparison,
|
||||
@@ -225,6 +227,41 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("separates selected WORKS user creation ids from update-needed user ids", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-only",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "needs_update",
|
||||
baronId: "needs-update",
|
||||
worksmobileId: "works-needs-update",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronId: "matched",
|
||||
worksmobileId: "works-matched",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
},
|
||||
];
|
||||
const selectedKeys = rows.map(getWorksmobileRowSelectionKey);
|
||||
|
||||
expect(getWorksmobileSelectedCreateUserIds(rows, selectedKeys)).toEqual([
|
||||
"baron-only",
|
||||
]);
|
||||
expect(getWorksmobileSelectedUpdateUserIds(rows, selectedKeys)).toEqual([
|
||||
"needs-update",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses compact comparison columns by default", () => {
|
||||
expect(getDefaultWorksmobileComparisonColumns()).toEqual({
|
||||
status: true,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
@@ -42,21 +39,16 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
deleteWorksmobileCredentialBatchPasswords,
|
||||
deleteWorksmobilePendingJobs,
|
||||
downloadWorksmobileInitialPasswordsCSV,
|
||||
enqueueWorksmobileBackfillDryRun,
|
||||
enqueueWorksmobileOrgUnitDelete,
|
||||
enqueueWorksmobileOrgUnitSync,
|
||||
enqueueWorksmobileUserSync,
|
||||
fetchMe,
|
||||
fetchWorksmobileComparison,
|
||||
fetchWorksmobileCredentialBatches,
|
||||
fetchWorksmobileOverview,
|
||||
resetWorksmobileUserPassword,
|
||||
retryWorksmobileJob,
|
||||
type WorksmobileComparisonItem,
|
||||
type WorksmobileCredentialBatch,
|
||||
type WorksmobileOutboxItem,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
@@ -81,8 +73,9 @@ import {
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedCreateUserIds,
|
||||
getWorksmobileSelectedUpdateUserIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
isImmutableWorksmobileAccount,
|
||||
summarizeWorksmobileComparison,
|
||||
type WorksmobileComparisonColumnKey,
|
||||
type WorksmobileComparisonColumnVisibility,
|
||||
@@ -90,17 +83,6 @@ import {
|
||||
type WorksmobileComparisonSummary,
|
||||
} from "./worksmobileComparison";
|
||||
|
||||
type InitialPasswordDownloadVariables = {
|
||||
batchId?: string;
|
||||
};
|
||||
|
||||
export function createWorksmobileCredentialBatchId() {
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `worksmobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function worksmobileJobPayloadString(job: WorksmobileOutboxItem, key: string) {
|
||||
const value = job.payload?.[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
@@ -238,12 +220,6 @@ export function TenantWorksmobilePage() {
|
||||
enabled: tenantId.length > 0 && hasWorksmobileAccess,
|
||||
});
|
||||
|
||||
const credentialBatchesQuery = useQuery({
|
||||
queryKey: ["worksmobile-credential-batches", tenantId],
|
||||
queryFn: () => fetchWorksmobileCredentialBatches(tenantId),
|
||||
enabled: tenantId.length > 0 && hasWorksmobileAccess,
|
||||
});
|
||||
|
||||
const dryRunMutation = useMutation({
|
||||
mutationFn: () => enqueueWorksmobileBackfillDryRun(tenantId),
|
||||
onSuccess: () => {
|
||||
@@ -275,7 +251,6 @@ export function TenantWorksmobilePage() {
|
||||
onSuccess: (result) => {
|
||||
toast.success(`대기중 payload ${result.deletedCount}건을 삭제했습니다.`);
|
||||
overviewQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("대기중 payload 삭제 실패", {
|
||||
@@ -284,40 +259,6 @@ export function TenantWorksmobilePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const initialPasswordDownloadMutation = useMutation({
|
||||
mutationFn: (variables?: InitialPasswordDownloadVariables) =>
|
||||
downloadWorksmobileInitialPasswordsCSV(tenantId, variables?.batchId),
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("초기 비밀번호 CSV 다운로드 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCredentialBatchPasswordsMutation = useMutation({
|
||||
mutationFn: (batchId: string) =>
|
||||
deleteWorksmobileCredentialBatchPasswords(tenantId, batchId),
|
||||
onSuccess: () => {
|
||||
toast.success("비밀번호 값을 삭제했습니다.");
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("비밀번호 값 삭제 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const orgUnitSyncMutation = useMutation({
|
||||
mutationFn: () => enqueueWorksmobileOrgUnitSync(tenantId, orgUnitId.trim()),
|
||||
onSuccess: () => {
|
||||
@@ -348,20 +289,24 @@ export function TenantWorksmobilePage() {
|
||||
mutationFn: async ({
|
||||
resourceKind,
|
||||
ids,
|
||||
initialPassword,
|
||||
}: {
|
||||
resourceKind: "users" | "groups";
|
||||
ids: string[];
|
||||
initialPassword?: string;
|
||||
}) => {
|
||||
const credentialBatchId =
|
||||
resourceKind === "users"
|
||||
? createWorksmobileCredentialBatchId()
|
||||
: undefined;
|
||||
const trimmedInitialPassword = initialPassword?.trim();
|
||||
const failures: string[] = [];
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
if (resourceKind === "users") {
|
||||
await enqueueWorksmobileUserSync(tenantId, id, credentialBatchId);
|
||||
await enqueueWorksmobileUserSync(
|
||||
tenantId,
|
||||
id,
|
||||
undefined,
|
||||
trimmedInitialPassword,
|
||||
);
|
||||
} else {
|
||||
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
||||
}
|
||||
@@ -379,10 +324,6 @@ export function TenantWorksmobilePage() {
|
||||
resourceKind,
|
||||
count: successCount,
|
||||
failureCount: failures.length,
|
||||
credentialBatchId:
|
||||
resourceKind === "users" && successCount > 0
|
||||
? credentialBatchId
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ resourceKind, count, failureCount }) => {
|
||||
@@ -397,15 +338,11 @@ export function TenantWorksmobilePage() {
|
||||
});
|
||||
} else {
|
||||
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
||||
description:
|
||||
resourceKind === "users"
|
||||
? `${count}건, 비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.`
|
||||
: `${count}건`,
|
||||
description: `${count}건`,
|
||||
});
|
||||
}
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("WORKS 생성 작업 등록 실패", {
|
||||
@@ -414,30 +351,6 @@ export function TenantWorksmobilePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const resetWorksmobilePasswordMutation = useMutation({
|
||||
mutationFn: ({
|
||||
userId,
|
||||
credentialBatchId,
|
||||
}: {
|
||||
userId: string;
|
||||
credentialBatchId: string;
|
||||
}) => resetWorksmobileUserPassword(tenantId, userId, credentialBatchId),
|
||||
onSuccess: () => {
|
||||
toast.success("WORKS 비밀번호 재설정 작업을 등록했습니다.", {
|
||||
description:
|
||||
"비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.",
|
||||
});
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("WORKS 비밀번호 재설정 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const syncSelectedOrgUnitsMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
baronIds,
|
||||
@@ -522,10 +435,7 @@ export function TenantWorksmobilePage() {
|
||||
createSelectedMutation.isPending &&
|
||||
createSelectedMutation.variables?.resourceKind === "users";
|
||||
const isSyncingGroups = syncSelectedOrgUnitsMutation.isPending;
|
||||
const isRefreshing =
|
||||
overviewQuery.isFetching ||
|
||||
comparisonQuery.isFetching ||
|
||||
credentialBatchesQuery.isFetching;
|
||||
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 max-w-full space-y-6">
|
||||
@@ -548,7 +458,6 @@ export function TenantWorksmobilePage() {
|
||||
onClick={() => {
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
@@ -602,29 +511,6 @@ export function TenantWorksmobilePage() {
|
||||
|
||||
{activeTab === "history" ? (
|
||||
<div className="space-y-4 animate-in fade-in duration-500">
|
||||
<CredentialBatchHistory
|
||||
batches={credentialBatchesQuery.data ?? []}
|
||||
loading={credentialBatchesQuery.isLoading}
|
||||
downloadingBatchId={
|
||||
initialPasswordDownloadMutation.isPending
|
||||
? initialPasswordDownloadMutation.variables?.batchId
|
||||
: undefined
|
||||
}
|
||||
deletingBatchId={deleteCredentialBatchPasswordsMutation.variables}
|
||||
onDownload={(batchId) =>
|
||||
initialPasswordDownloadMutation.mutate({ batchId })
|
||||
}
|
||||
onDelete={(batchId) => {
|
||||
if (
|
||||
window.confirm(
|
||||
"이 배치의 실제 비밀번호 값을 삭제할까요? 생성 이력은 유지됩니다.",
|
||||
)
|
||||
) {
|
||||
deleteCredentialBatchPasswordsMutation.mutate(batchId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -742,6 +628,7 @@ export function TenantWorksmobilePage() {
|
||||
<ComparisonTable
|
||||
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
|
||||
rows={filteredComparisonUsers}
|
||||
totalRows={comparisonQuery.data?.users.length ?? 0}
|
||||
loading={comparisonQuery.isLoading}
|
||||
selectedKeys={selectedUserRowKeys}
|
||||
onSelectedKeysChange={setSelectedUserRowKeys}
|
||||
@@ -767,29 +654,21 @@ export function TenantWorksmobilePage() {
|
||||
passwordManageTenantId={overview?.config.adminTenantId}
|
||||
actionLabel="선택 구성원 WORKS에 생성"
|
||||
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
|
||||
onCreateSelected={(ids) =>
|
||||
updateActionLabel="선택 구성원 업데이트 적용"
|
||||
onCreateSelected={(ids, initialPassword) =>
|
||||
createSelectedMutation.mutate({
|
||||
resourceKind: "users",
|
||||
ids,
|
||||
initialPassword,
|
||||
})
|
||||
}
|
||||
onUpdateSelected={(ids) =>
|
||||
createSelectedMutation.mutate({
|
||||
resourceKind: "users",
|
||||
ids,
|
||||
})
|
||||
}
|
||||
resettingPasswordUserId={
|
||||
resetWorksmobilePasswordMutation.isPending
|
||||
? resetWorksmobilePasswordMutation.variables?.userId
|
||||
: undefined
|
||||
}
|
||||
onResetUserPassword={(userId) => {
|
||||
if (
|
||||
window.confirm(
|
||||
"선택한 WORKS 계정의 비밀번호를 재설정할까요? 새 비밀번호는 배치 처리 완료 후 히스토리에서 CSV로 다운로드할 수 있습니다.",
|
||||
)
|
||||
) {
|
||||
resetWorksmobilePasswordMutation.mutate({
|
||||
userId,
|
||||
credentialBatchId: createWorksmobileCredentialBatchId(),
|
||||
});
|
||||
}
|
||||
}}
|
||||
requireInitialPassword
|
||||
/>
|
||||
<Card data-testid="worksmobile-users-single-sync">
|
||||
<CardContent className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
||||
@@ -835,6 +714,7 @@ export function TenantWorksmobilePage() {
|
||||
"조직/그룹",
|
||||
)}
|
||||
rows={filteredComparisonGroups}
|
||||
totalRows={comparisonQuery.data?.groups.length ?? 0}
|
||||
loading={comparisonQuery.isLoading}
|
||||
selectedKeys={selectedGroupRowKeys}
|
||||
onSelectedKeysChange={setSelectedGroupRowKeys}
|
||||
@@ -940,6 +820,11 @@ const worksmobileComparisonColumnWidths: Record<
|
||||
worksmobileOrg: 260,
|
||||
manage: 112,
|
||||
};
|
||||
const worksmobileComparisonTableHeadClassName =
|
||||
"h-12 whitespace-nowrap px-0 align-middle";
|
||||
const worksmobileComparisonTableHeadContentClassName =
|
||||
"flex h-full items-center px-4";
|
||||
const worksmobileComparisonTableHeadCenterContentClassName = `${worksmobileComparisonTableHeadContentClassName} justify-center`;
|
||||
|
||||
function getDefaultGroupWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
|
||||
return {
|
||||
@@ -982,216 +867,6 @@ function getWorksmobileComparisonStatusVariant(status: string) {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function formatCredentialBatchDate(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
return date.toLocaleString("ko-KR", {
|
||||
timeZone: "Asia/Seoul",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function CredentialBatchHistory({
|
||||
batches,
|
||||
loading,
|
||||
downloadingBatchId,
|
||||
deletingBatchId,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: {
|
||||
batches: WorksmobileCredentialBatch[];
|
||||
loading: boolean;
|
||||
downloadingBatchId?: string;
|
||||
deletingBatchId?: string;
|
||||
onDownload: (batchId: string) => void;
|
||||
onDelete: (batchId: string) => void;
|
||||
}) {
|
||||
const [expandedBatchIds, setExpandedBatchIds] = React.useState<string[]>([]);
|
||||
const toggleExpanded = (batchId: string) => {
|
||||
setExpandedBatchIds((current) =>
|
||||
current.includes(batchId)
|
||||
? current.filter((id) => id !== batchId)
|
||||
: [...current, batchId],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">비밀번호 파일 히스토리</CardTitle>
|
||||
<CardDescription>
|
||||
생성 배치별 CSV를 다시 받거나 전달 완료된 배치의 실제 비밀번호 값을
|
||||
삭제합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="w-full max-w-full overflow-x-auto rounded-md border">
|
||||
<Table className="min-w-max">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-56 whitespace-nowrap">
|
||||
배치
|
||||
</TableHead>
|
||||
<TableHead className="w-24 whitespace-nowrap">사용자</TableHead>
|
||||
<TableHead className="min-w-36 whitespace-nowrap">
|
||||
상태
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
생성
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
삭제
|
||||
</TableHead>
|
||||
<TableHead className="w-24 whitespace-nowrap">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
불러오는 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && batches.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
생성된 비밀번호 배치가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{batches.map((batch) => {
|
||||
const isComplete =
|
||||
(batch.pendingCount ?? 0) === 0 &&
|
||||
(batch.processingCount ?? 0) === 0;
|
||||
const isExpanded = expandedBatchIds.includes(batch.batchId);
|
||||
const failures = batch.failures ?? [];
|
||||
return (
|
||||
<React.Fragment key={batch.batchId}>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
{failures.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 실패 사유 보기`}
|
||||
onClick={() => toggleExpanded(batch.batchId)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<span>{batch.batchId}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
{batch.userCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
<span className="mr-2">
|
||||
성공 {batch.processedCount ?? 0}
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
대기 {batch.pendingCount ?? 0}
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
처리 {batch.processingCount ?? 0}
|
||||
</span>
|
||||
<span>실패 {batch.failedCount ?? 0}</span>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{formatCredentialBatchDate(batch.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{batch.hasPasswords
|
||||
? "보관 중"
|
||||
: formatCredentialBatchDate(batch.deletedAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 비밀번호 CSV 다운로드`}
|
||||
disabled={
|
||||
!batch.hasPasswords ||
|
||||
!isComplete ||
|
||||
downloadingBatchId === batch.batchId
|
||||
}
|
||||
onClick={() => onDownload(batch.batchId)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 비밀번호 값 삭제`}
|
||||
disabled={
|
||||
!batch.hasPasswords ||
|
||||
deletingBatchId === batch.batchId
|
||||
}
|
||||
onClick={() => onDelete(batch.batchId)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && failures.length > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="bg-muted/30">
|
||||
<div className="space-y-2 text-xs">
|
||||
{failures.map((failure) => (
|
||||
<div
|
||||
key={`${failure.userId ?? failure.email}:${failure.lastError}`}
|
||||
className="grid gap-1 md:grid-cols-[minmax(12rem,1fr)_5rem_minmax(18rem,2fr)]"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{failure.email ?? failure.userId ?? "-"}
|
||||
</div>
|
||||
{failure.userId && (
|
||||
<div className="font-mono text-muted-foreground">
|
||||
{failure.userId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{failure.status} / retry{" "}
|
||||
{failure.retryCount ?? 0}
|
||||
</div>
|
||||
<div className="break-words">
|
||||
{failure.lastError ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonSummary({
|
||||
title,
|
||||
summary,
|
||||
@@ -1304,6 +979,7 @@ function ComparisonFilterButtons<T extends string>({
|
||||
function ComparisonTable({
|
||||
title,
|
||||
rows,
|
||||
totalRows,
|
||||
loading,
|
||||
selectedKeys,
|
||||
onSelectedKeysChange,
|
||||
@@ -1321,17 +997,19 @@ function ComparisonTable({
|
||||
showBaronIdColumn = true,
|
||||
showManageColumn = true,
|
||||
actionLabel,
|
||||
updateActionLabel,
|
||||
actionDisabled,
|
||||
onCreateSelected,
|
||||
onUpdateSelected,
|
||||
onRunSelected,
|
||||
deleteActionLabel,
|
||||
deleteActionDisabled = false,
|
||||
onDeleteSelected,
|
||||
resettingPasswordUserId,
|
||||
onResetUserPassword,
|
||||
requireInitialPassword = false,
|
||||
}: {
|
||||
title: string;
|
||||
rows: WorksmobileComparisonItem[];
|
||||
totalRows: number;
|
||||
loading: boolean;
|
||||
selectedKeys: string[];
|
||||
onSelectedKeysChange: (ids: string[]) => void;
|
||||
@@ -1351,22 +1029,35 @@ function ComparisonTable({
|
||||
showBaronIdColumn?: boolean;
|
||||
showManageColumn?: boolean;
|
||||
actionLabel: string;
|
||||
updateActionLabel?: string;
|
||||
actionDisabled: boolean;
|
||||
onCreateSelected: (ids: string[]) => void;
|
||||
onCreateSelected: (ids: string[], initialPassword?: string) => void;
|
||||
onUpdateSelected?: (ids: string[]) => void;
|
||||
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
|
||||
deleteActionLabel?: string;
|
||||
deleteActionDisabled?: boolean;
|
||||
onDeleteSelected?: (ids: string[]) => void;
|
||||
resettingPasswordUserId?: string;
|
||||
onResetUserPassword?: (userId: string) => void;
|
||||
requireInitialPassword?: boolean;
|
||||
}) {
|
||||
const [columnSettingsOpen, setColumnSettingsOpen] = React.useState(false);
|
||||
const [initialPasswordOpen, setInitialPasswordOpen] = React.useState(false);
|
||||
const [initialPassword, setInitialPassword] = React.useState("");
|
||||
const [pendingInitialPasswordIds, setPendingInitialPasswordIds] =
|
||||
React.useState<string[]>([]);
|
||||
const tableViewportRef = React.useRef<HTMLDivElement>(null);
|
||||
const selectableKeys = rows
|
||||
.filter(canSelectWorksmobileRow)
|
||||
.map(getWorksmobileRowSelectionKey)
|
||||
.filter(Boolean);
|
||||
const selectedActionIds = getWorksmobileSelectedActionIds(rows, selectedKeys);
|
||||
const selectedCreateUserIds = getWorksmobileSelectedCreateUserIds(
|
||||
rows,
|
||||
selectedKeys,
|
||||
);
|
||||
const selectedUpdateUserIds = getWorksmobileSelectedUpdateUserIds(
|
||||
rows,
|
||||
selectedKeys,
|
||||
);
|
||||
const selectedDeleteIds = getWorksmobileSelectedWorksOnlyOrgUnitIds(
|
||||
rows,
|
||||
selectedKeys,
|
||||
@@ -1377,6 +1068,7 @@ function ComparisonTable({
|
||||
selectedActionIds.length === 0 &&
|
||||
selectedDeleteIds.length > 0 &&
|
||||
canRunDeleteAction;
|
||||
const canRunUserUpdateAction = Boolean(onUpdateSelected);
|
||||
const selectedActionLabel = shouldRunDeleteAction
|
||||
? deleteActionLabel
|
||||
: actionLabel;
|
||||
@@ -1388,7 +1080,11 @@ function ComparisonTable({
|
||||
? selectedActionIds.length === 0 && selectedDeleteIds.length === 0
|
||||
: shouldRunDeleteAction
|
||||
? selectedDeleteIds.length === 0 || deleteActionDisabled
|
||||
: selectedActionIds.length === 0) || actionDisabled;
|
||||
: requireInitialPassword
|
||||
? selectedCreateUserIds.length === 0
|
||||
: selectedActionIds.length === 0) || actionDisabled;
|
||||
const updateActionDisabled =
|
||||
selectedUpdateUserIds.length === 0 || actionDisabled;
|
||||
const allSelectableSelected =
|
||||
selectableKeys.length > 0 &&
|
||||
selectableKeys.every((key) => selectedKeys.includes(key));
|
||||
@@ -1476,15 +1172,6 @@ function ComparisonTable({
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const canResetPassword = (row: WorksmobileComparisonItem) =>
|
||||
Boolean(
|
||||
onResetUserPassword &&
|
||||
row.resourceType === "USER" &&
|
||||
row.baronId &&
|
||||
row.status !== "missing_in_worksmobile" &&
|
||||
!isImmutableWorksmobileAccount(row),
|
||||
);
|
||||
|
||||
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
|
||||
onVisibleColumnsChange((current) => ({
|
||||
...current,
|
||||
@@ -1510,11 +1197,55 @@ function ComparisonTable({
|
||||
);
|
||||
};
|
||||
|
||||
const runSelectedAction = () => {
|
||||
if (onRunSelected) {
|
||||
onRunSelected(selectedActionIds, selectedDeleteIds);
|
||||
return;
|
||||
}
|
||||
if (shouldRunDeleteAction && onDeleteSelected) {
|
||||
onDeleteSelected(selectedDeleteIds);
|
||||
return;
|
||||
}
|
||||
if (requireInitialPassword) {
|
||||
setPendingInitialPasswordIds(selectedCreateUserIds);
|
||||
setInitialPassword("");
|
||||
setInitialPasswordOpen(true);
|
||||
return;
|
||||
}
|
||||
onCreateSelected(selectedActionIds);
|
||||
};
|
||||
|
||||
const runUpdateAction = () => {
|
||||
if (!onUpdateSelected || selectedUpdateUserIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
onUpdateSelected(selectedUpdateUserIds);
|
||||
};
|
||||
|
||||
const confirmInitialPassword = () => {
|
||||
const password = initialPassword.trim();
|
||||
if (!password) {
|
||||
toast.error("WORKS 초기 비밀번호를 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
onCreateSelected(pendingInitialPasswordIds, password);
|
||||
setInitialPasswordOpen(false);
|
||||
setInitialPassword("");
|
||||
setPendingInitialPasswordIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
|
||||
<h4 className="text-lg font-semibold leading-none">{title}</h4>
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid={`worksmobile-${title}-row-count`}
|
||||
className="font-mono"
|
||||
>
|
||||
표시 {rows.length} / 전체 {totalRows}
|
||||
</Badge>
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
@@ -1568,6 +1299,7 @@ function ComparisonTable({
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-muted/50"
|
||||
>
|
||||
<input
|
||||
name={`worksmobile-column-${column.key}`}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
checked={isColumnVisible(column.key)}
|
||||
@@ -1594,21 +1326,69 @@ function ComparisonTable({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selectedActionVariant}
|
||||
onClick={() => {
|
||||
if (onRunSelected) {
|
||||
onRunSelected(selectedActionIds, selectedDeleteIds);
|
||||
return;
|
||||
}
|
||||
if (shouldRunDeleteAction && onDeleteSelected) {
|
||||
onDeleteSelected(selectedDeleteIds);
|
||||
return;
|
||||
}
|
||||
onCreateSelected(selectedActionIds);
|
||||
}}
|
||||
onClick={runSelectedAction}
|
||||
disabled={selectedActionDisabled}
|
||||
>
|
||||
{selectedActionLabel}
|
||||
</Button>
|
||||
{canRunUserUpdateAction && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={runUpdateAction}
|
||||
disabled={updateActionDisabled}
|
||||
>
|
||||
{updateActionLabel || "선택 구성원 업데이트 적용"}
|
||||
</Button>
|
||||
)}
|
||||
<Dialog
|
||||
open={initialPasswordOpen}
|
||||
onOpenChange={(open) => {
|
||||
setInitialPasswordOpen(open);
|
||||
if (!open) {
|
||||
setInitialPassword("");
|
||||
setPendingInitialPasswordIds([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>WORKS 초기 비밀번호</DialogTitle>
|
||||
<DialogDescription>
|
||||
선택한 구성원을 WORKS에 신규 생성할 때 사용할 공통 초기
|
||||
비밀번호를 입력하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="worksmobile-initial-password"
|
||||
>
|
||||
초기 비밀번호
|
||||
</label>
|
||||
<Input
|
||||
id="worksmobile-initial-password"
|
||||
type="password"
|
||||
value={initialPassword}
|
||||
onChange={(event) => setInitialPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setInitialPasswordOpen(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="button" onClick={confirmInitialPassword}>
|
||||
생성 작업 등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1625,54 +1405,100 @@ function ComparisonTable({
|
||||
minWidth: tableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableHead className="w-10 whitespace-nowrap">
|
||||
<Checkbox
|
||||
aria-label={`${title} 전체 선택`}
|
||||
checked={allSelectableSelected}
|
||||
disabled={selectableKeys.length === 0}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={
|
||||
worksmobileComparisonTableHeadCenterContentClassName
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
aria-label={`${title} 전체 선택`}
|
||||
checked={allSelectableSelected}
|
||||
disabled={selectableKeys.length === 0}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
{isColumnVisible("status") && (
|
||||
<TableHead className="w-24 whitespace-nowrap">상태</TableHead>
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
상태
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{showBaronIdColumn && isColumnVisible("baronId") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
Baron ID
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
Baron ID
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("baron") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
Baron
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
Baron
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("baronOrg") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
{baronOrgColumnLabel}
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
{baronOrgColumnLabel}
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("externalKey") && (
|
||||
<TableHead className="min-w-40 whitespace-nowrap">
|
||||
external_key
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
external_key
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobileDomain") && (
|
||||
<TableHead className="min-w-28 whitespace-nowrap">
|
||||
WORKS 도메인
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
WORKS 도메인
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobile") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
WORKS
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
WORKS
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobileOrg") && (
|
||||
<TableHead className="min-w-52 whitespace-nowrap">
|
||||
상위 Works 조직
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
상위 Works 조직
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{showManageColumn && isColumnVisible("manage") && (
|
||||
<TableHead className="w-14 whitespace-nowrap">관리</TableHead>
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
관리
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -1887,23 +1713,6 @@ function ComparisonTable({
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 재설정`}
|
||||
disabled={
|
||||
!canResetPassword(row) ||
|
||||
resettingPasswordUserId === row.baronId
|
||||
}
|
||||
onClick={() => {
|
||||
if (row.baronId) {
|
||||
onResetUserPassword?.(row.baronId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
@@ -16,6 +16,16 @@ export function tenantMatchesListSearch(
|
||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
}
|
||||
|
||||
export function getTenantSearchMatchIds(
|
||||
rows: Array<Pick<TenantSummary, "id" | "name" | "slug" | "type">>,
|
||||
search: string,
|
||||
) {
|
||||
if (!search.trim()) return [];
|
||||
return rows
|
||||
.filter((row) => tenantMatchesListSearch(row, search))
|
||||
.map((row) => row.id);
|
||||
}
|
||||
|
||||
function collectTenantTreeRows(
|
||||
nodes: TenantNode[],
|
||||
depth: number,
|
||||
@@ -91,7 +101,8 @@ export function getTenantViewRows(
|
||||
...(rowsById.get(tenant.id) ?? {
|
||||
...tenant,
|
||||
children: [],
|
||||
recursiveMemberCount: Number(tenant.memberCount) || 0,
|
||||
recursiveMemberCount:
|
||||
Number(tenant.totalMemberCount ?? tenant.memberCount) || 0,
|
||||
}),
|
||||
depth: 0,
|
||||
}));
|
||||
|
||||
@@ -172,6 +172,38 @@ export function getWorksmobileSelectedActionIds(
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedCreateUserIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
const selected = new Set(selectedKeys);
|
||||
return rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.resourceType === "USER" &&
|
||||
row.status === "missing_in_worksmobile" &&
|
||||
selected.has(getWorksmobileRowSelectionKey(row)),
|
||||
)
|
||||
.map((row) => row.baronId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedUpdateUserIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
const selected = new Set(selectedKeys);
|
||||
return rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.resourceType === "USER" &&
|
||||
row.status === "needs_update" &&
|
||||
selected.has(getWorksmobileRowSelectionKey(row)),
|
||||
)
|
||||
.map((row) => row.baronId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
exportTenantsCSV,
|
||||
exportUsersCSV,
|
||||
fetchAllTenants,
|
||||
fetchUsers,
|
||||
type TenantSummary,
|
||||
@@ -432,6 +433,24 @@ function TenantUserGroupsTab() {
|
||||
),
|
||||
});
|
||||
|
||||
const exportCurrentMembersMutation = useMutation({
|
||||
mutationFn: (tenantSlug: string) => exportUsersCSV("", tenantSlug, false),
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
onError: () =>
|
||||
toast.error(
|
||||
t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
|
||||
),
|
||||
});
|
||||
|
||||
// Data Fetching
|
||||
const {
|
||||
data: allTenantsData,
|
||||
@@ -623,6 +642,20 @@ function TenantUserGroupsTab() {
|
||||
<UserPlus size={16} className="mr-2" />
|
||||
{t("ui.admin.users.list.add", "멤버 추가")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
exportCurrentMembersMutation.mutate(selectedNode.slug)
|
||||
}
|
||||
disabled={
|
||||
!selectedNode.slug || exportCurrentMembersMutation.isPending
|
||||
}
|
||||
data-testid="tenant-current-users-export-btn"
|
||||
>
|
||||
<Download size={16} className="mr-2" />
|
||||
{t("ui.admin.tenants.members.export", "선택 조직 사용자 CSV")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
323
adminfront/src/features/users/GlobalCustomClaimsPage.tsx
Normal file
323
adminfront/src/features/users/GlobalCustomClaimsPage.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Key, Plus, Save, Trash2, Users } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
fetchGlobalCustomClaimDefinitions,
|
||||
type GlobalCustomClaimDefinition,
|
||||
type GlobalCustomClaimPermission,
|
||||
updateGlobalCustomClaimDefinitions,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
type ClaimDraft = GlobalCustomClaimDefinition & { id: string };
|
||||
|
||||
const valueTypes: GlobalCustomClaimDefinition["valueType"][] = [
|
||||
"text",
|
||||
"number",
|
||||
"boolean",
|
||||
"array",
|
||||
"object",
|
||||
"date",
|
||||
"datetime",
|
||||
];
|
||||
|
||||
const permissions: GlobalCustomClaimPermission[] = [
|
||||
"admin_only",
|
||||
"user_and_admin",
|
||||
];
|
||||
|
||||
function toDrafts(items: GlobalCustomClaimDefinition[]): ClaimDraft[] {
|
||||
return items.map((item, index) => ({
|
||||
id: `${item.key || "claim"}-${index}`,
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
valueType: item.valueType || "text",
|
||||
readPermission: item.readPermission || "admin_only",
|
||||
writePermission: item.writePermission || "admin_only",
|
||||
description: item.description || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
|
||||
return drafts
|
||||
.map((draft) => ({
|
||||
key: draft.key.trim(),
|
||||
label: draft.label.trim(),
|
||||
valueType: draft.valueType,
|
||||
readPermission: draft.readPermission,
|
||||
writePermission: draft.writePermission,
|
||||
description: draft.description?.trim(),
|
||||
}))
|
||||
.filter((draft) => draft.key.length > 0);
|
||||
}
|
||||
|
||||
function permissionLabel(permission: GlobalCustomClaimPermission) {
|
||||
return permission === "user_and_admin"
|
||||
? t(
|
||||
"ui.common.custom_claim_permission.user_and_admin",
|
||||
"사용자 및 관리자 가능",
|
||||
)
|
||||
: t("ui.common.custom_claim_permission.admin_only", "관리자만 가능");
|
||||
}
|
||||
|
||||
export default function GlobalCustomClaimsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [drafts, setDrafts] = React.useState<ClaimDraft[]>([]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["global-custom-claim-definitions"],
|
||||
queryFn: fetchGlobalCustomClaimDefinitions,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data) {
|
||||
setDrafts(toDrafts(query.data.items));
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateGlobalCustomClaimDefinitions,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["global-custom-claim-definitions"], data);
|
||||
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("err.common.unknown", "오류가 발생했습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
const addClaim = () => {
|
||||
setDrafts((current) => [
|
||||
...current,
|
||||
{
|
||||
id: `global-claim-${Date.now()}`,
|
||||
key: "",
|
||||
label: "",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
description: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const updateClaim = (id: string, patch: Partial<ClaimDraft>) => {
|
||||
setDrafts((current) =>
|
||||
current.map((draft) =>
|
||||
draft.id === id ? { ...draft, ...patch } : draft,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removeClaim = (id: string) => {
|
||||
setDrafts((current) => current.filter((draft) => draft.id !== id));
|
||||
};
|
||||
|
||||
const saveClaims = () => {
|
||||
mutation.mutate({ items: toDefinitions(drafts) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
titleAs="h2"
|
||||
icon={<Key size={20} />}
|
||||
title={t(
|
||||
"ui.admin.users.global_custom_claims.title",
|
||||
"전역 Claim 설정",
|
||||
)}
|
||||
description={t(
|
||||
"msg.admin.users.global_custom_claims.description",
|
||||
"모든 RP에 공통 적용할 사용자 claim 정의와 읽기/쓰기 권한 기본값을 관리합니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="outline" size="sm" className="h-9">
|
||||
<Link to="/users">
|
||||
<Users size={16} />
|
||||
{t("ui.admin.users.list.title", "사용자 관리")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 gap-2"
|
||||
onClick={addClaim}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-9 gap-2"
|
||||
disabled={mutation.isPending}
|
||||
onClick={saveClaims}
|
||||
>
|
||||
<Save size={16} />
|
||||
{t("ui.common.save", "저장")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{t(
|
||||
"ui.admin.users.global_custom_claims.registry",
|
||||
"Global Claim Registry",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.global_custom_claims.registry",
|
||||
"정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{query.isLoading ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t("ui.common.loading", "로딩 중...")}
|
||||
</div>
|
||||
) : drafts.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed py-12 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.users.global_custom_claims.empty",
|
||||
"정의된 전역 claim이 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
drafts.map((claim) => (
|
||||
<div
|
||||
key={claim.id}
|
||||
className="grid gap-3 rounded-md border bg-background p-3 lg:grid-cols-[minmax(160px,0.8fr)_minmax(160px,0.8fr)_130px_160px_160px_minmax(220px,1fr)_40px]"
|
||||
>
|
||||
<Input
|
||||
value={claim.key}
|
||||
name={`global-claim-definition-key-${claim.id}`}
|
||||
className="font-mono text-xs"
|
||||
placeholder="claim_key"
|
||||
data-testid={`global-claim-definition-key-${claim.key || claim.id}`}
|
||||
onChange={(event) =>
|
||||
updateClaim(claim.id, { key: event.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
value={claim.label}
|
||||
name={`global-claim-definition-label-${claim.id}`}
|
||||
placeholder={t(
|
||||
"ui.admin.users.global_custom_claims.label_placeholder",
|
||||
"표시 이름",
|
||||
)}
|
||||
data-testid={`global-claim-definition-label-${claim.key || claim.id}`}
|
||||
onChange={(event) =>
|
||||
updateClaim(claim.id, { label: event.target.value })
|
||||
}
|
||||
/>
|
||||
<select
|
||||
aria-label={t(
|
||||
"ui.admin.users.global_custom_claims.value_type",
|
||||
"Claim 타입",
|
||||
)}
|
||||
value={claim.valueType}
|
||||
name={`global-claim-definition-value-type-${claim.id}`}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
onChange={(event) =>
|
||||
updateClaim(claim.id, {
|
||||
valueType: event.target
|
||||
.value as GlobalCustomClaimDefinition["valueType"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{valueTypes.map((valueType) => (
|
||||
<option key={valueType} value={valueType}>
|
||||
{valueType}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label={t(
|
||||
"ui.admin.users.global_custom_claims.read_permission",
|
||||
"읽기 권한",
|
||||
)}
|
||||
value={claim.readPermission}
|
||||
name={`global-claim-definition-read-permission-${claim.id}`}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
data-testid={`global-claim-definition-read-permission-${claim.key || claim.id}`}
|
||||
onChange={(event) =>
|
||||
updateClaim(claim.id, {
|
||||
readPermission: event.target
|
||||
.value as GlobalCustomClaimPermission,
|
||||
})
|
||||
}
|
||||
>
|
||||
{permissions.map((permission) => (
|
||||
<option key={permission} value={permission}>
|
||||
{permissionLabel(permission)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label={t(
|
||||
"ui.admin.users.global_custom_claims.write_permission",
|
||||
"쓰기 권한",
|
||||
)}
|
||||
value={claim.writePermission}
|
||||
name={`global-claim-definition-write-permission-${claim.id}`}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
data-testid={`global-claim-definition-write-permission-${claim.key || claim.id}`}
|
||||
onChange={(event) =>
|
||||
updateClaim(claim.id, {
|
||||
writePermission: event.target
|
||||
.value as GlobalCustomClaimPermission,
|
||||
})
|
||||
}
|
||||
>
|
||||
{permissions.map((permission) => (
|
||||
<option key={permission} value={permission}>
|
||||
{permissionLabel(permission)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
value={claim.description || ""}
|
||||
name={`global-claim-definition-description-${claim.id}`}
|
||||
placeholder={t(
|
||||
"ui.admin.users.global_custom_claims.description_placeholder",
|
||||
"설명",
|
||||
)}
|
||||
onChange={(event) =>
|
||||
updateClaim(claim.id, { description: event.target.value })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeClaim(claim.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<ShieldAlert size={48} className="text-destructive" />
|
||||
@@ -712,6 +720,8 @@ function UserCreatePage() {
|
||||
</Label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
id="auto-password"
|
||||
name="auto-password"
|
||||
type="checkbox"
|
||||
checked={autoPassword}
|
||||
onChange={(event) => setAutoPassword(event.target.checked)}
|
||||
|
||||
@@ -34,6 +34,18 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
name: "Admin",
|
||||
email: "admin@example.com",
|
||||
})),
|
||||
fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
key: "contract_date",
|
||||
label: "계약일",
|
||||
valueType: "date",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchPasswordPolicy: vi.fn(async () => ({ minLength: 12 })),
|
||||
fetchTenant: vi.fn(),
|
||||
fetchUser: vi.fn(async () => ({
|
||||
@@ -65,6 +77,9 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
"4": "o",
|
||||
"5": "n",
|
||||
},
|
||||
global_custom_claims: {
|
||||
contract_date: "2026-06-09",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
@@ -152,4 +167,45 @@ describe("UserDetailPage Worksmobile employee number", () => {
|
||||
const payload = updateUserMock.mock.calls[0][1];
|
||||
expect(payload.metadata).not.toHaveProperty("employee_id");
|
||||
});
|
||||
|
||||
it("only allows editing per-user values for globally defined custom claims", async () => {
|
||||
renderUserDetailPage();
|
||||
|
||||
const tab = await screen.findByTestId("global-custom-claim-tab");
|
||||
fireEvent.click(tab);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "추가" }),
|
||||
).not.toBeInTheDocument();
|
||||
const valueInput = await screen.findByTestId(
|
||||
"global-custom-claim-value-contract_date",
|
||||
);
|
||||
|
||||
expect(screen.getByText("contract_date")).toBeInTheDocument();
|
||||
expect(valueInput).toHaveValue("2026-06-09");
|
||||
expect(valueInput).toHaveAttribute("type", "date");
|
||||
|
||||
fireEvent.change(valueInput, { target: { value: "2026-07-01" } });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /사용자 Claim 값 저장/ }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
expect(updateUserMock).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
global_custom_claims: expect.objectContaining({
|
||||
contract_date: "2026-07-01",
|
||||
}),
|
||||
global_custom_claim_permissions: expect.objectContaining({
|
||||
contract_date: {
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,10 +60,14 @@ import {
|
||||
TabsTrigger,
|
||||
} from "../../components/ui/tabs";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||
import type {
|
||||
GlobalCustomClaimDefinition,
|
||||
PasswordPolicyResponse,
|
||||
} from "../../lib/adminApi";
|
||||
import {
|
||||
deleteUser,
|
||||
fetchAllTenants,
|
||||
fetchGlobalCustomClaimDefinitions,
|
||||
fetchMe,
|
||||
fetchPasswordPolicy,
|
||||
fetchTenant,
|
||||
@@ -75,7 +79,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,
|
||||
@@ -108,6 +115,25 @@ type PickerTarget = { kind: "appointment"; index: number };
|
||||
type AppointmentDraft = UserAppointment & {
|
||||
draftId: string;
|
||||
};
|
||||
type GlobalCustomClaimType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "date"
|
||||
| "datetime";
|
||||
type CustomClaimPermission = "admin_only" | "user_and_admin";
|
||||
type GlobalCustomClaimRow = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
valueType: GlobalCustomClaimType;
|
||||
readPermission: CustomClaimPermission;
|
||||
writePermission: CustomClaimPermission;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const PASSWORD_RESET_MIN_LENGTH = 12;
|
||||
|
||||
@@ -176,6 +202,74 @@ function createDraftId() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
|
||||
}
|
||||
|
||||
function createGlobalCustomClaimRows(
|
||||
metadata: Record<string, unknown>,
|
||||
definitions: GlobalCustomClaimDefinition[],
|
||||
): GlobalCustomClaimRow[] {
|
||||
const rawClaims = isMetadataRecord(metadata.global_custom_claims)
|
||||
? metadata.global_custom_claims
|
||||
: {};
|
||||
|
||||
return definitions.map((definition, index) => {
|
||||
const value = rawClaims[definition.key];
|
||||
return {
|
||||
id: `${definition.key}-${index}`,
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
value:
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value == null
|
||||
? ""
|
||||
: JSON.stringify(value),
|
||||
valueType: definition.valueType,
|
||||
readPermission: definition.readPermission,
|
||||
writePermission: definition.writePermission,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function globalCustomClaimInputType(valueType: GlobalCustomClaimType) {
|
||||
if (valueType === "date") {
|
||||
return "date";
|
||||
}
|
||||
if (valueType === "datetime") {
|
||||
return "datetime-local";
|
||||
}
|
||||
if (valueType === "number") {
|
||||
return "number";
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
|
||||
function globalCustomClaimRowsToMetadata(rows: GlobalCustomClaimRow[]) {
|
||||
const claims: Record<string, unknown> = {};
|
||||
const types: Record<string, GlobalCustomClaimType> = {};
|
||||
const permissions: Record<
|
||||
string,
|
||||
{
|
||||
readPermission: CustomClaimPermission;
|
||||
writePermission: CustomClaimPermission;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const key = row.key.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
claims[key] = row.value.trim();
|
||||
types[key] = row.valueType;
|
||||
permissions[key] = {
|
||||
readPermission: row.readPermission,
|
||||
writePermission: row.writePermission,
|
||||
};
|
||||
}
|
||||
|
||||
return { claims, types, permissions };
|
||||
}
|
||||
|
||||
async function resolveTenantSelection(
|
||||
selection: OrgChartTenantSelection,
|
||||
tenants: TenantSummary[],
|
||||
@@ -405,6 +499,9 @@ function UserDetailPage() {
|
||||
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
||||
AppointmentDraft[]
|
||||
>([]);
|
||||
const [globalCustomClaimRows, setGlobalCustomClaimRows] = React.useState<
|
||||
GlobalCustomClaimRow[]
|
||||
>([]);
|
||||
const [pickerTarget, setPickerTarget] = React.useState<PickerTarget | null>(
|
||||
null,
|
||||
);
|
||||
@@ -446,6 +543,14 @@ function UserDetailPage() {
|
||||
queryKey: ["password-policy"],
|
||||
queryFn: fetchPasswordPolicy,
|
||||
});
|
||||
const { data: globalCustomClaimDefinitionsData } = useQuery({
|
||||
queryKey: ["global-custom-claim-definitions"],
|
||||
queryFn: fetchGlobalCustomClaimDefinitions,
|
||||
});
|
||||
const globalCustomClaimDefinitions = React.useMemo(
|
||||
() => globalCustomClaimDefinitionsData?.items ?? [],
|
||||
[globalCustomClaimDefinitionsData?.items],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -472,6 +577,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("");
|
||||
@@ -753,6 +859,9 @@ function UserDetailPage() {
|
||||
? "hanmac"
|
||||
: "external";
|
||||
setUserCategory(resolvedUserCategory);
|
||||
setGlobalCustomClaimRows(
|
||||
createGlobalCustomClaimRows(metadata, globalCustomClaimDefinitions),
|
||||
);
|
||||
const familyFallbackTenants = [
|
||||
...(user.joinedTenants ?? []),
|
||||
...(user.tenant ? [user.tenant] : []),
|
||||
@@ -810,7 +919,14 @@ function UserDetailPage() {
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]);
|
||||
}, [
|
||||
globalCustomClaimDefinitions,
|
||||
hanmacFamilyTenantId,
|
||||
personalTenant,
|
||||
tenants,
|
||||
user,
|
||||
reset,
|
||||
]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
||||
@@ -959,6 +1075,29 @@ function UserDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateGlobalCustomClaimRow = (
|
||||
id: string,
|
||||
patch: Partial<GlobalCustomClaimRow>,
|
||||
) => {
|
||||
setGlobalCustomClaimRows((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
};
|
||||
|
||||
const saveGlobalCustomClaims = () => {
|
||||
const { claims, types, permissions } = globalCustomClaimRowsToMetadata(
|
||||
globalCustomClaimRows,
|
||||
);
|
||||
mutation.mutate({
|
||||
metadata: {
|
||||
...((user?.metadata as Record<string, unknown> | undefined) ?? {}),
|
||||
global_custom_claims: claims,
|
||||
global_custom_claim_types: types,
|
||||
global_custom_claim_permissions: permissions,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const userAffiliatedTenants = React.useMemo(() => {
|
||||
const joined = user?.joinedTenants || [];
|
||||
const primary = user?.tenant;
|
||||
@@ -999,8 +1138,7 @@ function UserDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Access Control: Only super_admin or self can view details
|
||||
if (!isAdmin && !isSelf) {
|
||||
if (!isAdmin && !isSelf && !canManageCurrentUser) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<ShieldAlert size={48} className="text-destructive" />
|
||||
@@ -1118,6 +1256,17 @@ function UserDetailPage() {
|
||||
<Building2 size={16} className="mr-2" />
|
||||
{t("ui.admin.users.detail.tabs.tenants", "테넌트 프로필")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="customClaims"
|
||||
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
||||
data-testid="global-custom-claim-tab"
|
||||
>
|
||||
<Key size={16} className="mr-2" />
|
||||
{t(
|
||||
"ui.admin.users.detail.tabs.custom_claims",
|
||||
"전역 Custom Claims",
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
||||
@@ -1790,6 +1939,135 @@ function UserDetailPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="customClaims"
|
||||
className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2"
|
||||
>
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Key size={18} className="text-primary" />
|
||||
{t(
|
||||
"ui.admin.users.detail.custom_claims.title",
|
||||
"사용자별 Custom Claim 값",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.detail.custom_claims.description",
|
||||
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => navigate("/users/custom-claims")}
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.admin.users.global_custom_claims.manage_definitions",
|
||||
"전역 정의 관리",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-8">
|
||||
{globalCustomClaimRows.length === 0 ? (
|
||||
<div className="rounded-2xl border-2 border-dashed bg-muted/5 py-12 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.users.detail.custom_claims.empty",
|
||||
"전역으로 정의된 custom claim이 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{globalCustomClaimRows.map((claim) => (
|
||||
<div
|
||||
key={claim.id}
|
||||
className="grid gap-3 lg:grid-cols-[minmax(180px,0.8fr)_130px_150px_160px_minmax(220px,1fr)]"
|
||||
>
|
||||
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
|
||||
{claim.key}
|
||||
</div>
|
||||
<Badge
|
||||
variant="muted"
|
||||
className="h-10 justify-center rounded-md px-3 font-mono text-xs"
|
||||
>
|
||||
{claim.valueType}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="muted"
|
||||
className="h-10 justify-center rounded-md px-3 text-xs"
|
||||
>
|
||||
{claim.readPermission === "user_and_admin"
|
||||
? t(
|
||||
"ui.common.custom_claim_permission.user_and_admin",
|
||||
"사용자 및 관리자 가능",
|
||||
)
|
||||
: t(
|
||||
"ui.common.custom_claim_permission.admin_only",
|
||||
"관리자만 가능",
|
||||
)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="muted"
|
||||
className="h-10 justify-center rounded-md px-3 text-xs"
|
||||
>
|
||||
{claim.writePermission === "user_and_admin"
|
||||
? t(
|
||||
"ui.common.custom_claim_permission.user_and_admin",
|
||||
"사용자 및 관리자 가능",
|
||||
)
|
||||
: t(
|
||||
"ui.common.custom_claim_permission.admin_only",
|
||||
"관리자만 가능",
|
||||
)}
|
||||
</Badge>
|
||||
<Input
|
||||
type={globalCustomClaimInputType(claim.valueType)}
|
||||
value={claim.value}
|
||||
onChange={(event) =>
|
||||
updateGlobalCustomClaimRow(claim.id, {
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
data-testid={`global-custom-claim-value-${claim.key || claim.id}`}
|
||||
placeholder="claim value"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={mutation.isPending}
|
||||
onClick={saveGlobalCustomClaims}
|
||||
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<span className="text-base font-bold">
|
||||
{t(
|
||||
"ui.admin.users.detail.custom_claims.save",
|
||||
"사용자 Claim 값 저장",
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</form>
|
||||
|
||||
<TabsContent
|
||||
|
||||
@@ -23,7 +23,7 @@ const users = Array.from({ length: 200 }, (_, index) => ({
|
||||
|
||||
const fetchUsersMock = vi.hoisted(() => vi.fn());
|
||||
const searchRenderBudgetMs =
|
||||
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 200;
|
||||
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 300;
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
@@ -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" } });
|
||||
@@ -157,6 +157,35 @@ describe("UserListPage search rendering", () => {
|
||||
expect(content).toHaveClass("flex", "h-full", "items-center");
|
||||
});
|
||||
|
||||
it("renders additional tenant appointments in the tenant column", async () => {
|
||||
fetchUsersMock.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
...users[0],
|
||||
name: "Additional Tenant User",
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantId: "tenant-2",
|
||||
tenantSlug: "private-team",
|
||||
tenantName: "비공개 팀",
|
||||
isPrimary: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
renderUserListPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Additional Tenant User"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("비공개 팀")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("centers the initial loading message across the user table", async () => {
|
||||
const deferred = createDeferred<{ items: typeof users; total: number }>();
|
||||
fetchUsersMock.mockReturnValueOnce(deferred.promise);
|
||||
@@ -179,7 +208,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 +218,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ChevronDown,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -117,7 +118,7 @@ type UserSchemaField = {
|
||||
type UserSortKey = string;
|
||||
|
||||
const USER_ROW_ESTIMATED_HEIGHT = 64;
|
||||
const USER_ROW_OVERSCAN = 20;
|
||||
const USER_ROW_OVERSCAN = 2;
|
||||
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
|
||||
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
|
||||
const userMetadataColumnWidth = 160;
|
||||
@@ -150,6 +151,52 @@ function assignableSystemRoleValue(role?: string | null) {
|
||||
return isSuperAdminRole(role) ? "super_admin" : "user";
|
||||
}
|
||||
|
||||
function collectAdditionalTenantLabels(user: UserSummary) {
|
||||
const primaryKeys = new Set(
|
||||
[user.tenant?.id, user.tenant?.slug, user.tenantSlug]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.toLowerCase()),
|
||||
);
|
||||
const labels: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addLabel = (
|
||||
tenantId?: unknown,
|
||||
tenantSlug?: unknown,
|
||||
tenantName?: unknown,
|
||||
) => {
|
||||
const id = typeof tenantId === "string" ? tenantId.trim() : "";
|
||||
const slug = typeof tenantSlug === "string" ? tenantSlug.trim() : "";
|
||||
const name = typeof tenantName === "string" ? tenantName.trim() : "";
|
||||
const key = (id || slug || name).toLowerCase();
|
||||
if (!key || primaryKeys.has(key) || seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
labels.push(name || slug || id);
|
||||
};
|
||||
|
||||
for (const tenant of user.joinedTenants ?? []) {
|
||||
addLabel(tenant.id, tenant.slug, tenant.name);
|
||||
}
|
||||
|
||||
const appointments = user.metadata?.additionalAppointments;
|
||||
if (Array.isArray(appointments)) {
|
||||
for (const appointment of appointments) {
|
||||
if (!appointment || typeof appointment !== "object") {
|
||||
continue;
|
||||
}
|
||||
const value = appointment as Record<string, unknown>;
|
||||
addLabel(
|
||||
value.tenantId,
|
||||
value.tenantSlug ?? value.slug,
|
||||
value.tenantName ?? value.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
|
||||
return {
|
||||
width: rect.width > 0 ? rect.width : fallbackWidth,
|
||||
@@ -204,12 +251,14 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<>
|
||||
<div className="relative w-48">
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="user-list-search"
|
||||
name="user-list-search"
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
"이름 또는 이메일 검색",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={localSearch}
|
||||
@@ -223,6 +272,8 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
</div>
|
||||
|
||||
<select
|
||||
id="user-list-tenant-filter"
|
||||
name="user-list-tenant-filter"
|
||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(event) => onCompanyChange(event.target.value)}
|
||||
@@ -416,7 +467,7 @@ function UserListPage() {
|
||||
name_email: (user) =>
|
||||
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
|
||||
tenant_dept: (user) =>
|
||||
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${user.department ?? ""}`,
|
||||
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${collectAdditionalTenantLabels(user).join(" ")} ${user.department ?? ""}`,
|
||||
},
|
||||
),
|
||||
[userSchema],
|
||||
@@ -636,6 +687,15 @@ function UserListPage() {
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="h-9 gap-2">
|
||||
<Link to="/users/custom-claims">
|
||||
<Key size={16} />
|
||||
{t(
|
||||
"ui.admin.users.global_custom_claims.title",
|
||||
"전역 Claim 설정",
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -727,6 +787,7 @@ function UserListPage() {
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-muted/50"
|
||||
>
|
||||
<input
|
||||
name={`user-list-column-${field.key}`}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
checked={visibleColumns[field.key] !== false}
|
||||
@@ -802,6 +863,7 @@ function UserListPage() {
|
||||
<TableHead className={`${userTableHeadClassName} w-12`}>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<input
|
||||
name="user-list-select-all"
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={
|
||||
@@ -957,6 +1019,8 @@ function UserListPage() {
|
||||
virtualRows.map((virtualRow) => {
|
||||
const user = items[virtualRow.index];
|
||||
if (!user) return null;
|
||||
const additionalTenantLabels =
|
||||
collectAdditionalTenantLabels(user);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -980,6 +1044,7 @@ function UserListPage() {
|
||||
>
|
||||
<TableCell>
|
||||
<input
|
||||
name={`user-list-select-${user.id}`}
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
@@ -998,7 +1063,7 @@ function UserListPage() {
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
|
||||
className="block max-w-[150px] truncate font-medium text-foreground transition-colors hover:text-primary hover:underline"
|
||||
title={user.name}
|
||||
>
|
||||
{user.name}
|
||||
@@ -1095,6 +1160,18 @@ function UserListPage() {
|
||||
{user.department}
|
||||
</span>
|
||||
)}
|
||||
{additionalTenantLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{additionalTenantLabels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="max-w-40 truncate rounded border bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
|
||||
@@ -191,6 +191,8 @@ export function UserBulkMoveGroupModal({
|
||||
{t("ui.admin.users.create.form.tenant", "테넌트 선택")}
|
||||
</label>
|
||||
<select
|
||||
id="bulk-move-target-tenant"
|
||||
name="bulk-move-target-tenant"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={selectedTenantSlug}
|
||||
onChange={(e) => {
|
||||
@@ -290,6 +292,8 @@ export function UserBulkMoveGroupModal({
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer mt-2 pt-2 border-t border-destructive/10">
|
||||
<input
|
||||
id="bulk-move-acknowledge-warning"
|
||||
name="bulk-move-acknowledge-warning"
|
||||
type="checkbox"
|
||||
checked={acknowledgeWarning}
|
||||
onChange={(e) => setAcknowledgeWarning(e.target.checked)}
|
||||
|
||||
@@ -420,6 +420,7 @@ export function UserBulkUploadModal({
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
<input
|
||||
name="user-bulk-upload-file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
@@ -482,6 +483,8 @@ export function UserBulkUploadModal({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
id={`user-bulk-tenant-match-${preview.row.rowNumber}`}
|
||||
name={`user-bulk-tenant-match-${preview.row.rowNumber}`}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={
|
||||
selectedTenantMatches[preview.row.rowNumber] ??
|
||||
@@ -512,6 +515,8 @@ export function UserBulkUploadModal({
|
||||
{(selectedTenantMatches[preview.row.rowNumber] ??
|
||||
"__create__") === "__create__" && (
|
||||
<input
|
||||
id={`user-bulk-tenant-create-slug-${preview.row.rowNumber}`}
|
||||
name={`user-bulk-tenant-create-slug-${preview.row.rowNumber}`}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
|
||||
value={
|
||||
selectedTenantCreateSlugs[
|
||||
@@ -552,6 +557,8 @@ export function UserBulkUploadModal({
|
||||
>
|
||||
<td className="p-2">
|
||||
<input
|
||||
id={`user-bulk-email-preview-${index}`}
|
||||
name={`user-bulk-email-preview-${index}`}
|
||||
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
|
||||
value={
|
||||
hanmacEmailPreviews[index]?.finalEmail ??
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("adminApi endpoint contracts", () => {
|
||||
await adminApi.fetchAdminOverviewStats();
|
||||
await adminApi.fetchDataIntegrityReport();
|
||||
await adminApi.fetchOrphanUserLoginIDs();
|
||||
await adminApi.fetchUserProjectionStatus();
|
||||
await adminApi.fetchOrySSOTSystemStatus();
|
||||
await adminApi.fetchAdminRPUsageDaily({
|
||||
days: 30,
|
||||
period: "week",
|
||||
@@ -90,6 +90,7 @@ describe("adminApi endpoint contracts", () => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", {
|
||||
params: { limit: 10, cursor: "cursor-a" },
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/ory/ssot");
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", {
|
||||
params: {
|
||||
limit: 25,
|
||||
@@ -133,8 +134,7 @@ describe("adminApi endpoint contracts", () => {
|
||||
const adminApi = await import("./adminApi");
|
||||
|
||||
await adminApi.deleteOrphanUserLoginIDs(["orphan-1"]);
|
||||
await adminApi.reconcileUserProjection();
|
||||
await adminApi.resetUserProjection();
|
||||
await adminApi.flushIdentityCache();
|
||||
await adminApi.createTenant({ name: "Tenant", slug: "tenant" });
|
||||
await adminApi.updateTenant("tenant-1", { status: "inactive" });
|
||||
await adminApi.deleteTenant("tenant-1");
|
||||
@@ -167,6 +167,7 @@ describe("adminApi endpoint contracts", () => {
|
||||
"tenant-1",
|
||||
"user-2",
|
||||
"credential-batch-1",
|
||||
"InputPass1!",
|
||||
);
|
||||
await adminApi.resetWorksmobileUserPassword(
|
||||
"tenant-1",
|
||||
@@ -199,7 +200,7 @@ describe("adminApi endpoint contracts", () => {
|
||||
{ data: { ids: ["orphan-1"] } },
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/projections/users/reconcile",
|
||||
"/v1/admin/ory/ssot/identity-cache/flush",
|
||||
);
|
||||
expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", {
|
||||
status: "active",
|
||||
@@ -209,7 +210,10 @@ describe("adminApi endpoint contracts", () => {
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/sync",
|
||||
{ credentialBatchId: "credential-batch-1" },
|
||||
{
|
||||
credentialBatchId: "credential-batch-1",
|
||||
initialPassword: "InputPass1!",
|
||||
},
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/password/reset",
|
||||
|
||||
@@ -31,7 +31,8 @@ export type TenantSummary = {
|
||||
domains?: string[];
|
||||
parentId?: string;
|
||||
config?: Record<string, unknown>;
|
||||
memberCount: number; // Added member count
|
||||
memberCount: number; // 해당 테넌트 직접 소속 인원
|
||||
totalMemberCount?: number; // 하위 테넌트 포함 전체 인원
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -155,9 +156,24 @@ export type UserProjectionStatus = {
|
||||
projectedUsers: number;
|
||||
};
|
||||
|
||||
export type UserProjectionActionResult = {
|
||||
export type IdentityCacheStatus = {
|
||||
status: string;
|
||||
syncedUsers: number;
|
||||
redisReady: boolean;
|
||||
observedCount: number;
|
||||
keyCount: number;
|
||||
lastRefreshedAt?: string;
|
||||
lastError?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type OrySSOTSystemStatus = {
|
||||
userProjection: UserProjectionStatus;
|
||||
identityCache: IdentityCacheStatus;
|
||||
};
|
||||
|
||||
export type IdentityCacheFlushResult = {
|
||||
status: string;
|
||||
flushedKeys: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -261,16 +277,15 @@ export async function fetchUserProjectionStatus() {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function reconcileUserProjection() {
|
||||
const { data } = await apiClient.post<UserProjectionActionResult>(
|
||||
"/v1/admin/projections/users/reconcile",
|
||||
);
|
||||
export async function fetchOrySSOTSystemStatus() {
|
||||
const { data } =
|
||||
await apiClient.get<OrySSOTSystemStatus>("/v1/admin/ory/ssot");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function resetUserProjection() {
|
||||
const { data } = await apiClient.post<UserProjectionActionResult>(
|
||||
"/v1/admin/projections/users/reset",
|
||||
export async function flushIdentityCache() {
|
||||
const { data } = await apiClient.post<IdentityCacheFlushResult>(
|
||||
"/v1/admin/ory/ssot/identity-cache/flush",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -716,6 +731,28 @@ export type UserUpdateRequest = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GlobalCustomClaimPermission = "admin_only" | "user_and_admin";
|
||||
|
||||
export type GlobalCustomClaimDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
valueType:
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "date"
|
||||
| "datetime";
|
||||
readPermission: GlobalCustomClaimPermission;
|
||||
writePermission: GlobalCustomClaimPermission;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type GlobalCustomClaimDefinitionsResponse = {
|
||||
items: GlobalCustomClaimDefinition[];
|
||||
};
|
||||
|
||||
export type UserAppointment = {
|
||||
tenantId: string;
|
||||
tenantSlug?: string;
|
||||
@@ -906,6 +943,23 @@ export async function fetchUser(userId: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchGlobalCustomClaimDefinitions() {
|
||||
const { data } = await apiClient.get<GlobalCustomClaimDefinitionsResponse>(
|
||||
"/v1/admin/global-custom-claims",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateGlobalCustomClaimDefinitions(
|
||||
payload: GlobalCustomClaimDefinitionsResponse,
|
||||
) {
|
||||
const { data } = await apiClient.put<GlobalCustomClaimDefinitionsResponse>(
|
||||
"/v1/admin/global-custom-claims",
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createUser(payload: UserCreateRequest) {
|
||||
const { data } = await apiClient.post<UserCreateResponse>(
|
||||
"/v1/admin/users",
|
||||
@@ -1040,14 +1094,21 @@ export async function enqueueWorksmobileUserSync(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
credentialBatchId?: string,
|
||||
initialPassword?: string,
|
||||
) {
|
||||
const trimmedBatchId = credentialBatchId?.trim();
|
||||
const trimmedInitialPassword = initialPassword?.trim();
|
||||
const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`;
|
||||
const { data } = trimmedBatchId
|
||||
? await apiClient.post<WorksmobileOutboxItem>(path, {
|
||||
credentialBatchId: trimmedBatchId,
|
||||
})
|
||||
: await apiClient.post<WorksmobileOutboxItem>(path);
|
||||
const body = {
|
||||
...(trimmedBatchId ? { credentialBatchId: trimmedBatchId } : {}),
|
||||
...(trimmedInitialPassword
|
||||
? { initialPassword: trimmedInitialPassword }
|
||||
: {}),
|
||||
};
|
||||
const { data } =
|
||||
Object.keys(body).length > 0
|
||||
? await apiClient.post<WorksmobileOutboxItem>(path, body)
|
||||
: await apiClient.post<WorksmobileOutboxItem>(path);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -63,6 +63,48 @@ describe("tenantTree utility", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses backend total member counts without double-counting children", () => {
|
||||
const tenantsWithTotals: TenantSummary[] = [
|
||||
{
|
||||
...mockTenants[0],
|
||||
memberCount: 10,
|
||||
totalMemberCount: 17,
|
||||
},
|
||||
{
|
||||
...mockTenants[1],
|
||||
memberCount: 5,
|
||||
totalMemberCount: 7,
|
||||
},
|
||||
{
|
||||
...mockTenants[2],
|
||||
memberCount: 2,
|
||||
totalMemberCount: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const { currentBase } = buildTenantFullTree(tenantsWithTotals, "root-1");
|
||||
|
||||
expect(currentBase?.recursiveMemberCount).toBe(17);
|
||||
expect(currentBase?.children[0]?.recursiveMemberCount).toBe(7);
|
||||
expect(currentBase?.children[0]?.children[0]?.recursiveMemberCount).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps total member counts when descendants are not loaded on the current page", () => {
|
||||
const { currentBase } = buildTenantFullTree(
|
||||
[
|
||||
{
|
||||
...mockTenants[0],
|
||||
memberCount: 10,
|
||||
totalMemberCount: 17,
|
||||
},
|
||||
],
|
||||
"root-1",
|
||||
);
|
||||
|
||||
expect(currentBase?.recursiveMemberCount).toBe(17);
|
||||
expect(currentBase?.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns null currentBase if rootId is not found", () => {
|
||||
const { currentBase } = buildTenantFullTree(mockTenants, "non-existent");
|
||||
expect(currentBase).toBeNull();
|
||||
|
||||
@@ -21,7 +21,7 @@ export function buildTenantFullTree(
|
||||
tenantMap.set(t.id, {
|
||||
...t,
|
||||
children: [],
|
||||
recursiveMemberCount: Number(t.memberCount) || 0,
|
||||
recursiveMemberCount: Number(t.totalMemberCount ?? t.memberCount) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ export function buildTenantFullTree(
|
||||
}
|
||||
visitedForCalc.add(node.id);
|
||||
|
||||
if (typeof node.totalMemberCount === "number") {
|
||||
node.recursiveMemberCount = Number(node.totalMemberCount) || 0;
|
||||
return node.recursiveMemberCount;
|
||||
}
|
||||
|
||||
let total = Number(node.memberCount) || 0;
|
||||
for (const child of node.children) {
|
||||
total += calculateRecursive(child);
|
||||
|
||||
@@ -178,15 +178,14 @@ description = "Checks whether user_login_ids.user_id points to a missing or soft
|
||||
[msg.admin.integrity.check.orphan_user_tenant_memberships]
|
||||
description = "Checks whether users.tenant_id points to a missing or soft-deleted tenant."
|
||||
|
||||
[msg.admin.user_projection]
|
||||
action_error = "Projection operation failed."
|
||||
action_success = "Refreshed the projection for {{count}} users."
|
||||
forbidden_description = "This screen is only available to super_admin users."
|
||||
load_error = "Failed to load projection status."
|
||||
reset_confirm = "Rebuild user projection from the Kratos source of truth?"
|
||||
subtitle = "Review and sync the Kratos user read model."
|
||||
[msg.admin.ory_ssot]
|
||||
flush_confirm = "Flush only Redis identity cache keys?"
|
||||
flush_error = "Redis identity cache flush failed."
|
||||
flush_success = "Flushed {{count}} Redis identity cache keys."
|
||||
load_error = "Failed to load Ory SSOT system status."
|
||||
subtitle = "Review Kratos source-of-truth and Redis identity cache status separately."
|
||||
|
||||
[msg.admin.user_projection.forbidden]
|
||||
[msg.admin.ory_ssot.forbidden]
|
||||
description = "This screen is only available to super_admin users."
|
||||
|
||||
[msg.admin.groups.prompt]
|
||||
@@ -348,6 +347,10 @@ not_found = "Not Found"
|
||||
update_error = "Failed to User Edit."
|
||||
update_success = "Update Success"
|
||||
|
||||
[msg.admin.users.detail.custom_claims]
|
||||
description = "Manage this user's values for globally defined custom claims. Add claim definitions and change types only from the global settings screen."
|
||||
empty = "No global custom claims have been defined."
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "Required."
|
||||
invalid_format = "Invalid format."
|
||||
@@ -890,6 +893,7 @@ loading = "Loading data integrity report..."
|
||||
title = "Data Integrity Check"
|
||||
fetch_error = "Unable to load the final integrity check result."
|
||||
subtitle = "Review integrity status and inspect checks across the admin data model."
|
||||
tab_ory_ssot = "Ory SSOT System"
|
||||
|
||||
[ui.admin.integrity.forbidden]
|
||||
title = "Access denied"
|
||||
@@ -970,33 +974,38 @@ relying_parties = "Apps (RP)"
|
||||
tenant_dashboard = "Tenant Dashboard"
|
||||
user_groups = "User Groups"
|
||||
tenants = "Tenants"
|
||||
user_projection = "User Projection"
|
||||
ory_ssot = "Ory SSOT System"
|
||||
users = "Users"
|
||||
|
||||
[ui.admin.user_projection]
|
||||
loading = "Loading user projection data..."
|
||||
subtitle = "Review and sync the Kratos user read model."
|
||||
title = "User Projection Management"
|
||||
[ui.admin.ory_ssot]
|
||||
loading = "Loading"
|
||||
title = "Ory SSOT System"
|
||||
|
||||
[ui.admin.user_projection.actions]
|
||||
reconcile = "Re-sync"
|
||||
reset = "Reset and rebuild"
|
||||
[ui.admin.ory_ssot.actions]
|
||||
flush_identity_cache = "Redis cache flush"
|
||||
|
||||
[ui.admin.user_projection.card]
|
||||
description = "Current user read model state referenced by backend DB statistics."
|
||||
title = "Kratos users projection"
|
||||
[ui.admin.ory_ssot.cache_card]
|
||||
description = "Redis mirror/cache status for Kratos identity list and lookup operations."
|
||||
title = "Redis identity cache"
|
||||
|
||||
[ui.admin.user_projection.forbidden]
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
title = "Access denied"
|
||||
|
||||
[ui.admin.user_projection.status]
|
||||
[ui.admin.ory_ssot.projection_card]
|
||||
description = "PostgreSQL read model status used by admin search and statistics."
|
||||
title = "Backend user read model"
|
||||
|
||||
[ui.admin.ory_ssot.status]
|
||||
failed = "failed"
|
||||
not_ready = "not ready"
|
||||
ready = "ready"
|
||||
|
||||
[ui.admin.user_projection.summary]
|
||||
last_synced = "Last synced"
|
||||
projected_users = "Projected users"
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = "Cache keys"
|
||||
last_refreshed = "Last refreshed"
|
||||
last_synced = "Last read-model refresh"
|
||||
local_users = "Local users"
|
||||
observed_identities = "Observed identities"
|
||||
status = "Status"
|
||||
updated_at = "Updated at"
|
||||
|
||||
@@ -1071,6 +1080,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 +1277,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"
|
||||
@@ -1345,6 +1366,10 @@ section = "Users"
|
||||
[ui.admin.users.detail.custom_fields]
|
||||
multi_title = "Per-tenant Profile Management"
|
||||
|
||||
[ui.admin.users.detail.custom_claims]
|
||||
save = "Save User Claim Values"
|
||||
title = "User Custom Claim Values"
|
||||
|
||||
[ui.admin.users.detail.form]
|
||||
department = "Department"
|
||||
department_placeholder = "Department Placeholder"
|
||||
@@ -1381,6 +1406,9 @@ additional = "Additional Affiliated/Manageable Tenants"
|
||||
primary = "Representative Affiliated Tenant"
|
||||
title = "Affiliation & Organization Info"
|
||||
|
||||
[ui.admin.users.global_custom_claims]
|
||||
manage_definitions = "Manage Global Definitions"
|
||||
|
||||
[ui.admin.users.list]
|
||||
add = "Add User"
|
||||
add_to_tenant = "Add to Tenant"
|
||||
@@ -1389,7 +1417,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 +1452,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 +1470,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 +1565,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)"
|
||||
|
||||
@@ -181,15 +181,14 @@ description = "users.tenant_id가 존재하지 않거나 soft-deleted tenant를
|
||||
[msg.admin.integrity]
|
||||
subtitle = "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다."
|
||||
|
||||
[msg.admin.user_projection]
|
||||
action_error = "사용자 동기화 작업에 실패했습니다."
|
||||
action_success = "{{count}}명 기준으로 사용자 동기화를 갱신했습니다."
|
||||
forbidden_description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
|
||||
load_error = "사용자 동기화 상태를 불러오지 못했습니다."
|
||||
reset_confirm = "사용자 동기화를 Kratos 기준으로 다시 구축하시겠습니까?"
|
||||
subtitle = "Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다."
|
||||
[msg.admin.ory_ssot]
|
||||
flush_confirm = "Redis identity cache 키만 비우시겠습니까?"
|
||||
flush_error = "Redis identity cache flush에 실패했습니다."
|
||||
flush_success = "Redis identity cache key {{count}}개를 비웠습니다."
|
||||
load_error = "Ory SSOT 시스템 상태를 불러오지 못했습니다."
|
||||
subtitle = "Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다."
|
||||
|
||||
[msg.admin.user_projection.forbidden]
|
||||
[msg.admin.ory_ssot.forbidden]
|
||||
description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
|
||||
|
||||
[msg.admin.groups.prompt]
|
||||
@@ -353,6 +352,10 @@ update_error = "사용자 수정에 실패했습니다."
|
||||
update_success = "사용자 정보가 수정되었습니다."
|
||||
self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
|
||||
|
||||
[msg.admin.users.detail.custom_claims]
|
||||
description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다."
|
||||
empty = "전역으로 정의된 custom claim이 없습니다."
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "필수입니다."
|
||||
invalid_format = "형식이 올바르지 않습니다."
|
||||
@@ -894,6 +897,7 @@ kicker = "시스템"
|
||||
loading = "불러오는 중"
|
||||
title = "데이터 정합성 검증"
|
||||
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
|
||||
tab_ory_ssot = "Ory SSOT 시스템"
|
||||
|
||||
[ui.admin.integrity.forbidden]
|
||||
title = "접근 권한이 없습니다"
|
||||
@@ -974,32 +978,38 @@ relying_parties = "애플리케이션(RP)"
|
||||
tenant_dashboard = "테넌트 대시보드"
|
||||
user_groups = "유저 그룹"
|
||||
tenants = "테넌트"
|
||||
user_projection = "사용자 동기화"
|
||||
ory_ssot = "Ory SSOT 시스템"
|
||||
users = "사용자"
|
||||
|
||||
[ui.admin.user_projection]
|
||||
[ui.admin.ory_ssot]
|
||||
loading = "불러오는 중"
|
||||
title = "사용자 동기화 관리"
|
||||
title = "Ory SSOT 시스템"
|
||||
|
||||
[ui.admin.user_projection.actions]
|
||||
reconcile = "재동기화"
|
||||
reset = "초기화 후 재구축"
|
||||
[ui.admin.ory_ssot.actions]
|
||||
flush_identity_cache = "Redis cache flush"
|
||||
|
||||
[ui.admin.user_projection.card]
|
||||
description = "Backend DB 통계가 참조하는 사용자 read model 상태입니다."
|
||||
title = "Kratos 사용자 동기화"
|
||||
[ui.admin.ory_ssot.cache_card]
|
||||
description = "Kratos identity 목록 및 조회 작업을 위한 Redis mirror/cache 상태입니다."
|
||||
title = "Redis identity cache"
|
||||
|
||||
[ui.admin.user_projection.forbidden]
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
title = "접근 권한이 없습니다"
|
||||
|
||||
[ui.admin.user_projection.status]
|
||||
[ui.admin.ory_ssot.projection_card]
|
||||
description = "관리자 검색과 통계에서 사용하는 PostgreSQL read model 상태입니다."
|
||||
title = "Backend 사용자 read model"
|
||||
|
||||
[ui.admin.ory_ssot.status]
|
||||
failed = "실패"
|
||||
not_ready = "준비되지 않음"
|
||||
ready = "준비됨"
|
||||
|
||||
[ui.admin.user_projection.summary]
|
||||
last_synced = "마지막 동기화"
|
||||
projected_users = "동기화 사용자"
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = "Cache keys"
|
||||
last_refreshed = "마지막 refresh"
|
||||
last_synced = "마지막 read-model refresh"
|
||||
local_users = "Local users"
|
||||
observed_identities = "관측 identity"
|
||||
status = "상태"
|
||||
updated_at = "상태 갱신"
|
||||
|
||||
@@ -1074,6 +1084,7 @@ user = "일반 사용자 (Tenant Member)"
|
||||
[ui.admin.tenants]
|
||||
add = "테넌트 추가"
|
||||
csv_template = "템플릿"
|
||||
data_mgmt = "데이터 관리"
|
||||
delete_selected = "선택 삭제"
|
||||
export_with_ids = "UUID 포함"
|
||||
export_without_ids = "UUID 제외 내보내기"
|
||||
@@ -1270,15 +1281,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 = "템플릿 다운로드"
|
||||
@@ -1348,6 +1370,10 @@ section = "Users"
|
||||
[ui.admin.users.detail.custom_fields]
|
||||
multi_title = "테넌트별 프로필 관리"
|
||||
|
||||
[ui.admin.users.detail.custom_claims]
|
||||
save = "사용자 Claim 값 저장"
|
||||
title = "사용자별 Custom Claim 값"
|
||||
|
||||
[ui.admin.users.detail.form]
|
||||
department = "부서"
|
||||
department_placeholder = "개발팀"
|
||||
@@ -1384,6 +1410,9 @@ additional = "추가 소속/관리 테넌트"
|
||||
primary = "대표 소속 테넌트"
|
||||
title = "소속 및 조직 정보"
|
||||
|
||||
[ui.admin.users.global_custom_claims]
|
||||
manage_definitions = "전역 정의 관리"
|
||||
|
||||
[ui.admin.users.list]
|
||||
add = "사용자 추가"
|
||||
add_to_tenant = "테넌트에 추가"
|
||||
@@ -1392,7 +1421,7 @@ change_status = "{{name}} 상태 변경"
|
||||
empty = "검색 결과가 없습니다."
|
||||
fetch_error = "사용자 목록 조회에 실패했습니다."
|
||||
search_label = "사용자 검색"
|
||||
search_placeholder = "이름 또는 이메일 검색..."
|
||||
search_placeholder = "이름 또는 이메일 검색"
|
||||
subtitle = "시스템 사용자를 조회하고 관리합니다."
|
||||
toggle_status = "{{name}} 활성 상태"
|
||||
title = "사용자 관리"
|
||||
@@ -1427,7 +1456,7 @@ remove_success = "조직에서 제외되었습니다."
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
search_label = "테넌트 검색"
|
||||
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
|
||||
search_placeholder = "이름 또는 슬러그, ID 검색"
|
||||
title = "테넌트 목록"
|
||||
|
||||
[ui.admin.users.list.breadcrumb]
|
||||
@@ -1445,12 +1474,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 +1569,10 @@ unknown_name = "Unknown User"
|
||||
logout = "Logout"
|
||||
profile = "내 정보"
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = "사이드바 접기"
|
||||
expand = "사이드바 펼치기"
|
||||
|
||||
[ui.shell.role]
|
||||
rp_admin = "서비스 관리자 (RP Admin)"
|
||||
super_admin = "시스템 관리자 (Super Admin)"
|
||||
|
||||
@@ -184,7 +184,7 @@ description = ""
|
||||
|
||||
[ui.admin.integrity]
|
||||
tab_checks = ""
|
||||
tab_user_projection = ""
|
||||
tab_ory_ssot = ""
|
||||
subtitle = ""
|
||||
|
||||
[ui.admin.tenants.profile]
|
||||
@@ -194,15 +194,14 @@ worksmobile_sync = ""
|
||||
allowed_domains = ""
|
||||
|
||||
|
||||
[msg.admin.user_projection]
|
||||
action_error = ""
|
||||
action_success = ""
|
||||
forbidden_description = ""
|
||||
[msg.admin.ory_ssot]
|
||||
flush_confirm = ""
|
||||
flush_error = ""
|
||||
flush_success = ""
|
||||
load_error = ""
|
||||
reset_confirm = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.user_projection.forbidden]
|
||||
[msg.admin.ory_ssot.forbidden]
|
||||
description = ""
|
||||
|
||||
[msg.admin.groups.prompt]
|
||||
@@ -988,32 +987,38 @@ relying_parties = ""
|
||||
tenant_dashboard = ""
|
||||
user_groups = ""
|
||||
tenants = ""
|
||||
user_projection = ""
|
||||
ory_ssot = ""
|
||||
users = ""
|
||||
|
||||
[ui.admin.user_projection]
|
||||
[ui.admin.ory_ssot]
|
||||
loading = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.user_projection.actions]
|
||||
reconcile = ""
|
||||
reset = ""
|
||||
[ui.admin.ory_ssot.actions]
|
||||
flush_identity_cache = ""
|
||||
|
||||
[ui.admin.user_projection.card]
|
||||
[ui.admin.ory_ssot.cache_card]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.user_projection.forbidden]
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
title = ""
|
||||
|
||||
[ui.admin.user_projection.status]
|
||||
[ui.admin.ory_ssot.projection_card]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.ory_ssot.status]
|
||||
failed = ""
|
||||
not_ready = ""
|
||||
ready = ""
|
||||
|
||||
[ui.admin.user_projection.summary]
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = ""
|
||||
last_refreshed = ""
|
||||
last_synced = ""
|
||||
projected_users = ""
|
||||
local_users = ""
|
||||
observed_identities = ""
|
||||
status = ""
|
||||
updated_at = ""
|
||||
|
||||
@@ -1291,6 +1296,8 @@ slug = ""
|
||||
status = ""
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
members_count = ""
|
||||
members_recursive = ""
|
||||
actions = ""
|
||||
id = ""
|
||||
members = ""
|
||||
@@ -1426,11 +1433,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 +1526,10 @@ unknown_name = ""
|
||||
logout = ""
|
||||
profile = ""
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = ""
|
||||
expand = ""
|
||||
|
||||
[ui.shell.role]
|
||||
rp_admin = ""
|
||||
super_admin = ""
|
||||
|
||||
43
adminfront/src/test/formFieldDiagnostics.test.ts
Normal file
43
adminfront/src/test/formFieldDiagnostics.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const formFieldTagPattern = /<(input|select|textarea)\b[\s\S]*?(?:>|\/>)/g;
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).flatMap((entry) => {
|
||||
const path = join(dir, entry);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) return sourceFiles(path);
|
||||
if (!/\.(tsx|jsx)$/.test(entry)) return [];
|
||||
if (/\.(test|spec)\./.test(entry)) return [];
|
||||
return [path];
|
||||
});
|
||||
}
|
||||
|
||||
function lineNumber(source: string, index: number) {
|
||||
return source.slice(0, index).split("\n").length;
|
||||
}
|
||||
|
||||
describe("adminfront form field diagnostics", () => {
|
||||
it("keeps raw rendered form fields identifiable for browser autofill diagnostics", () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const file of sourceFiles("src")) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (
|
||||
let match = formFieldTagPattern.exec(source);
|
||||
match !== null;
|
||||
match = formFieldTagPattern.exec(source)
|
||||
) {
|
||||
const tag = match[0];
|
||||
if (/\b(id|name)\s*=/.test(tag)) continue;
|
||||
if (/\{\.\.\s*[^}]+\}/.test(tag)) continue;
|
||||
offenders.push(`${file}:${lineNumber(source, match.index)}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
27
adminfront/src/test/formFieldDiagnostics.ts
Normal file
27
adminfront/src/test/formFieldDiagnostics.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
export function anonymousFormFields(container: ParentNode) {
|
||||
return Array.from(
|
||||
container.querySelectorAll("input, select, textarea"),
|
||||
).filter(
|
||||
(field) =>
|
||||
!field.getAttribute("id")?.trim() && !field.getAttribute("name")?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function expectNoAnonymousFormFields(container: ParentNode) {
|
||||
const fields = anonymousFormFields(container);
|
||||
const diagnostics = fields.map((field) => {
|
||||
const tag = field.tagName.toLowerCase();
|
||||
const type = field.getAttribute("type");
|
||||
const label =
|
||||
field.getAttribute("aria-label") ||
|
||||
field.getAttribute("placeholder") ||
|
||||
field.getAttribute("data-testid") ||
|
||||
field.textContent ||
|
||||
"";
|
||||
return `${tag}${type ? `[type=${type}]` : ""}${label ? `: ${label}` : ""}`;
|
||||
});
|
||||
|
||||
expect(fields, diagnostics.join("\n")).toHaveLength(0);
|
||||
}
|
||||
@@ -44,23 +44,39 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
||||
"ui.admin.integrity.orphan_login_ids.title": "유령 로그인 ID 정리",
|
||||
"ui.admin.integrity.forbidden.title": "접근 권한이 없습니다",
|
||||
"ui.admin.integrity.summary.title": "정합성 최종 검증",
|
||||
"ui.admin.user_projection.actions.reconcile": "재동기화",
|
||||
"ui.admin.user_projection.actions.reset": "초기화 후 재구축",
|
||||
"ui.admin.user_projection.card.description":
|
||||
"Backend DB 통계가 참조하는 사용자 read model 상태입니다.",
|
||||
"ui.admin.user_projection.card.title": "Kratos 사용자 동기화",
|
||||
"ui.admin.user_projection.forbidden.title": "접근 권한이 없습니다",
|
||||
"ui.admin.user_projection.loading": "불러오는 중",
|
||||
"ui.admin.user_projection.status.failed": "실패",
|
||||
"ui.admin.user_projection.status.not_ready": "준비되지 않음",
|
||||
"ui.admin.user_projection.status.ready": "준비됨",
|
||||
"ui.admin.user_projection.summary.last_synced": "마지막 동기화",
|
||||
"ui.admin.user_projection.summary.projected_users": "동기화 사용자",
|
||||
"ui.admin.user_projection.summary.status": "상태",
|
||||
"ui.admin.user_projection.summary.updated_at": "상태 갱신",
|
||||
"ui.admin.user_projection.title": "사용자 동기화 관리",
|
||||
"msg.admin.user_projection.subtitle":
|
||||
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
|
||||
"ui.admin.integrity.tab_ory_ssot": "Ory SSOT 시스템",
|
||||
"ui.admin.ory_ssot.actions.flush_identity_cache": "Redis cache flush",
|
||||
"ui.admin.ory_ssot.cache_card.description":
|
||||
"Kratos identity 목록 및 조회 작업을 위한 Redis mirror/cache 상태입니다.",
|
||||
"ui.admin.ory_ssot.cache_card.title": "Redis identity cache",
|
||||
"ui.admin.ory_ssot.forbidden.title": "접근 권한이 없습니다",
|
||||
"ui.admin.ory_ssot.loading": "불러오는 중",
|
||||
"ui.admin.ory_ssot.projection_card.description":
|
||||
"관리자 검색과 통계에서 사용하는 PostgreSQL read model 상태입니다.",
|
||||
"ui.admin.ory_ssot.projection_card.title": "Backend 사용자 read model",
|
||||
"ui.admin.ory_ssot.status.failed": "실패",
|
||||
"ui.admin.ory_ssot.status.not_ready": "준비되지 않음",
|
||||
"ui.admin.ory_ssot.status.ready": "준비됨",
|
||||
"ui.admin.ory_ssot.summary.cache_keys": "Cache keys",
|
||||
"ui.admin.ory_ssot.summary.last_refreshed": "마지막 refresh",
|
||||
"ui.admin.ory_ssot.summary.last_synced": "마지막 read-model refresh",
|
||||
"ui.admin.ory_ssot.summary.local_users": "Local users",
|
||||
"ui.admin.ory_ssot.summary.observed_identities": "관측 identity",
|
||||
"ui.admin.ory_ssot.summary.status": "상태",
|
||||
"ui.admin.ory_ssot.summary.updated_at": "상태 갱신",
|
||||
"ui.admin.ory_ssot.title": "Ory SSOT 시스템",
|
||||
"msg.admin.ory_ssot.flush_confirm":
|
||||
"Redis identity cache 키만 비우시겠습니까?",
|
||||
"msg.admin.ory_ssot.flush_error":
|
||||
"Redis identity cache flush에 실패했습니다.",
|
||||
"msg.admin.ory_ssot.flush_success":
|
||||
"Redis identity cache key {{count}}개를 비웠습니다.",
|
||||
"msg.admin.ory_ssot.forbidden.description":
|
||||
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
|
||||
"msg.admin.ory_ssot.load_error":
|
||||
"Ory SSOT 시스템 상태를 불러오지 못했습니다.",
|
||||
"msg.admin.ory_ssot.subtitle":
|
||||
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
|
||||
"msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.",
|
||||
"msg.admin.users.list.registry.count":
|
||||
"총 {{count}}명의 사용자가 등록되어 있습니다.",
|
||||
@@ -76,8 +92,6 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
||||
"users.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.",
|
||||
"msg.admin.integrity.recheck.running": "정합성 검사를 실행 중입니다.",
|
||||
"msg.admin.integrity.recheck.success": "검사가 완료되었습니다.",
|
||||
"msg.admin.user_projection.forbidden.description":
|
||||
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
|
||||
},
|
||||
en: {
|
||||
"ui.admin.auth_guard.title": "Auth Guard",
|
||||
@@ -123,23 +137,36 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
||||
"ui.admin.integrity.orphan_login_ids.title": "Orphan Login ID Cleanup",
|
||||
"ui.admin.integrity.forbidden.title": "Access denied",
|
||||
"ui.admin.integrity.summary.title": "Final integrity check",
|
||||
"ui.admin.user_projection.actions.reconcile": "Re-sync",
|
||||
"ui.admin.user_projection.actions.reset": "Reset and rebuild",
|
||||
"ui.admin.user_projection.card.description":
|
||||
"Current user read model state referenced by backend DB statistics.",
|
||||
"ui.admin.user_projection.card.title": "Kratos users projection",
|
||||
"ui.admin.user_projection.forbidden.title": "Access denied",
|
||||
"ui.admin.user_projection.loading": "Loading",
|
||||
"ui.admin.user_projection.status.failed": "failed",
|
||||
"ui.admin.user_projection.status.not_ready": "not ready",
|
||||
"ui.admin.user_projection.status.ready": "ready",
|
||||
"ui.admin.user_projection.summary.last_synced": "Last synced",
|
||||
"ui.admin.user_projection.summary.projected_users": "Projected users",
|
||||
"ui.admin.user_projection.summary.status": "Status",
|
||||
"ui.admin.user_projection.summary.updated_at": "Updated at",
|
||||
"ui.admin.user_projection.title": "User Projection Management",
|
||||
"msg.admin.user_projection.subtitle":
|
||||
"Review and sync the Kratos user read model.",
|
||||
"ui.admin.integrity.tab_ory_ssot": "Ory SSOT System",
|
||||
"ui.admin.ory_ssot.actions.flush_identity_cache": "Redis cache flush",
|
||||
"ui.admin.ory_ssot.cache_card.description":
|
||||
"Redis mirror/cache status for Kratos identity list and lookup operations.",
|
||||
"ui.admin.ory_ssot.cache_card.title": "Redis identity cache",
|
||||
"ui.admin.ory_ssot.forbidden.title": "Access denied",
|
||||
"ui.admin.ory_ssot.loading": "Loading",
|
||||
"ui.admin.ory_ssot.projection_card.description":
|
||||
"PostgreSQL read model status used by admin search and statistics.",
|
||||
"ui.admin.ory_ssot.projection_card.title": "Backend user read model",
|
||||
"ui.admin.ory_ssot.status.failed": "failed",
|
||||
"ui.admin.ory_ssot.status.not_ready": "not ready",
|
||||
"ui.admin.ory_ssot.status.ready": "ready",
|
||||
"ui.admin.ory_ssot.summary.cache_keys": "Cache keys",
|
||||
"ui.admin.ory_ssot.summary.last_refreshed": "Last refreshed",
|
||||
"ui.admin.ory_ssot.summary.last_synced": "Last read-model refresh",
|
||||
"ui.admin.ory_ssot.summary.local_users": "Local users",
|
||||
"ui.admin.ory_ssot.summary.observed_identities": "Observed identities",
|
||||
"ui.admin.ory_ssot.summary.status": "Status",
|
||||
"ui.admin.ory_ssot.summary.updated_at": "Updated at",
|
||||
"ui.admin.ory_ssot.title": "Ory SSOT System",
|
||||
"msg.admin.ory_ssot.flush_confirm": "Flush only Redis identity cache keys?",
|
||||
"msg.admin.ory_ssot.flush_error": "Redis identity cache flush failed.",
|
||||
"msg.admin.ory_ssot.flush_success":
|
||||
"Flushed {{count}} Redis identity cache keys.",
|
||||
"msg.admin.ory_ssot.forbidden.description":
|
||||
"This screen is only available to super_admin users.",
|
||||
"msg.admin.ory_ssot.load_error": "Failed to load Ory SSOT system status.",
|
||||
"msg.admin.ory_ssot.subtitle":
|
||||
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
||||
"msg.admin.users.list.subtitle":
|
||||
"Search and manage users registered in the current tenant.",
|
||||
"msg.admin.users.list.registry.count": "{{count}} users loaded.",
|
||||
@@ -155,8 +182,6 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
||||
"Checks whether users.tenant_id points to a missing or soft-deleted tenant.",
|
||||
"msg.admin.integrity.recheck.running": "Running integrity check.",
|
||||
"msg.admin.integrity.recheck.success": "Check completed.",
|
||||
"msg.admin.user_projection.forbidden.description":
|
||||
"This screen is only available to super_admin users.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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$/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {},
|
||||
) => {
|
||||
// 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: [],
|
||||
@@ -152,9 +179,7 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
||||
await expect(page.locator('a[href="/tenants"]')).toBeVisible();
|
||||
await expect(page.locator('a[href="/api-keys"]')).toBeVisible();
|
||||
await expect(page.locator('a[href="/audit-logs"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('a[href="/system/projections/users"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('a[href="/system/ory-ssot"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('a[href="/system/data-integrity"]'),
|
||||
).toBeVisible();
|
||||
@@ -182,7 +207,7 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
||||
await expect(page.locator('a[href="/tenants"]')).not.toBeVisible();
|
||||
await expect(page.locator('a[href="/api-keys"]')).not.toBeVisible();
|
||||
await expect(
|
||||
page.locator('a[href="/system/projections/users"]'),
|
||||
page.locator('a[href="/system/ory-ssot"]'),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
page.locator('a[href="/system/data-integrity"]'),
|
||||
@@ -218,4 +243,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
@@ -119,6 +121,106 @@ test.describe("Tenants Management", () => {
|
||||
expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true);
|
||||
});
|
||||
|
||||
test("should export currently selected organization users by tenant slug", async ({
|
||||
page,
|
||||
}) => {
|
||||
let exportUrl = "";
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.continue();
|
||||
}
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.endsWith("/admin/tenants/tenant-company")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "tenant-company",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
type: "COMPANY",
|
||||
status: "active",
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "tenant-company",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
type: "COMPANY",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
recursiveMemberCount: 1,
|
||||
},
|
||||
{
|
||||
id: "tenant-team",
|
||||
parentId: "tenant-company",
|
||||
name: "기술연구팀",
|
||||
slug: "gpdtdc-rnd",
|
||||
type: "ORGANIZATION",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
recursiveMemberCount: 1,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
expect(url.searchParams.get("tenantSlug")).toBe("gpdtdc");
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Member User",
|
||||
email: "member@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "gpdtdc",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/admin\/users\/export(\?.*)?$/, async (route) => {
|
||||
exportUrl = route.request().url();
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/csv; charset=utf-8",
|
||||
"content-disposition": 'attachment; filename="tenant-users.csv"',
|
||||
"access-control-expose-headers": "content-disposition",
|
||||
},
|
||||
body: "email,name\nmember@example.com,Member User\n",
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/tenants/tenant-company/organization");
|
||||
await expect(page.getByText("Member User")).toBeVisible();
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByTestId("tenant-current-users-export-btn").click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename()).toBe("tenant-users.csv");
|
||||
expect(exportUrl).toContain("tenantSlug=gpdtdc");
|
||||
expect(exportUrl).toContain("includeIds=false");
|
||||
});
|
||||
|
||||
test("searches tenant ids in the tree view and selects descendants", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -139,7 +241,8 @@ test.describe("Tenants Management", () => {
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
memberCount: 3,
|
||||
totalMemberCount: 9,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
@@ -149,7 +252,8 @@ test.describe("Tenants Management", () => {
|
||||
status: "active",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "company-1",
|
||||
memberCount: 0,
|
||||
memberCount: 4,
|
||||
totalMemberCount: 6,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
@@ -159,19 +263,31 @@ test.describe("Tenants Management", () => {
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
parentId: "dept-1",
|
||||
memberCount: 0,
|
||||
memberCount: 2,
|
||||
totalMemberCount: 2,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
filtered = items.filter(
|
||||
const directMatches = items.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
const ids = new Set(directMatches.map((item) => item.id));
|
||||
for (const match of directMatches) {
|
||||
let parentId = match.parentId;
|
||||
while (parentId) {
|
||||
const parent = items.find((item) => item.id === parentId);
|
||||
if (!parent) break;
|
||||
ids.add(parent.id);
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
filtered = items.filter((item) => ids.has(item.id));
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
@@ -188,16 +304,21 @@ 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("Acme");
|
||||
await expect(page.locator("table")).toContainText("Planning");
|
||||
await expect(page.locator("table")).toContainText("Platform");
|
||||
await expect(page.getByTestId("tenant-search-match-team-1")).toBeVisible();
|
||||
await expect(page.getByTestId("tenant-search-match-company-1")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByTestId("tenant-search-match-dept-1")).toHaveCount(0);
|
||||
|
||||
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();
|
||||
|
||||
@@ -226,7 +347,8 @@ test.describe("Tenants Management", () => {
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
memberCount: 3,
|
||||
totalMemberCount: 9,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
@@ -236,7 +358,8 @@ test.describe("Tenants Management", () => {
|
||||
status: "active",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "company-1",
|
||||
memberCount: 0,
|
||||
memberCount: 4,
|
||||
totalMemberCount: 6,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
@@ -246,7 +369,8 @@ test.describe("Tenants Management", () => {
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
parentId: "dept-1",
|
||||
memberCount: 0,
|
||||
memberCount: 2,
|
||||
totalMemberCount: 2,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
@@ -280,6 +404,11 @@ test.describe("Tenants Management", () => {
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("tenant-internal-id-company-1")
|
||||
.locator("xpath=ancestor::tr"),
|
||||
).toContainText("9명");
|
||||
|
||||
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1");
|
||||
await page.keyboard.press("Enter");
|
||||
@@ -291,8 +420,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 +492,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,
|
||||
@@ -743,16 +872,18 @@ test.describe("Tenants Management", () => {
|
||||
let exportUrl = "";
|
||||
let importRequested = false;
|
||||
let importBody = "";
|
||||
const openDataManagementMenu = async () => {
|
||||
const openDataManagementMenu = async (
|
||||
expectedTestId = "tenant-export-menu-item",
|
||||
) => {
|
||||
const btn = page.getByTestId("tenant-data-mgmt-btn");
|
||||
const exportMenuItem = page.getByTestId("tenant-export-menu-item");
|
||||
const expectedMenuItem = page.getByTestId(expectedTestId);
|
||||
|
||||
// Attempt to open the menu with a retry loop using toPass
|
||||
await expect(async () => {
|
||||
if (!(await exportMenuItem.isVisible())) {
|
||||
if (!(await expectedMenuItem.isVisible())) {
|
||||
await btn.click({ force: true });
|
||||
}
|
||||
await expect(exportMenuItem).toBeVisible({ timeout: 2000 });
|
||||
await expect(expectedMenuItem).toBeVisible({ timeout: 2000 });
|
||||
}).toPass({
|
||||
intervals: [1000, 2000],
|
||||
timeout: 10000,
|
||||
@@ -847,7 +978,7 @@ test.describe("Tenants Management", () => {
|
||||
|
||||
await expect(page.getByText(/조직\/사용자 통합/)).toHaveCount(0);
|
||||
|
||||
await openDataManagementMenu();
|
||||
await openDataManagementMenu("tenant-export-menu-item");
|
||||
await expect(page.getByTestId("tenant-template-menu-item")).toBeVisible();
|
||||
await expect(page.getByTestId("tenant-import-menu-item")).toBeVisible();
|
||||
|
||||
@@ -872,14 +1003,14 @@ test.describe("Tenants Management", () => {
|
||||
expect(exportDownload.suggestedFilename()).toBe("tenants.csv");
|
||||
expect(exportUrl).toContain("includeIds=false");
|
||||
|
||||
await openDataManagementMenu();
|
||||
await openDataManagementMenu("tenant-export-with-ids-menu-item");
|
||||
await expect(
|
||||
page.getByTestId("tenant-export-with-ids-menu-item"),
|
||||
).toBeVisible();
|
||||
await safeDownload("tenant-export-with-ids-menu-item");
|
||||
expect(exportUrl).toContain("includeIds=true");
|
||||
|
||||
await openDataManagementMenu();
|
||||
await openDataManagementMenu("tenant-template-menu-item");
|
||||
const template = await safeDownload("tenant-template-menu-item");
|
||||
expect(template.suggestedFilename()).toBe("tenant-import-template.csv");
|
||||
|
||||
|
||||
@@ -315,6 +315,201 @@ test.describe("User Management", () => {
|
||||
await expect(page.getByText(/저장/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should manage global custom claim values in user detail", async ({
|
||||
page,
|
||||
}) => {
|
||||
let updatePayload: Record<string, unknown> | undefined;
|
||||
|
||||
await page.route(/\/admin\/global-custom-claims$/, async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
key: "contract_date",
|
||||
label: "계약일",
|
||||
valueType: "date",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/admin\/users\/u-1$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
loginId: "johndoe",
|
||||
tenantSlug: "test-tenant",
|
||||
tenant: { id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
||||
role: "user",
|
||||
status: "active",
|
||||
metadata: {
|
||||
"t-1": { loginId: "johndoe" },
|
||||
global_custom_claims: {
|
||||
contract_date: "2026-06-09",
|
||||
},
|
||||
global_custom_claim_types: {
|
||||
contract_date: "date",
|
||||
},
|
||||
global_custom_claim_permissions: {
|
||||
contract_date: {
|
||||
readPermission: "user_and_admin",
|
||||
writePermission: "admin_only",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "PUT") {
|
||||
updatePayload = route.request().postDataJSON();
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
loginId: "johndoe",
|
||||
status: "active",
|
||||
metadata: updatePayload?.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/users/u-1");
|
||||
await page
|
||||
.getByRole("tab", { name: /전역 Custom Claims|Custom Claims/i })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText("contract_date")).toBeVisible();
|
||||
const valueInput = page.getByTestId(
|
||||
"global-custom-claim-value-contract_date",
|
||||
);
|
||||
await expect(valueInput).toHaveValue("2026-06-09");
|
||||
await expect(valueInput).toHaveAttribute("type", "date");
|
||||
|
||||
await valueInput.fill("2026-07-01");
|
||||
|
||||
await page.screenshot({
|
||||
path: "test-results/adminfront-global-custom-claim-permissions.png",
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /사용자 Claim 값 저장/i })
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() => updatePayload)
|
||||
.toMatchObject({
|
||||
metadata: {
|
||||
global_custom_claims: {
|
||||
contract_date: "2026-07-01",
|
||||
},
|
||||
global_custom_claim_types: {
|
||||
contract_date: "date",
|
||||
},
|
||||
global_custom_claim_permissions: {
|
||||
contract_date: {
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should configure global custom claim definitions", async ({ page }) => {
|
||||
let updatePayload:
|
||||
| {
|
||||
items?: Array<Record<string, unknown>>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
await page.route(/\/admin\/global-custom-claims$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
key: "contract_date",
|
||||
label: "Contract date",
|
||||
valueType: "date",
|
||||
readPermission: "user_and_admin",
|
||||
writePermission: "admin_only",
|
||||
description: "전체 RP에 공통 제공되는 계약일",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "PUT") {
|
||||
updatePayload = route.request().postDataJSON();
|
||||
return route.fulfill({ json: updatePayload });
|
||||
}
|
||||
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/users/custom-claims");
|
||||
|
||||
await expect(page.getByText("전역 Claim 설정")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("global-claim-definition-key-contract_date"),
|
||||
).toHaveValue("contract_date");
|
||||
await expect(
|
||||
page.getByTestId("global-claim-definition-read-permission-contract_date"),
|
||||
).toHaveValue("user_and_admin");
|
||||
await expect(
|
||||
page.getByTestId(
|
||||
"global-claim-definition-write-permission-contract_date",
|
||||
),
|
||||
).toHaveValue("admin_only");
|
||||
|
||||
await page
|
||||
.getByTestId("global-claim-definition-write-permission-contract_date")
|
||||
.selectOption("user_and_admin");
|
||||
|
||||
await page.screenshot({
|
||||
path: "test-results/adminfront-global-custom-claim-definition-settings.png",
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => updatePayload)
|
||||
.toMatchObject({
|
||||
items: [
|
||||
{
|
||||
key: "contract_date",
|
||||
label: "Contract date",
|
||||
valueType: "date",
|
||||
readPermission: "user_and_admin",
|
||||
writePermission: "user_and_admin",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("should show conflict error when updating to an existing Login ID", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -602,11 +797,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) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Worksmobile tenant management", () => {
|
||||
@@ -32,7 +31,10 @@ test.describe("Worksmobile tenant management", () => {
|
||||
page,
|
||||
}) => {
|
||||
const comparisonRequests: boolean[] = [];
|
||||
const syncRequests: string[] = [];
|
||||
const syncRequests: Array<{
|
||||
userId: string;
|
||||
body: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
@@ -218,7 +220,13 @@ test.describe("Worksmobile tenant management", () => {
|
||||
isWorksmobileTenantPath("/worksmobile/users/user-missing/sync") &&
|
||||
method === "POST"
|
||||
) {
|
||||
syncRequests.push("user-missing");
|
||||
syncRequests.push({
|
||||
userId: "user-missing",
|
||||
body: JSON.parse(route.request().postData() ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
return route.fulfill({
|
||||
json: { id: "job-user-missing", resourceId: "user-missing" },
|
||||
headers,
|
||||
@@ -235,7 +243,8 @@ test.describe("Worksmobile tenant management", () => {
|
||||
await expect(page.getByRole("tab", { name: "조직" })).toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: "이력" }).click();
|
||||
await expect(page.getByText("비밀번호 파일 히스토리")).toBeVisible();
|
||||
await expect(page.getByText("비밀번호 파일 히스토리")).not.toBeVisible();
|
||||
await expect(page.getByText("최근 작업")).toBeVisible();
|
||||
await expect(page.getByText("domainMappings")).not.toBeVisible();
|
||||
await expect(page.getByText("SCIM token")).not.toBeVisible();
|
||||
await page.getByRole("tab", { name: "사용자" }).click();
|
||||
@@ -246,6 +255,9 @@ test.describe("Worksmobile tenant management", () => {
|
||||
"worksmobile-구성원-virtual-body",
|
||||
);
|
||||
await expect(userComparisonTable).toBeVisible();
|
||||
await expect(page.getByTestId("worksmobile-구성원-row-count")).toHaveText(
|
||||
"표시 2 / 전체 5",
|
||||
);
|
||||
await expect(userSyncCard).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
@@ -347,7 +359,17 @@ test.describe("Worksmobile tenant management", () => {
|
||||
await page
|
||||
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
|
||||
.click();
|
||||
await expect.poll(() => syncRequests).toEqual(["user-missing"]);
|
||||
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
|
||||
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
|
||||
await page.getByRole("button", { name: "생성 작업 등록" }).click();
|
||||
await expect
|
||||
.poll(() => syncRequests)
|
||||
.toEqual([
|
||||
{
|
||||
userId: "user-missing",
|
||||
body: expect.objectContaining({ initialPassword: "InitPass123!" }),
|
||||
},
|
||||
]);
|
||||
|
||||
await page.getByRole("tab", { name: "조직" }).click();
|
||||
await expect(page.getByText("조직 단건 동기화")).toBeVisible();
|
||||
@@ -357,6 +379,9 @@ test.describe("Worksmobile tenant management", () => {
|
||||
"worksmobile-조직/그룹-virtual-body",
|
||||
);
|
||||
await expect(groupComparisonTable).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("worksmobile-조직/그룹-row-count"),
|
||||
).toHaveText("표시 2 / 전체 2");
|
||||
await expect(groupSyncCard).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
@@ -381,6 +406,232 @@ test.describe("Worksmobile tenant management", () => {
|
||||
await expect(page.getByText("works-parent-tech")).toBeVisible();
|
||||
});
|
||||
|
||||
test("separates selected user create and update actions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const syncRequests: Array<{
|
||||
userId: string;
|
||||
body: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const method = route.request().method();
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
const isWorksmobileTenantPath = (suffix: string) =>
|
||||
url.pathname.endsWith(`/admin/tenants/hanmac-family-id${suffix}`) ||
|
||||
url.pathname.endsWith(
|
||||
`/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a${suffix}`,
|
||||
);
|
||||
|
||||
if (url.pathname.endsWith("/user/me")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin",
|
||||
role: "super_admin",
|
||||
manageableTenants: [
|
||||
{
|
||||
id: "038326b6-954a-48a7-a85f-efd83f62b82a",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
type: "COMPANY_GROUP",
|
||||
},
|
||||
],
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.endsWith("/admin/tenants/hanmac-family-id") &&
|
||||
method === "GET"
|
||||
) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "hanmac-family-id",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
type: "COMPANY_GROUP",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (isWorksmobileTenantPath("/worksmobile") && method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
tenant: {
|
||||
id: "hanmac-family-id",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
type: "COMPANY_GROUP",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-04T00:00:00Z",
|
||||
updatedAt: "2026-05-04T00:00:00Z",
|
||||
},
|
||||
config: {
|
||||
enabled: true,
|
||||
tokenConfigured: true,
|
||||
},
|
||||
recentJobs: [],
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isWorksmobileTenantPath("/worksmobile/credential-batches") &&
|
||||
method === "GET"
|
||||
) {
|
||||
return route.fulfill({ json: [], headers });
|
||||
}
|
||||
|
||||
if (
|
||||
isWorksmobileTenantPath("/worksmobile/comparison") &&
|
||||
method === "GET"
|
||||
) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
users: [
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-missing",
|
||||
baronName: "김생성",
|
||||
status: "missing_in_worksmobile",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-update",
|
||||
baronName: "이업데이트",
|
||||
baronEmail: "domain@typo.example.com",
|
||||
worksmobileId: "works-user-update",
|
||||
externalKey: "user-update",
|
||||
worksmobileName: "이업데이트",
|
||||
worksmobileEmail: "domain@example.com",
|
||||
status: "needs_update",
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isWorksmobileTenantPath("/worksmobile/users/user-missing/sync") &&
|
||||
method === "POST"
|
||||
) {
|
||||
syncRequests.push({
|
||||
userId: "user-missing",
|
||||
body: JSON.parse(route.request().postData() ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
return route.fulfill({
|
||||
json: { id: "job-user-missing", resourceId: "user-missing" },
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isWorksmobileTenantPath("/worksmobile/users/user-update/sync") &&
|
||||
method === "POST"
|
||||
) {
|
||||
syncRequests.push({
|
||||
userId: "user-update",
|
||||
body: JSON.parse(route.request().postData() ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
return route.fulfill({
|
||||
json: { id: "job-user-update", resourceId: "user-update" },
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||
});
|
||||
|
||||
await page.goto("/worksmobile");
|
||||
await page.getByRole("tab", { name: "사용자" }).click();
|
||||
|
||||
const userComparisonSection = page
|
||||
.getByRole("heading", { name: "구성원" })
|
||||
.locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]");
|
||||
await expect(userComparisonSection.getByText("김생성")).toBeVisible();
|
||||
await expect(userComparisonSection.getByText("이업데이트")).toHaveCount(2);
|
||||
|
||||
const statusHeader = userComparisonSection
|
||||
.locator("thead th")
|
||||
.filter({ hasText: "상태" })
|
||||
.locator("div")
|
||||
.first();
|
||||
await expect
|
||||
.poll(() =>
|
||||
statusHeader.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return { alignItems: style.alignItems, display: style.display };
|
||||
}),
|
||||
)
|
||||
.toEqual({ alignItems: "center", display: "flex" });
|
||||
|
||||
await page
|
||||
.getByRole("row", { name: /김생성/ })
|
||||
.getByRole("checkbox")
|
||||
.check();
|
||||
await page
|
||||
.getByRole("row", { name: /이업데이트/ })
|
||||
.getByRole("checkbox")
|
||||
.check();
|
||||
|
||||
await userComparisonSection
|
||||
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
|
||||
.click();
|
||||
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
|
||||
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
|
||||
await page.getByRole("button", { name: "생성 작업 등록" }).click();
|
||||
await expect
|
||||
.poll(() => syncRequests)
|
||||
.toEqual([
|
||||
{
|
||||
userId: "user-missing",
|
||||
body: expect.objectContaining({ initialPassword: "InitPass123!" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const updateRowCheckbox = userComparisonSection
|
||||
.getByRole("row", { name: /이업데이트/ })
|
||||
.getByRole("checkbox");
|
||||
await expect(updateRowCheckbox).not.toBeChecked();
|
||||
await page
|
||||
.getByRole("row", { name: /이업데이트/ })
|
||||
.getByRole("checkbox")
|
||||
.check();
|
||||
await userComparisonSection
|
||||
.getByRole("button", { name: "선택 구성원 업데이트 적용" })
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => syncRequests)
|
||||
.toEqual([
|
||||
{
|
||||
userId: "user-missing",
|
||||
body: expect.objectContaining({ initialPassword: "InitPass123!" }),
|
||||
},
|
||||
{
|
||||
userId: "user-update",
|
||||
body: expect.not.objectContaining({
|
||||
initialPassword: expect.anything(),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("shows a toast when selected WORKS creation fails", async ({ page }) => {
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
@@ -651,9 +902,7 @@ test.describe("Worksmobile tenant management", () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("downloads initial password CSV and enqueues WORKS admin jobs", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("shows WORKS job history and enqueues admin jobs", async ({ page }) => {
|
||||
const requests: string[] = [];
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
@@ -790,24 +1039,6 @@ test.describe("Worksmobile tenant management", () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.endsWith(
|
||||
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/initial-passwords.csv",
|
||||
) &&
|
||||
method === "GET"
|
||||
) {
|
||||
requests.push("download-passwords");
|
||||
return route.fulfill({
|
||||
body: "email,password\nuser@example.com,Secret123!\n",
|
||||
contentType: "text/csv",
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Disposition":
|
||||
'attachment; filename="worksmobile-passwords.csv"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.endsWith(
|
||||
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/backfill/dry-run",
|
||||
@@ -864,18 +1095,10 @@ test.describe("Worksmobile tenant management", () => {
|
||||
await page.goto("/worksmobile");
|
||||
await expect(page.getByText("Worksmobile 연동")).toBeVisible();
|
||||
await page.getByRole("tab", { name: "이력" }).click();
|
||||
|
||||
const download = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("button", { name: "batch-1 비밀번호 CSV 다운로드" })
|
||||
.click();
|
||||
const passwordCsv = await download;
|
||||
expect(passwordCsv.suggestedFilename()).toBe("worksmobile-passwords.csv");
|
||||
const passwordCsvPath = await passwordCsv.path();
|
||||
expect(passwordCsvPath).toBeTruthy();
|
||||
expect(await readFile(passwordCsvPath ?? "", "utf8")).toContain(
|
||||
"user@example.com,Secret123!",
|
||||
);
|
||||
await expect(page.getByText("비밀번호 파일 히스토리")).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /비밀번호 CSV 다운로드/ }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Backfill Dry-run" }).click();
|
||||
await expect.poll(() => requests).toContain("dry-run");
|
||||
@@ -915,6 +1138,5 @@ test.describe("Worksmobile tenant management", () => {
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: /대기중 payload 삭제/ }).click();
|
||||
await expect.poll(() => requests).toContain("delete-pending");
|
||||
expect(requests).toContain("download-passwords");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
||||
"../common/**/node_modules/**",
|
||||
"../common/.pnpm-store/**",
|
||||
`${commonRoot}/theme/**`,
|
||||
`${commonRoot}/core/components/audit/AuditLogTable.tsx`,
|
||||
`${commonRoot}/core/pagination/*.worker.ts`,
|
||||
`${commonRoot}/core/query/queryClient.ts`,
|
||||
],
|
||||
|
||||
@@ -56,6 +56,11 @@ func main() {
|
||||
slog.Error("clear-orphan-user-tenant-memberships failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "worksmobile-sync":
|
||||
if err := runWorksmobileSync(os.Args[2:]); err != nil {
|
||||
slog.Error("worksmobile-sync failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
printUsage()
|
||||
os.Exit(2)
|
||||
@@ -227,4 +232,5 @@ func printUsage() {
|
||||
fmt.Fprintln(os.Stderr, "usage:")
|
||||
fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]")
|
||||
fmt.Fprintln(os.Stderr, " adminctl clear-orphan-user-tenant-memberships [--dry-run]")
|
||||
fmt.Fprintln(os.Stderr, " adminctl worksmobile-sync [--orgunits] [--users-csv PATH] [--credential-batch-id ID] [--process] [--serialize-orgunits] [--serialize-users-batch ID] [--batch-size N] [--delay DURATION]")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveCreateSuperAdminConfigUsesEnvDefaults(t *testing.T) {
|
||||
t.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
@@ -71,3 +76,65 @@ func TestResolveClearOrphanUserTenantMembershipsConfig(t *testing.T) {
|
||||
t.Fatal("dry-run flag was not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditWorksmobileDuplicatePhoneCountryCodesReportsAndFixes(t *testing.T) {
|
||||
client := &fakeWorksmobilePhoneAuditClient{
|
||||
users: []service.WorksmobileRemoteUser{
|
||||
{
|
||||
ID: "works-user-1",
|
||||
ExternalID: "baron-user-1",
|
||||
Email: "one@example.com",
|
||||
DisplayName: "One",
|
||||
CellPhone: "+82 +821091917771",
|
||||
DomainID: 1001,
|
||||
DomainName: "samaneng.com",
|
||||
},
|
||||
{
|
||||
ID: "works-user-2",
|
||||
Email: "two@example.com",
|
||||
CellPhone: "+821012345678",
|
||||
DomainID: 1001,
|
||||
},
|
||||
},
|
||||
}
|
||||
output := &strings.Builder{}
|
||||
|
||||
count, err := auditWorksmobileDuplicatePhoneCountryCodes(context.Background(), output, true, client)
|
||||
if err != nil {
|
||||
t.Fatalf("auditWorksmobileDuplicatePhoneCountryCodes returned error: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("count=%d, want 1", count)
|
||||
}
|
||||
if !strings.Contains(output.String(), "one@example.com") || !strings.Contains(output.String(), "+821091917771") {
|
||||
t.Fatalf("audit output did not include normalized duplicate phone row: %s", output.String())
|
||||
}
|
||||
if len(client.patches) != 1 {
|
||||
t.Fatalf("patch count=%d, want 1", len(client.patches))
|
||||
}
|
||||
if client.patches[0].identifier != "works-user-1" {
|
||||
t.Fatalf("patch identifier=%q, want works-user-1", client.patches[0].identifier)
|
||||
}
|
||||
if client.patches[0].payload.CellPhone != "+821091917771" {
|
||||
t.Fatalf("patch cellPhone=%q, want +821091917771", client.patches[0].payload.CellPhone)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeWorksmobilePhoneAuditClient struct {
|
||||
users []service.WorksmobileRemoteUser
|
||||
patches []fakeWorksmobilePhonePatch
|
||||
}
|
||||
|
||||
type fakeWorksmobilePhonePatch struct {
|
||||
identifier string
|
||||
payload service.WorksmobileUserPatchPayload
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePhoneAuditClient) ListUsers(ctx context.Context) ([]service.WorksmobileRemoteUser, error) {
|
||||
return f.users, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePhoneAuditClient) PatchUser(ctx context.Context, identifier string, payload service.WorksmobileUserPatchPayload) error {
|
||||
f.patches = append(f.patches, fakeWorksmobilePhonePatch{identifier: identifier, payload: payload})
|
||||
return nil
|
||||
}
|
||||
|
||||
1709
backend/cmd/adminctl/worksmobile_sync.go
Normal file
1709
backend/cmd/adminctl/worksmobile_sync.go
Normal file
File diff suppressed because it is too large
Load Diff
38
backend/cmd/adminctl/worksmobile_sync_test.go
Normal file
38
backend/cmd/adminctl/worksmobile_sync_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyWorksmobileAlignFromWorksAllowsDomainOnlyEmailMismatch(t *testing.T) {
|
||||
item := service.WorksmobileComparisonItem{
|
||||
BaronEmail: "user@typo.example.com",
|
||||
WorksmobileEmail: "user@example.com",
|
||||
}
|
||||
|
||||
status, ok := classifyWorksmobileAlignFromWorks(item)
|
||||
|
||||
if !ok {
|
||||
t.Fatalf("expected domain-only email mismatch to be alignable, status=%s", status)
|
||||
}
|
||||
if status != "updated" {
|
||||
t.Fatalf("expected updated status, got %s", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyWorksmobileAlignFromWorksSkipsLocalPartChange(t *testing.T) {
|
||||
item := service.WorksmobileComparisonItem{
|
||||
BaronEmail: "old@example.com",
|
||||
WorksmobileEmail: "new@example.com",
|
||||
}
|
||||
|
||||
status, ok := classifyWorksmobileAlignFromWorks(item)
|
||||
|
||||
if ok {
|
||||
t.Fatalf("expected local-part change to be skipped")
|
||||
}
|
||||
if status != "skipped_email_local_part_changed" {
|
||||
t.Fatalf("expected skipped_email_local_part_changed status, got %s", status)
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,21 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", true, "변경 대상만 출력하고 Kratos identity를 수정하지 않습니다")
|
||||
maintenanceWindow := flag.Bool("maintenance-window", false, "승인된 정비 시간에만 실제 변경을 허용합니다")
|
||||
markMirrorStale := flag.Bool("mark-mirror-stale", false, "실행 전 Redis identity mirror를 stale로 표시했음을 확인합니다")
|
||||
flag.Parse()
|
||||
|
||||
if !*dryRun && (!*maintenanceWindow || !*markMirrorStale) {
|
||||
log.Fatal("refusing to update Kratos identities: pass --dry-run=false --maintenance-window --mark-mirror-stale after marking identity mirror stale")
|
||||
}
|
||||
|
||||
kratosAdmin := service.NewKratosAdminService()
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -37,6 +47,11 @@ func main() {
|
||||
}
|
||||
|
||||
if changed {
|
||||
if *dryRun {
|
||||
count++
|
||||
fmt.Printf("Would update %s\n", id.ID)
|
||||
continue
|
||||
}
|
||||
_, err := kratosAdmin.UpdateIdentity(ctx, id.ID, traits, id.State)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update %s: %v", id.ID, err)
|
||||
@@ -46,5 +61,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("Total updated: %d\n", count)
|
||||
if *dryRun {
|
||||
fmt.Printf("Total candidates: %d\n", count)
|
||||
} else {
|
||||
fmt.Printf("Total updated: %d\n", count)
|
||||
fmt.Println("Identity mirror was marked stale before maintenance; run full mirror refresh and drift report before trusting cached user lists.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +336,12 @@ func main() {
|
||||
)
|
||||
configureWorksmobileClientFromEnv(worksmobileClient)
|
||||
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
|
||||
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient)
|
||||
worksmobileRelayClient := *worksmobileClient
|
||||
worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
|
||||
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient)
|
||||
if lock := service.NewWorksmobileRedisRelayLeaderLock(redisService); lock != nil {
|
||||
worksmobileRelayWorker.SetLeaderLock(lock)
|
||||
}
|
||||
go worksmobileRelayWorker.Start(context.Background())
|
||||
slog.Info("✅ Worksmobile Relay Worker started")
|
||||
rpUsageEmitter := service.NewRPUsageEventEmitter(rpUsageOutboxRepo)
|
||||
@@ -370,12 +375,13 @@ func main() {
|
||||
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||
authHandler.RPUsageSink = rpUsageEmitter
|
||||
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
|
||||
adminHandler.DB = db
|
||||
adminHandler.RPUsageQueries = rpUsageQueryRepo
|
||||
adminHandler.TenantRepo = tenantRepo
|
||||
adminHandler.Hydra = hydraService
|
||||
adminHandler.AuditRepo = auditRepo
|
||||
adminHandler.UserProjectionRepo = userProjectionRepo
|
||||
adminHandler.UserProjectionSyncer = userProjectionSyncer
|
||||
adminHandler.IdentityCache = redisService
|
||||
adminHandler.IntegrityChecker = repository.NewDataIntegrityChecker(db)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
|
||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||
@@ -383,6 +389,7 @@ func main() {
|
||||
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||
devHandler.RPUsageQueries = rpUsageQueryRepo
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService, hydraService, consentRepo)
|
||||
tenantHandler.OrgChartCache = redisService
|
||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
||||
@@ -718,12 +725,15 @@ func main() {
|
||||
admin.Get("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.ListOrphanUserLoginIDs)
|
||||
admin.Delete("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.DeleteOrphanUserLoginIDs)
|
||||
admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus)
|
||||
admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection)
|
||||
admin.Post("/projections/users/reset", requireSuperAdmin, adminHandler.ResetUserProjection)
|
||||
admin.Get("/ory/ssot", requireSuperAdmin, adminHandler.GetOrySSOTSystemStatus)
|
||||
admin.Post("/ory/ssot/identity-cache/flush", requireSuperAdmin, adminHandler.FlushIdentityCache)
|
||||
admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)
|
||||
admin.Get("/global-custom-claims", requireSuperAdmin, adminHandler.GetGlobalCustomClaimDefinitions)
|
||||
admin.Put("/global-custom-claims", requireSuperAdmin, adminHandler.UpdateGlobalCustomClaimDefinitions)
|
||||
|
||||
// Tenant Management (Mixed roles, handler filters results)
|
||||
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
|
||||
admin.Get("/orgchart/snapshot", requireAnyUser, tenantHandler.GetOrgChartSnapshot)
|
||||
admin.Get("/tenants/export", requireSuperAdmin, tenantHandler.ExportTenantsCSV)
|
||||
admin.Post("/tenants/import", requireSuperAdmin, tenantHandler.ImportTenantsCSV)
|
||||
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
|
||||
@@ -851,6 +861,9 @@ func main() {
|
||||
dev.Post("/developer-request/:id/approve", devHandler.ApproveDeveloperRequest)
|
||||
dev.Post("/developer-request/:id/reject", devHandler.RejectDeveloperRequest)
|
||||
dev.Post("/developer-request/:id/cancel-approval", devHandler.CancelDeveloperRequestApproval)
|
||||
dev.Get("/developer-grants", devHandler.ListDeveloperGrants)
|
||||
dev.Post("/developer-grants", devHandler.CreateDeveloperGrant)
|
||||
dev.Post("/developer-grants/:id/revoke", devHandler.RevokeDeveloperGrant)
|
||||
|
||||
// Webhook for Kratos courier (HTTP delivery)
|
||||
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
||||
|
||||
@@ -63,6 +63,7 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.SharedLink{},
|
||||
&domain.DeveloperRequest{},
|
||||
&domain.RPUserMetadata{},
|
||||
&domain.SystemSetting{},
|
||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -11,19 +13,39 @@ const (
|
||||
DeveloperRequestStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
const (
|
||||
DeveloperAccessPageAll = "all"
|
||||
DeveloperAccessPageOverview = "overview"
|
||||
DeveloperAccessPageClientCreate = "client_create"
|
||||
DeveloperAccessPageAudit = "audit"
|
||||
)
|
||||
|
||||
var DeveloperAccessPageOrder = []string{
|
||||
DeveloperAccessPageOverview,
|
||||
DeveloperAccessPageClientCreate,
|
||||
DeveloperAccessPageAudit,
|
||||
}
|
||||
|
||||
// DeveloperRequest represents a user's application to become a developer.
|
||||
type DeveloperRequest struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID
|
||||
TenantID string `gorm:"index;not null" json:"tenantId"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Organization string `json:"organization"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
Reason string `json:"reason"`
|
||||
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled
|
||||
AdminNotes string `json:"adminNotes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID
|
||||
TenantID string `gorm:"index;not null" json:"tenantId"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Organization string `json:"organization"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
Reason string `json:"reason"`
|
||||
AccessPages pq.StringArray `gorm:"type:text[]" json:"accessPages,omitempty"`
|
||||
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled
|
||||
AdminNotes string `json:"adminNotes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DeveloperAccessStatus struct {
|
||||
Status string `json:"status"`
|
||||
ApprovedPages pq.StringArray `json:"approvedPages,omitempty"`
|
||||
PendingPages pq.StringArray `json:"pendingPages,omitempty"`
|
||||
}
|
||||
|
||||
19
backend/internal/domain/identity_cache.go
Normal file
19
backend/internal/domain/identity_cache.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type IdentityCacheStatus struct {
|
||||
Status string `json:"status"`
|
||||
RedisReady bool `json:"redisReady"`
|
||||
ObservedCount int64 `json:"observedCount"`
|
||||
KeyCount int64 `json:"keyCount"`
|
||||
LastRefreshedAt *time.Time `json:"lastRefreshedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type IdentityCacheFlushResult struct {
|
||||
Status string `json:"status"`
|
||||
FlushedKeys int64 `json:"flushedKeys"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
11
backend/internal/domain/system_setting.go
Normal file
11
backend/internal/domain/system_setting.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// SystemSetting stores small global configuration documents.
|
||||
type SystemSetting struct {
|
||||
Key string `gorm:"primaryKey;size:128" json:"key"`
|
||||
Value JSONMap `gorm:"type:jsonb" json:"value"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -174,13 +174,7 @@ func ValidateLoginID(loginID string, emails []string, phone string) error {
|
||||
}
|
||||
|
||||
if phone != "" {
|
||||
normalizedPhone := strings.ReplaceAll(phone, "-", "")
|
||||
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
|
||||
if strings.HasPrefix(normalizedPhone, "010") {
|
||||
normalizedPhone = "+82" + normalizedPhone[1:]
|
||||
} else if strings.HasPrefix(normalizedPhone, "82") {
|
||||
normalizedPhone = "+" + normalizedPhone
|
||||
}
|
||||
normalizedPhone := NormalizePhoneNumber(phone)
|
||||
|
||||
if loginID == phone || loginID == normalizedPhone {
|
||||
return fmt.Errorf("ID cannot be the same as the phone number")
|
||||
@@ -211,3 +205,43 @@ func ValidateLoginID(loginID string, emails []string, phone string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizePhoneNumber(phone string) string {
|
||||
trimmed := strings.TrimSpace(phone)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
hasLeadingPlus := false
|
||||
digits := strings.Builder{}
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
digits.WriteRune(r)
|
||||
case r == '+' && digits.Len() == 0 && !hasLeadingPlus:
|
||||
hasLeadingPlus = true
|
||||
}
|
||||
}
|
||||
|
||||
number := digits.String()
|
||||
if number == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(number, "010") {
|
||||
return "+82" + number[1:]
|
||||
}
|
||||
if strings.HasPrefix(number, "82") {
|
||||
rest := number[2:]
|
||||
for strings.HasPrefix(rest, "82") {
|
||||
rest = rest[2:]
|
||||
}
|
||||
if strings.HasPrefix(rest, "0") {
|
||||
rest = rest[1:]
|
||||
}
|
||||
return "+82" + rest
|
||||
}
|
||||
if hasLeadingPlus {
|
||||
return "+" + number
|
||||
}
|
||||
return number
|
||||
}
|
||||
|
||||
@@ -39,3 +39,26 @@ func TestValidateLoginID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePhoneNumberDeduplicatesKoreanCountryCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Local mobile", "010-9191-7771", "+821091917771"},
|
||||
{"Korean country code", "+82 10-9191-7771", "+821091917771"},
|
||||
{"Duplicate plus Korean country code", "+82 +821091917771", "+821091917771"},
|
||||
{"Duplicate compact Korean country code", "+82821091917771", "+821091917771"},
|
||||
{"Duplicate spaced Korean country code", "+82 8210 9191 7771", "+821091917771"},
|
||||
{"Non Korean international phone preserved", "+1 914 481 2222", "+19144812222"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := NormalizePhoneNumber(tt.input); got != tt.want {
|
||||
t.Fatalf("NormalizePhoneNumber(%q)=%q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,22 +11,44 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type adminHydraClientLister interface {
|
||||
ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error)
|
||||
}
|
||||
|
||||
type identityCacheAdmin interface {
|
||||
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
|
||||
FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error)
|
||||
}
|
||||
|
||||
type AdminHandler struct {
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
TenantRepo repository.TenantRepository
|
||||
Hydra adminHydraClientLister
|
||||
AuditRepo domain.AuditRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
UserProjectionSyncer service.UserProjectionReconciler
|
||||
IntegrityChecker repository.DataIntegrityChecker
|
||||
DB *gorm.DB
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
TenantRepo repository.TenantRepository
|
||||
Hydra adminHydraClientLister
|
||||
AuditRepo domain.AuditRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
IdentityCache identityCacheAdmin
|
||||
IntegrityChecker repository.DataIntegrityChecker
|
||||
}
|
||||
|
||||
const globalCustomClaimsSettingKey = "global_custom_claim_definitions"
|
||||
|
||||
type globalCustomClaimDefinition struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
ValueType string `json:"valueType"`
|
||||
ReadPermission string `json:"readPermission"`
|
||||
WritePermission string `json:"writePermission"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type globalCustomClaimDefinitionsResponse struct {
|
||||
Items []globalCustomClaimDefinition `json:"items"`
|
||||
}
|
||||
|
||||
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
|
||||
@@ -110,6 +132,154 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetGlobalCustomClaimDefinitions(c *fiber.Ctx) error {
|
||||
if h == nil || h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"error": "settings store unavailable",
|
||||
})
|
||||
}
|
||||
|
||||
var setting domain.SystemSetting
|
||||
if err := h.DB.WithContext(c.Context()).First(&setting, "key = ?", globalCustomClaimsSettingKey).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.JSON(globalCustomClaimDefinitionsResponse{Items: []globalCustomClaimDefinition{}})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(globalCustomClaimDefinitionsResponse{
|
||||
Items: normalizeGlobalCustomClaimDefinitions(setting.Value["items"]),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateGlobalCustomClaimDefinitions(c *fiber.Ctx) error {
|
||||
if h == nil || h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"error": "settings store unavailable",
|
||||
})
|
||||
}
|
||||
|
||||
var req globalCustomClaimDefinitionsResponse
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
items, err := validateGlobalCustomClaimDefinitions(req.Items)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
setting := domain.SystemSetting{
|
||||
Key: globalCustomClaimsSettingKey,
|
||||
Value: domain.JSONMap{"items": globalCustomClaimDefinitionsToJSON(items)},
|
||||
}
|
||||
if err := h.DB.WithContext(c.Context()).Save(&setting).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(globalCustomClaimDefinitionsResponse{Items: items})
|
||||
}
|
||||
|
||||
func normalizeGlobalCustomClaimDefinitions(value any) []globalCustomClaimDefinition {
|
||||
rawItems, ok := value.([]any)
|
||||
if !ok {
|
||||
return []globalCustomClaimDefinition{}
|
||||
}
|
||||
items := make([]globalCustomClaimDefinition, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
raw, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
def := globalCustomClaimDefinition{
|
||||
Key: strings.TrimSpace(stringValue(raw["key"])),
|
||||
Label: strings.TrimSpace(stringValue(raw["label"])),
|
||||
ValueType: normalizeGlobalCustomClaimType(stringValue(raw["valueType"])),
|
||||
ReadPermission: adminNormalizeCustomClaimPermission(stringValue(raw["readPermission"])),
|
||||
WritePermission: adminNormalizeCustomClaimPermission(stringValue(raw["writePermission"])),
|
||||
Description: strings.TrimSpace(stringValue(raw["description"])),
|
||||
}
|
||||
if def.Key != "" {
|
||||
items = append(items, def)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func validateGlobalCustomClaimDefinitions(items []globalCustomClaimDefinition) ([]globalCustomClaimDefinition, error) {
|
||||
seen := map[string]struct{}{}
|
||||
normalized := make([]globalCustomClaimDefinition, 0, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if !isValidCustomClaimKey(key) {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "claim key must use letters, numbers, underscore, dot, or hyphen")
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "duplicate claim key: "+key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, globalCustomClaimDefinition{
|
||||
Key: key,
|
||||
Label: strings.TrimSpace(item.Label),
|
||||
ValueType: normalizeGlobalCustomClaimType(item.ValueType),
|
||||
ReadPermission: adminNormalizeCustomClaimPermission(item.ReadPermission),
|
||||
WritePermission: adminNormalizeCustomClaimPermission(item.WritePermission),
|
||||
Description: strings.TrimSpace(item.Description),
|
||||
})
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func globalCustomClaimDefinitionsToJSON(items []globalCustomClaimDefinition) []any {
|
||||
values := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
values = append(values, map[string]any{
|
||||
"key": item.Key,
|
||||
"label": item.Label,
|
||||
"valueType": item.ValueType,
|
||||
"readPermission": item.ReadPermission,
|
||||
"writePermission": item.WritePermission,
|
||||
"description": item.Description,
|
||||
})
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func normalizeGlobalCustomClaimType(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "number", "boolean", "array", "object", "date", "datetime":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "text"
|
||||
}
|
||||
}
|
||||
|
||||
func adminNormalizeCustomClaimPermission(value string) string {
|
||||
if strings.TrimSpace(value) == "user_and_admin" {
|
||||
return "user_and_admin"
|
||||
}
|
||||
return "admin_only"
|
||||
}
|
||||
|
||||
func isValidCustomClaimKey(value string) bool {
|
||||
for _, r := range value {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' || r == '.' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requireSuperAdminProfile(c *fiber.Ctx) bool {
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
@@ -133,26 +303,48 @@ func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error {
|
||||
return c.JSON(status)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ReconcileUserProjection(c *fiber.Ctx) error {
|
||||
func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.UserProjectionSyncer == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection sync service unavailable"})
|
||||
if h == nil || h.UserProjectionRepo == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
|
||||
}
|
||||
count, err := h.UserProjectionSyncer.Reconcile(c.Context())
|
||||
projectionStatus, err := h.UserProjectionRepo.GetStatus(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()})
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
cacheStatus := domain.IdentityCacheStatus{
|
||||
Status: "unavailable",
|
||||
RedisReady: false,
|
||||
LastError: "identity cache service unavailable",
|
||||
}
|
||||
if h.IdentityCache != nil {
|
||||
cacheStatus, err = h.IdentityCache.GetIdentityCacheStatus(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "success",
|
||||
"syncedUsers": count,
|
||||
"updatedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
"userProjection": projectionStatus,
|
||||
"identityCache": cacheStatus,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ResetUserProjection(c *fiber.Ctx) error {
|
||||
return h.ReconcileUserProjection(c)
|
||||
func (h *AdminHandler) FlushIdentityCache(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.IdentityCache == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity cache service unavailable"})
|
||||
}
|
||||
result, err := h.IdentityCache.FlushIdentityCache(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetDataIntegrity(c *fiber.Ctx) error {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -78,6 +77,10 @@ func (f *fakeAdminUserProjectionRepo) CountTenantMembers(ctx context.Context, te
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
return nil
|
||||
}
|
||||
@@ -90,15 +93,22 @@ func (f *fakeAdminUserProjectionRepo) GetStatus(ctx context.Context) (domain.Use
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
type fakeAdminUserProjectionSyncer struct {
|
||||
count int
|
||||
err error
|
||||
calls int
|
||||
type fakeIdentityCacheAdmin struct {
|
||||
status domain.IdentityCacheStatus
|
||||
flush domain.IdentityCacheFlushResult
|
||||
err error
|
||||
statusHit int
|
||||
flushCalls int
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionSyncer) Reconcile(ctx context.Context) (int, error) {
|
||||
f.calls++
|
||||
return f.count, f.err
|
||||
func (f *fakeIdentityCacheAdmin) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
f.statusHit++
|
||||
return f.status, f.err
|
||||
}
|
||||
|
||||
func (f *fakeIdentityCacheAdmin) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) {
|
||||
f.flushCalls++
|
||||
return f.flush, f.err
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
|
||||
@@ -199,42 +209,81 @@ func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t
|
||||
require.Equal(t, int64(152), body.ProjectedUsers)
|
||||
}
|
||||
|
||||
func TestAdminHandler_ReconcileUserProjectionRequiresSuperAdminAndRunsSyncer(t *testing.T) {
|
||||
syncer := &fakeAdminUserProjectionSyncer{count: 4}
|
||||
h := &AdminHandler{UserProjectionSyncer: syncer}
|
||||
func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t *testing.T) {
|
||||
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
|
||||
cache := &fakeIdentityCacheAdmin{
|
||||
status: domain.IdentityCacheStatus{
|
||||
Status: "ready",
|
||||
RedisReady: true,
|
||||
ObservedCount: 151,
|
||||
KeyCount: 153,
|
||||
LastRefreshedAt: &syncedAt,
|
||||
UpdatedAt: &syncedAt,
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{
|
||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
||||
status: domain.UserProjectionStatus{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: domain.UserProjectionStatusReady,
|
||||
Ready: true,
|
||||
LastSyncedAt: &syncedAt,
|
||||
ProjectedUsers: 152,
|
||||
},
|
||||
},
|
||||
IdentityCache: cache,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/admin/projections/users/reconcile", h.ReconcileUserProjection)
|
||||
app.Get("/api/v1/admin/ory/ssot", h.GetOrySSOTSystemStatus)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/projections/users/reconcile", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/ory/ssot", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, 1, syncer.calls)
|
||||
|
||||
var body map[string]any
|
||||
var body struct {
|
||||
UserProjection domain.UserProjectionStatus `json:"userProjection"`
|
||||
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, "success", body["status"])
|
||||
require.Equal(t, float64(4), body["syncedUsers"])
|
||||
require.Equal(t, int64(152), body.UserProjection.ProjectedUsers)
|
||||
require.True(t, body.IdentityCache.RedisReady)
|
||||
require.Equal(t, int64(151), body.IdentityCache.ObservedCount)
|
||||
require.Equal(t, int64(153), body.IdentityCache.KeyCount)
|
||||
require.Equal(t, 1, cache.statusHit)
|
||||
}
|
||||
|
||||
func TestAdminHandler_ReconcileUserProjectionReturnsServiceUnavailableOnSyncFailure(t *testing.T) {
|
||||
syncer := &fakeAdminUserProjectionSyncer{err: errors.New("kratos down")}
|
||||
h := &AdminHandler{UserProjectionSyncer: syncer}
|
||||
func TestAdminHandler_FlushIdentityCacheRequiresSuperAdminAndFlushesCacheOnly(t *testing.T) {
|
||||
cache := &fakeIdentityCacheAdmin{
|
||||
flush: domain.IdentityCacheFlushResult{
|
||||
Status: "success",
|
||||
FlushedKeys: 7,
|
||||
UpdatedAt: time.Date(2026, 5, 11, 3, 2, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{
|
||||
IdentityCache: cache,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/admin/projections/users/reconcile", h.ReconcileUserProjection)
|
||||
app.Post("/api/v1/admin/ory/ssot/identity-cache/flush", h.FlushIdentityCache)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/projections/users/reconcile", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/ory/ssot/identity-cache/flush", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body domain.IdentityCacheFlushResult
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, int64(7), body.FlushedKeys)
|
||||
require.Equal(t, 1, cache.flushCalls)
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
|
||||
|
||||
@@ -776,13 +776,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Normalize Phone (E.164 형태로 보관)
|
||||
normalizedPhone := strings.ReplaceAll(req.Phone, "-", "")
|
||||
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
|
||||
if strings.HasPrefix(normalizedPhone, "010") {
|
||||
normalizedPhone = "+82" + normalizedPhone[1:]
|
||||
} else if strings.HasPrefix(normalizedPhone, "82") {
|
||||
normalizedPhone = "+" + normalizedPhone
|
||||
}
|
||||
normalizedPhone := domain.NormalizePhoneNumber(req.Phone)
|
||||
|
||||
slog.Info("[Signup] Phone normalization", "raw", req.Phone, "normalized", normalizedPhone)
|
||||
|
||||
@@ -1092,15 +1086,7 @@ func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error {
|
||||
|
||||
// normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다.
|
||||
func normalizePhoneForLoginID(phone string) string {
|
||||
normalized := strings.ReplaceAll(phone, "-", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
if strings.HasPrefix(normalized, "010") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
if strings.HasPrefix(normalized, "82") {
|
||||
return "+" + normalized
|
||||
}
|
||||
return normalized
|
||||
return domain.NormalizePhoneNumber(phone)
|
||||
}
|
||||
|
||||
func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string) map[string]any {
|
||||
@@ -1226,7 +1212,7 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
|
||||
|
||||
// Heuristic: if a trait value is a map, it's treated as namespaced metadata for a tenant
|
||||
for k, v := range traits {
|
||||
if k == "metadata" {
|
||||
if k == "metadata" || k == "global_custom_claims" || k == "global_custom_claim_types" || k == "global_custom_claim_permissions" {
|
||||
continue
|
||||
}
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
@@ -1242,7 +1228,7 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
|
||||
claims["tenants"] = allTenants
|
||||
}
|
||||
|
||||
return claims
|
||||
return applyGlobalCustomClaims(claims, traits)
|
||||
}
|
||||
|
||||
func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string]any {
|
||||
@@ -1263,6 +1249,39 @@ func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any,
|
||||
return withOidcSessionMetadata(claims, sessionID)
|
||||
}
|
||||
|
||||
func applyGlobalCustomClaims(baseClaims map[string]any, traits map[string]any) map[string]any {
|
||||
if baseClaims == nil {
|
||||
baseClaims = map[string]any{}
|
||||
}
|
||||
if traits == nil {
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
rawClaims, ok := traits["global_custom_claims"]
|
||||
if !ok || rawClaims == nil {
|
||||
return baseClaims
|
||||
}
|
||||
customClaims, ok := rawClaims.(map[string]any)
|
||||
if !ok {
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
for key, value := range customClaims {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || value == nil {
|
||||
continue
|
||||
}
|
||||
if key == "rp_claims" || key == "rp_profiles" {
|
||||
continue
|
||||
}
|
||||
if _, exists := baseClaims[key]; exists {
|
||||
continue
|
||||
}
|
||||
baseClaims[key] = value
|
||||
}
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
func (h *AuthHandler) withHanmacFamilyTenantClaims(ctx context.Context, claims map[string]any, traits map[string]any, scopes []string) map[string]any {
|
||||
if claims == nil {
|
||||
claims = map[string]any{}
|
||||
@@ -1798,6 +1817,7 @@ func collectEmailList(traits map[string]any, primaryEmail string) []string {
|
||||
|
||||
func buildIdentityLookupCandidates(loginID string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
candidates := make([]string, 0, 3)
|
||||
add := func(value string) {
|
||||
candidate := strings.TrimSpace(value)
|
||||
if candidate == "" {
|
||||
@@ -1807,6 +1827,7 @@ func buildIdentityLookupCandidates(loginID string) []string {
|
||||
return
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
|
||||
normalized := strings.TrimSpace(loginID)
|
||||
@@ -1818,10 +1839,6 @@ func buildIdentityLookupCandidates(loginID string) []string {
|
||||
add(normalizePhoneForLoginID(normalized))
|
||||
}
|
||||
|
||||
candidates := make([]string, 0, len(seen))
|
||||
for candidate := range seen {
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
@@ -4666,7 +4683,7 @@ func extractFirstString(data map[string]any, keys ...string) string {
|
||||
}
|
||||
|
||||
func sanitizePhoneForSms(phone string) string {
|
||||
sanitized := strings.TrimSpace(phone)
|
||||
sanitized := domain.NormalizePhoneNumber(phone)
|
||||
if strings.HasPrefix(sanitized, "+82") {
|
||||
sanitized = "0" + sanitized[3:]
|
||||
}
|
||||
@@ -4685,11 +4702,7 @@ func (h *AuthHandler) formatPhoneForDisplay(phone string) string {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) formatPhoneForStorage(phone string) string {
|
||||
phone = strings.ReplaceAll(phone, "-", "")
|
||||
if strings.HasPrefix(phone, "010") && len(phone) == 11 {
|
||||
return "+8210" + phone[3:]
|
||||
}
|
||||
return phone
|
||||
return domain.NormalizePhoneNumber(phone)
|
||||
}
|
||||
|
||||
// GetMe - Returns current user's profile with enriched data from local DB
|
||||
@@ -5920,6 +5933,12 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
|
||||
slog.Error("failed to revoke hydra consent sessions", "error", err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke link")
|
||||
}
|
||||
if h.ConsentRepo != nil {
|
||||
if err := h.ConsentRepo.Delete(c.Context(), subject, clientID); err != nil {
|
||||
slog.Error("failed to delete local consent after hydra revoke", "error", err, "subject", subject, "client_id", clientID)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke local consent")
|
||||
}
|
||||
}
|
||||
|
||||
if h.AuditRepo != nil {
|
||||
detailsMap := map[string]any{
|
||||
@@ -7611,35 +7630,6 @@ func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error
|
||||
return result.ID, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]any) error {
|
||||
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
|
||||
if kratosAdminURL == "" {
|
||||
kratosAdminURL = "http://kratos:4434"
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", kratosAdminURL, identityID), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("kratos admin update failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domain.UserProfileResponse, error) {
|
||||
intro, err := h.Hydra.IntrospectToken(ctx, token)
|
||||
if err != nil {
|
||||
@@ -7952,10 +7942,17 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.updateKratosIdentity(identityID, traits); err != nil {
|
||||
if h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
}
|
||||
updatedIdentity, err := h.KratosAdmin.UpdateIdentity(c.Context(), identityID, traits, "")
|
||||
if err != nil {
|
||||
slog.Error("Failed to update profile in Kratos", "error", err)
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.")
|
||||
}
|
||||
if updatedIdentity != nil && updatedIdentity.Traits != nil {
|
||||
traits = updatedIdentity.Traits
|
||||
}
|
||||
|
||||
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency
|
||||
if h.UserRepo != nil {
|
||||
|
||||
@@ -28,6 +28,8 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
||||
}
|
||||
// 2. Hydra Revoke
|
||||
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
assert.Equal(t, "user-123", r.URL.Query().Get("subject"))
|
||||
assert.Equal(t, "app-1", r.URL.Query().Get("client"))
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
@@ -40,12 +42,22 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
rpUsageSink := &mockRPUsageEventSink{}
|
||||
consentRepo := &mockConsentRepo{
|
||||
consents: []domain.ClientConsent{
|
||||
{
|
||||
ClientID: "app-1",
|
||||
Subject: "user-123",
|
||||
GrantedScopes: []string{"openid", "profile"},
|
||||
},
|
||||
},
|
||||
}
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
AuditRepo: auditRepo,
|
||||
ConsentRepo: consentRepo,
|
||||
RPUsageSink: rpUsageSink,
|
||||
}
|
||||
app := fiber.New()
|
||||
@@ -67,6 +79,9 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
||||
assert.Equal(t, domain.RPUsageEventTypeAuthorizationRevoked, rpUsageSink.events[0].EventType)
|
||||
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
|
||||
assert.Equal(t, "app-1", rpUsageSink.events[0].ClientID)
|
||||
remaining, err := consentRepo.Find(req.Context(), "app-1", "user-123")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, remaining)
|
||||
}
|
||||
|
||||
func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) {
|
||||
|
||||
@@ -696,6 +696,31 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
||||
assert.Equal(t, "Officer", capturedClaims["position"])
|
||||
}
|
||||
|
||||
func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"global_custom_claims": map[string]any{
|
||||
"contract_date": "2026-06-09",
|
||||
"approved_at": "2026-06-09T09:30:00+09:00",
|
||||
"email": "override@test.com",
|
||||
"rp_claims": "reserved",
|
||||
},
|
||||
"global_custom_claim_permissions": map[string]any{
|
||||
"contract_date": map[string]any{
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "admin_only",
|
||||
},
|
||||
},
|
||||
}, []string{"openid", "profile", "email"}, "")
|
||||
|
||||
assert.Equal(t, "2026-06-09", claims["contract_date"])
|
||||
assert.Equal(t, "2026-06-09T09:30:00+09:00", claims["approved_at"])
|
||||
assert.Equal(t, "user@test.com", claims["email"])
|
||||
assert.NotEqual(t, "reserved", claims["rp_claims"])
|
||||
assert.NotContains(t, claims, "global_custom_claim_permissions")
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
||||
var capturedClaims map[string]any
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package handler
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -50,35 +49,37 @@ func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
|
||||
return app
|
||||
}
|
||||
|
||||
func newKratosWhoamiTestServer(t *testing.T, identityID string) *httptest.Server {
|
||||
func newKratosWhoamiTestServer(t *testing.T, identityID string) string {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/sessions/whoami" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" {
|
||||
http.Error(w, "missing session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "session-123",
|
||||
"authenticated_at": "2026-05-21T00:00:00Z",
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
origDefaultClient := http.DefaultClient
|
||||
http.DefaultClient = server.Client()
|
||||
http.DefaultClient = &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/sessions/whoami" {
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" {
|
||||
return httpResponse(r, http.StatusUnauthorized, "missing session"), nil
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"id": "session-123",
|
||||
"authenticated_at": "2026-05-21T00:00:00Z",
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return httpResponse(r, http.StatusOK, string(body)), nil
|
||||
}),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
http.DefaultClient = origDefaultClient
|
||||
})
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
return "http://kratos.test"
|
||||
}
|
||||
|
||||
func TestEnchantedLinkFlow_Email_Success(t *testing.T) {
|
||||
@@ -215,8 +216,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserSameSubjectApprovesOnly(t *testi
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1"))
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
@@ -248,8 +248,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user"))
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
@@ -302,8 +301,7 @@ func TestVerifyLoginCode_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
|
||||
prefixLoginCodePending + "user@example.com": "pending-123",
|
||||
prefixLoginCodeValue + "pending-123": "569765",
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user"))
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
@@ -393,8 +391,7 @@ func TestPollEnchantedLink_SharedBrowserSameSubjectIssuesSession(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1"))
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
@@ -425,8 +422,7 @@ func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T)
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`,
|
||||
}}
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user"))
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
@@ -456,18 +452,11 @@ func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T)
|
||||
func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
@@ -485,7 +474,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -497,6 +486,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
SmsService: &mockSmsService{},
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
@@ -529,10 +519,6 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
@@ -659,10 +645,6 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
||||
func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
@@ -748,8 +730,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
}
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-userfront-a"))
|
||||
|
||||
verifyBody, _ := json.Marshal(map[string]any{
|
||||
"token": token,
|
||||
@@ -785,10 +766,6 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
@@ -880,8 +857,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T
|
||||
resp, _ = app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-userfront-a"))
|
||||
|
||||
pollBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/middleware"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
@@ -446,10 +445,6 @@ func runHeadlessPasswordLoginWithAssertionRequest(
|
||||
headers map[string]string,
|
||||
) *http.Response {
|
||||
t.Helper()
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -463,11 +458,8 @@ func runHeadlessPasswordLoginWithAssertionRequest(
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal jwks body: %v", err)
|
||||
}
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
t.Cleanup(jwksServer.Close)
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -481,7 +473,7 @@ func runHeadlessPasswordLoginWithAssertionRequest(
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -496,6 +488,7 @@ func runHeadlessPasswordLoginWithAssertionRequest(
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -551,10 +544,6 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
|
||||
logger *slog.Logger,
|
||||
) *http.Response {
|
||||
t.Helper()
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -568,11 +557,8 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal jwks body: %v", err)
|
||||
}
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
t.Cleanup(jwksServer.Close)
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -586,7 +572,7 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -601,6 +587,7 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -879,10 +866,6 @@ func TestPasswordLogin_UserFront_AuditIncludesDefaultClientMetadata(t *testing.T
|
||||
func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -891,11 +874,8 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -909,7 +889,7 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -926,6 +906,7 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -979,10 +960,6 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee002", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -991,11 +968,8 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
acceptCalled := false
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1012,7 +986,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1030,6 +1004,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -1065,10 +1040,6 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
||||
func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -1077,11 +1048,8 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
|
||||
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -1097,7 +1065,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1114,6 +1082,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -1271,10 +1240,6 @@ func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) {
|
||||
func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -1283,11 +1248,8 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
|
||||
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -1301,7 +1263,7 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
"headless_jwks": map[string]any{
|
||||
"keys": []map[string]any{},
|
||||
},
|
||||
@@ -1321,6 +1283,7 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -1360,10 +1323,6 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
|
||||
func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "")
|
||||
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -1383,12 +1342,11 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te
|
||||
}
|
||||
|
||||
fetchCount := 0
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
jwksClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
fetchCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(freshRaw)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
return httpResponse(r, http.StatusOK, string(freshRaw)), nil
|
||||
})}
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -1402,7 +1360,7 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1417,12 +1375,12 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
redisRepo := &testRedisRepo{values: map[string]string{}}
|
||||
cacheService := service.NewHeadlessJWKSCacheService(redisRepo, jwksServer.Client())
|
||||
cacheService := service.NewHeadlessJWKSCacheService(redisRepo, jwksClient)
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(30 * time.Minute)
|
||||
if err := cacheService.SaveState("headless-login-client", domain.HeadlessJWKSCacheState{
|
||||
ClientID: "headless-login-client",
|
||||
JWKSURI: jwksServer.URL + "/.well-known/jwks.json",
|
||||
JWKSURI: jwksURI,
|
||||
RawJWKS: string(staleRaw),
|
||||
CachedKids: []string{"test-kid"},
|
||||
CachedAt: &now,
|
||||
@@ -1546,10 +1504,6 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -1562,11 +1516,8 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
invalidKey, _ := mustHeadlessRSAJWK(t)
|
||||
_ = validKey
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
jwksClient := newJWKSHTTPClient(t, jwksBody)
|
||||
jwksURI := jwksURL()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -1580,7 +1531,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks_uri": jwksURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1595,6 +1546,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
@@ -2198,8 +2150,7 @@ func TestPasswordLogin_SharedBrowserSameSubjectAllowed(t *testing.T) {
|
||||
Subject: "kratos-user-1",
|
||||
}, nil)
|
||||
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1"))
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil)
|
||||
@@ -2237,8 +2188,7 @@ func TestPasswordLogin_SharedBrowserDifferentSubjectConflicts(t *testing.T) {
|
||||
Subject: "kratos-user-1",
|
||||
}, nil)
|
||||
|
||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user")
|
||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user"))
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil)
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -31,6 +32,28 @@ func (r *recordingUpdateMeUserRepo) UpdateUserLoginIDs(ctx context.Context, user
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingUpdateMeKratosAdmin struct {
|
||||
MockKratosAdminService
|
||||
updatedIdentityID string
|
||||
updatedTraits map[string]any
|
||||
updatedState string
|
||||
storedTraits map[string]any
|
||||
}
|
||||
|
||||
func (r *recordingUpdateMeKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
|
||||
r.updatedIdentityID = identityID
|
||||
r.updatedTraits = maps.Clone(traits)
|
||||
r.updatedState = state
|
||||
if r.storedTraits != nil {
|
||||
maps.Copy(r.storedTraits, traits)
|
||||
}
|
||||
return &service.KratosIdentity{
|
||||
ID: identityID,
|
||||
Traits: traits,
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
||||
token := "token-abc"
|
||||
identityID := "user-1"
|
||||
@@ -79,8 +102,10 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
||||
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
kratosAdmin := &recordingUpdateMeKratosAdmin{storedTraits: traits}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
KratosAdmin: kratosAdmin,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/me", h.GetMe)
|
||||
@@ -113,6 +138,8 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||
require.Equal(t, "New Dept", traits["department"])
|
||||
require.Equal(t, identityID, kratosAdmin.updatedIdentityID)
|
||||
require.Equal(t, "New Dept", kratosAdmin.updatedTraits["department"])
|
||||
|
||||
// 3) 새로고침 재조회 시 New Dept가 보여야 함(캐시 무효화 회귀 방지)
|
||||
getReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||
@@ -177,9 +204,11 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
|
||||
"verify_update_phone:" + identityID + ":+821087654321": "verified",
|
||||
}}
|
||||
userRepo := &recordingUpdateMeUserRepo{}
|
||||
kratosAdmin := &recordingUpdateMeKratosAdmin{storedTraits: traits}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
UserRepo: userRepo,
|
||||
KratosAdmin: kratosAdmin,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Put("/api/v1/user/me", h.UpdateMe)
|
||||
@@ -199,6 +228,9 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
|
||||
updateResp, err := app.Test(updateReq, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||
require.Equal(t, identityID, kratosAdmin.updatedIdentityID)
|
||||
require.Equal(t, "New Name", kratosAdmin.updatedTraits["name"])
|
||||
require.Equal(t, "+821087654321", kratosAdmin.updatedTraits["phone_number"])
|
||||
|
||||
require.NotNil(t, userRepo.updated)
|
||||
require.Equal(t, identityID, userRepo.updated.ID)
|
||||
|
||||
@@ -135,6 +135,130 @@ func clientTenantAccessAllowed(profile *domain.UserProfileResponse, client domai
|
||||
return false
|
||||
}
|
||||
|
||||
func clientTenantAccessAllowedForSubtree(c *fiber.Ctx, tenantSvc service.TenantService, profile *domain.UserProfileResponse, client domain.HydraClient) bool {
|
||||
if clientTenantAccessAllowed(profile, client) {
|
||||
return true
|
||||
}
|
||||
if tenantSvc == nil || profile == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
allowedTenants := make([]domain.Tenant, 0)
|
||||
for _, identifier := range clientAllowedTenants(client.Metadata) {
|
||||
if tenant, ok := resolveTenantAccessTenant(c, tenantSvc, domain.Tenant{ID: identifier, Slug: identifier}); ok {
|
||||
allowedTenants = append(allowedTenants, tenant)
|
||||
}
|
||||
}
|
||||
if len(allowedTenants) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, candidate := range tenantAccessProfileTenants(profile) {
|
||||
resolvedCandidate, ok := resolveTenantAccessTenant(c, tenantSvc, candidate)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, allowed := range allowedTenants {
|
||||
if tenantMatchesOrDescendsFrom(c, tenantSvc, resolvedCandidate, allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tenantAccessProfileTenants(profile *domain.UserProfileResponse) []domain.Tenant {
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
tenants := make([]domain.Tenant, 0, len(profile.ManageableTenants)+len(profile.JoinedTenants)+2)
|
||||
add := func(tenant domain.Tenant) {
|
||||
key := strings.ToLower(firstNonEmptyString(tenant.ID, tenant.Slug, tenant.Name))
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
tenants = append(tenants, tenant)
|
||||
}
|
||||
|
||||
if profile.Tenant != nil {
|
||||
add(*profile.Tenant)
|
||||
}
|
||||
if profile.TenantID != nil {
|
||||
add(domain.Tenant{ID: strings.TrimSpace(*profile.TenantID)})
|
||||
}
|
||||
for _, tenant := range profile.ManageableTenants {
|
||||
add(tenant)
|
||||
}
|
||||
for _, tenant := range profile.JoinedTenants {
|
||||
add(tenant)
|
||||
}
|
||||
return tenants
|
||||
}
|
||||
|
||||
func resolveTenantAccessTenant(c *fiber.Ctx, tenantSvc service.TenantService, tenant domain.Tenant) (domain.Tenant, bool) {
|
||||
if tenantSvc == nil {
|
||||
return tenant, firstNonEmptyString(tenant.ID, tenant.Slug) != ""
|
||||
}
|
||||
if strings.TrimSpace(tenant.ID) != "" {
|
||||
if resolved, err := tenantSvc.GetTenant(c.Context(), strings.TrimSpace(tenant.ID)); err == nil && resolved != nil {
|
||||
return *resolved, true
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(tenant.Slug) != "" {
|
||||
if resolved, err := tenantSvc.GetTenantBySlug(c.Context(), strings.TrimSpace(tenant.Slug)); err == nil && resolved != nil {
|
||||
return *resolved, true
|
||||
}
|
||||
}
|
||||
return tenant, firstNonEmptyString(tenant.ID, tenant.Slug) != ""
|
||||
}
|
||||
|
||||
func tenantMatchesOrDescendsFrom(c *fiber.Ctx, tenantSvc service.TenantService, tenant domain.Tenant, ancestor domain.Tenant) bool {
|
||||
if tenantAccessTenantMatches(tenant, ancestor) {
|
||||
return true
|
||||
}
|
||||
if tenantSvc == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
visited := make(map[string]struct{})
|
||||
current := tenant
|
||||
for current.ParentID != nil && strings.TrimSpace(*current.ParentID) != "" {
|
||||
parentID := strings.TrimSpace(*current.ParentID)
|
||||
if _, ok := visited[parentID]; ok {
|
||||
return false
|
||||
}
|
||||
visited[parentID] = struct{}{}
|
||||
|
||||
parent, err := tenantSvc.GetTenant(c.Context(), parentID)
|
||||
if err != nil || parent == nil {
|
||||
return false
|
||||
}
|
||||
if tenantAccessTenantMatches(*parent, ancestor) {
|
||||
return true
|
||||
}
|
||||
current = *parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tenantAccessTenantMatches(left, right domain.Tenant) bool {
|
||||
leftID := strings.ToLower(strings.TrimSpace(left.ID))
|
||||
rightID := strings.ToLower(strings.TrimSpace(right.ID))
|
||||
if leftID != "" && rightID != "" && leftID == rightID {
|
||||
return true
|
||||
}
|
||||
|
||||
leftSlug := strings.ToLower(strings.TrimSpace(left.Slug))
|
||||
rightSlug := strings.ToLower(strings.TrimSpace(right.Slug))
|
||||
return leftSlug != "" && rightSlug != "" && leftSlug == rightSlug
|
||||
}
|
||||
|
||||
type tenantAccessDeniedDetails struct {
|
||||
Account tenantAccessDeniedAccount `json:"account"`
|
||||
CurrentTenant tenantAccessDeniedTenant `json:"current_tenant"`
|
||||
@@ -179,7 +303,7 @@ func enforceClientTenantAccess(c *fiber.Ctx, tenantSvc service.TenantService, cl
|
||||
_ = tenantNotAllowedError(c, details)
|
||||
return true
|
||||
}
|
||||
if !clientTenantAccessAllowed(profile, client) {
|
||||
if !clientTenantAccessAllowedForSubtree(c, tenantSvc, profile, client) {
|
||||
_ = tenantNotAllowedError(c, details)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -264,17 +264,22 @@ func TestGetConsentRequest_DeniesRestrictedClientWhenProfileResolutionFails(t *t
|
||||
ID: "tenant-a",
|
||||
Slug: "tenant-a",
|
||||
Name: "Tenant A",
|
||||
}, nil).Twice()
|
||||
}, nil)
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-c").Return(&domain.Tenant{
|
||||
ID: "tenant-c",
|
||||
Slug: "tenant-c",
|
||||
Name: "Tenant C",
|
||||
}, nil)
|
||||
tenantSvc.On("ListJoinedTenants", mock.Anything, "user-123").Return([]domain.Tenant{
|
||||
{ID: "tenant-a", Slug: "tenant-a", Name: "Tenant A"},
|
||||
{ID: "tenant-c", Slug: "tenant-c", Name: "Tenant C"},
|
||||
}, nil).Once()
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-b").Return(nil, assert.AnError).Once()
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-b").Return(nil, assert.AnError)
|
||||
tenantSvc.On("GetTenantBySlug", mock.Anything, "tenant-b").Return(&domain.Tenant{
|
||||
ID: "tenant-b-id",
|
||||
Slug: "tenant-b",
|
||||
Name: "Tenant B",
|
||||
}, nil).Once()
|
||||
}, nil)
|
||||
return tenantSvc
|
||||
}(),
|
||||
ConsentRepo: &mockConsentRepo{
|
||||
@@ -343,13 +348,18 @@ func TestAcceptOidcLoginRequest_DeniesTenantAccess(t *testing.T) {
|
||||
ID: "tenant-a",
|
||||
Slug: "tenant-a",
|
||||
Name: "Tenant A",
|
||||
}, nil).Twice()
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-b").Return(nil, assert.AnError).Once()
|
||||
}, nil)
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-c").Return(&domain.Tenant{
|
||||
ID: "tenant-c",
|
||||
Slug: "tenant-c",
|
||||
Name: "Tenant C",
|
||||
}, nil)
|
||||
tenantSvc.On("GetTenant", mock.Anything, "tenant-b").Return(nil, assert.AnError)
|
||||
tenantSvc.On("GetTenantBySlug", mock.Anything, "tenant-b").Return(&domain.Tenant{
|
||||
ID: "tenant-b-id",
|
||||
Slug: "tenant-b",
|
||||
Name: "Tenant B",
|
||||
}, nil).Once()
|
||||
}, nil)
|
||||
enforceClientTenantAccess(c, tenantSvc, client, profile, nil)
|
||||
return nil
|
||||
})
|
||||
@@ -384,3 +394,65 @@ func TestAcceptOidcLoginRequest_DeniesTenantAccess(t *testing.T) {
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Tenant B", allowedTenant["name"])
|
||||
}
|
||||
|
||||
func TestAcceptOidcLoginRequest_AllowsRestrictedClientForHanmacFamilyDescendant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Get("/allow-descendant", func(c *fiber.Ctx) error {
|
||||
hanmacFamilyID := "hanmac-family-id"
|
||||
samanID := "saman-id"
|
||||
profile := &domain.UserProfileResponse{
|
||||
ID: "user-123",
|
||||
Role: domain.RoleUser,
|
||||
Email: "user@samaneng.com",
|
||||
TenantID: &samanID,
|
||||
Tenant: &domain.Tenant{
|
||||
ID: samanID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
ParentID: &hanmacFamilyID,
|
||||
},
|
||||
JoinedTenants: []domain.Tenant{
|
||||
{
|
||||
ID: samanID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
ParentID: &hanmacFamilyID,
|
||||
},
|
||||
},
|
||||
}
|
||||
client := domain.HydraClient{
|
||||
ClientID: "orgfront",
|
||||
Metadata: map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"hanmac-family"},
|
||||
},
|
||||
}
|
||||
tenantSvc := new(MockTenantService)
|
||||
tenantSvc.On("GetTenant", mock.Anything, "hanmac-family").Return(nil, assert.AnError).Maybe()
|
||||
tenantSvc.On("GetTenantBySlug", mock.Anything, "hanmac-family").Return(&domain.Tenant{
|
||||
ID: hanmacFamilyID,
|
||||
Slug: "hanmac-family",
|
||||
Name: "한맥가족",
|
||||
}, nil).Maybe()
|
||||
tenantSvc.On("GetTenant", mock.Anything, samanID).Return(&domain.Tenant{
|
||||
ID: samanID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
ParentID: &hanmacFamilyID,
|
||||
}, nil).Maybe()
|
||||
tenantSvc.On("GetTenant", mock.Anything, hanmacFamilyID).Return(&domain.Tenant{
|
||||
ID: hanmacFamilyID,
|
||||
Slug: "hanmac-family",
|
||||
Name: "한맥가족",
|
||||
}, nil).Maybe()
|
||||
|
||||
blocked := enforceClientTenantAccess(c, tenantSvc, client, profile, nil)
|
||||
assert.False(t, blocked)
|
||||
return c.SendStatus(http.StatusNoContent)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/allow-descendant", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -196,7 +196,17 @@ func (m *mockConsentRepo) Find(ctx context.Context, clientID, subject string) (*
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) Delete(ctx context.Context, subject, clientID string) error { return nil }
|
||||
func (m *mockConsentRepo) Delete(ctx context.Context, subject, clientID string) error {
|
||||
filtered := m.consents[:0]
|
||||
for _, consent := range m.consents {
|
||||
if consent.Subject == subject && (clientID == "" || consent.ClientID == clientID) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, consent)
|
||||
}
|
||||
m.consents = filtered
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) DeleteByClient(ctx context.Context, clientID string) error {
|
||||
filtered := m.consents[:0]
|
||||
|
||||
@@ -39,7 +39,7 @@ type DevHandler struct {
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPSvc service.RelyingPartyService
|
||||
TenantSvc service.TenantService
|
||||
DeveloperSvc *service.DeveloperService
|
||||
DeveloperSvc developerRequestService
|
||||
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
Auth interface {
|
||||
@@ -47,6 +47,17 @@ type DevHandler struct {
|
||||
}
|
||||
}
|
||||
|
||||
type developerRequestService interface {
|
||||
RequestAccess(ctx context.Context, req domain.DeveloperRequest) error
|
||||
GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperAccessStatus, error)
|
||||
GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error)
|
||||
ListRequests(ctx context.Context, userID, status, tenantID string) ([]domain.DeveloperRequest, error)
|
||||
CreateGrant(ctx context.Context, req domain.DeveloperRequest) error
|
||||
ApproveRequest(ctx context.Context, id uint, adminNotes string) error
|
||||
RejectRequest(ctx context.Context, id uint, adminNotes string) error
|
||||
CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error
|
||||
}
|
||||
|
||||
func NewDevHandler(
|
||||
redis domain.RedisRepository,
|
||||
secretRepo domain.ClientSecretRepository,
|
||||
@@ -176,17 +187,18 @@ type clientRelationUpsertRequest struct {
|
||||
}
|
||||
|
||||
type consentSummary struct {
|
||||
Subject string `json:"subject"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientName string `json:"clientName,omitempty"`
|
||||
GrantedScopes []string `json:"grantedScopes"`
|
||||
AuthenticatedAt string `json:"authenticatedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantName string `json:"tenantName,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientName string `json:"clientName,omitempty"`
|
||||
GrantedScopes []string `json:"grantedScopes"`
|
||||
AuthenticatedAt string `json:"authenticatedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantName string `json:"tenantName,omitempty"`
|
||||
RPMetadata domain.JSONMap `json:"rpMetadata,omitempty"`
|
||||
}
|
||||
|
||||
type consentListResponse struct {
|
||||
@@ -217,10 +229,12 @@ type clientUpsertRequest struct {
|
||||
}
|
||||
|
||||
type normalizedIDTokenClaim struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
ValueType string `json:"valueType"`
|
||||
Namespace string `json:"namespace"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
ValueType string `json:"valueType"`
|
||||
ReadPermission string `json:"readPermission"`
|
||||
WritePermission string `json:"writePermission"`
|
||||
}
|
||||
|
||||
var protectedSystemClientIDs = map[string]struct{}{
|
||||
@@ -261,6 +275,56 @@ func isDevConsoleViewerRole(role string) bool {
|
||||
return r == domain.RoleSuperAdmin || r == domain.RoleUser
|
||||
}
|
||||
|
||||
func normalizeDeveloperAccessPagesForHandler(pages []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
normalized := make([]string, 0, len(pages))
|
||||
add := func(page string) {
|
||||
page = strings.ToLower(strings.TrimSpace(page))
|
||||
if page == "" {
|
||||
return
|
||||
}
|
||||
if page == domain.DeveloperAccessPageAll {
|
||||
normalized = []string{domain.DeveloperAccessPageAll}
|
||||
seen = map[string]struct{}{domain.DeveloperAccessPageAll: struct{}{}}
|
||||
return
|
||||
}
|
||||
for _, allowed := range domain.DeveloperAccessPageOrder {
|
||||
if page == allowed {
|
||||
if _, exists := seen[page]; exists {
|
||||
return
|
||||
}
|
||||
seen[page] = struct{}{}
|
||||
normalized = append(normalized, page)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, page := range pages {
|
||||
add(page)
|
||||
if len(normalized) == 1 && normalized[0] == domain.DeveloperAccessPageAll {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return []string{domain.DeveloperAccessPageAll}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func developerAccessPagesEqual(left, right []string) bool {
|
||||
leftNormalized := normalizeDeveloperAccessPagesForHandler(left)
|
||||
rightNormalized := normalizeDeveloperAccessPagesForHandler(right)
|
||||
if len(leftNormalized) != len(rightNormalized) {
|
||||
return false
|
||||
}
|
||||
for i := range leftNormalized {
|
||||
if leftNormalized[i] != rightNormalized[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func setCurrentProfileContext(c *fiber.Ctx, profile *domain.UserProfileResponse) {
|
||||
if profile == nil {
|
||||
return
|
||||
@@ -423,7 +487,26 @@ func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domai
|
||||
return false
|
||||
}
|
||||
allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", tenantID, "grant_dev_permissions")
|
||||
return err == nil && allowed
|
||||
if err == nil && allowed {
|
||||
return true
|
||||
}
|
||||
return h.hasApprovedDeveloperRequest(c, profile, tenantID)
|
||||
}
|
||||
|
||||
func (h *DevHandler) hasApprovedDeveloperRequest(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool {
|
||||
if h.DeveloperSvc == nil || profile == nil {
|
||||
return false
|
||||
}
|
||||
userID := strings.TrimSpace(profile.ID)
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if userID == "" || tenantID == "" {
|
||||
return false
|
||||
}
|
||||
status, err := h.DeveloperSvc.GetRequestStatus(c.Context(), userID, tenantID)
|
||||
if err != nil || status == nil {
|
||||
return false
|
||||
}
|
||||
return status.Status == domain.DeveloperRequestStatusApproved
|
||||
}
|
||||
|
||||
func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary, relation string) bool {
|
||||
@@ -1535,19 +1618,202 @@ func (h *DevHandler) UpsertRPUserMetadata(c *fiber.Ctx) error {
|
||||
if req.Metadata == nil {
|
||||
req.Metadata = map[string]any{}
|
||||
}
|
||||
normalizedMetadata, err := normalizeRPUserMetadataForClient(req.Metadata, summary.Metadata)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
row := &domain.RPUserMetadata{
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Metadata: domain.JSONMap(req.Metadata),
|
||||
Metadata: normalizedMetadata,
|
||||
}
|
||||
if err := h.RPUserMetadataRepo.Upsert(c.Context(), row); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if err := h.syncRPUserMetadataToKratos(c.Context(), userID, clientID, normalizedMetadata); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(row)
|
||||
}
|
||||
|
||||
func (h *DevHandler) syncRPUserMetadataToKratos(ctx context.Context, userID string, clientID string, metadata domain.JSONMap) error {
|
||||
if h == nil || h.KratosAdmin == nil {
|
||||
return nil
|
||||
}
|
||||
identity, err := h.KratosAdmin.GetIdentity(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load kratos identity for rp user metadata: %w", err)
|
||||
}
|
||||
if identity == nil {
|
||||
return errors.New("kratos identity not found for rp user metadata")
|
||||
}
|
||||
traits := identity.Traits
|
||||
if traits == nil {
|
||||
traits = map[string]any{}
|
||||
}
|
||||
rawRPClaims, _ := traits["rp_custom_claims"].(map[string]any)
|
||||
if rawRPClaims == nil {
|
||||
rawRPClaims = map[string]any{}
|
||||
}
|
||||
rawRPClaims[clientID] = metadata
|
||||
traits["rp_custom_claims"] = rawRPClaims
|
||||
_, err = h.KratosAdmin.UpdateIdentity(ctx, identity.ID, traits, identity.State)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update kratos rp user metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type rpUserMetadataClaimSchema struct {
|
||||
Key string
|
||||
ValueType string
|
||||
ReadPermission string
|
||||
WritePermission string
|
||||
}
|
||||
|
||||
func normalizeCustomClaimPermission(value any) string {
|
||||
permission := strings.TrimSpace(readInterfaceString(value, ""))
|
||||
switch permission {
|
||||
case "user_and_admin":
|
||||
return "user_and_admin"
|
||||
default:
|
||||
return "admin_only"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCustomClaimPermissions(value any, fallbackRead string, fallbackWrite string) map[string]any {
|
||||
var record map[string]any
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
record = typed
|
||||
case domain.JSONMap:
|
||||
record = map[string]any(typed)
|
||||
}
|
||||
return map[string]any{
|
||||
"readPermission": normalizeCustomClaimPermission(readMapValueOrFallback(record, "readPermission", fallbackRead)),
|
||||
"writePermission": normalizeCustomClaimPermission(readMapValueOrFallback(record, "writePermission", fallbackWrite)),
|
||||
}
|
||||
}
|
||||
|
||||
func readMapValueOrFallback(values map[string]any, key string, fallback string) any {
|
||||
if values == nil {
|
||||
return fallback
|
||||
}
|
||||
if value, ok := values[key]; ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func normalizeRPUserMetadataForClient(metadata map[string]any, clientMetadata map[string]any) (domain.JSONMap, error) {
|
||||
schemas, err := rpUserMetadataClaimSchemas(clientMetadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalized := domain.JSONMap{}
|
||||
for rawKey, rawValue := range metadata {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
if key == "" || isEmptyRPUserMetadataValue(rawValue) {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(key, "_permissions") {
|
||||
claimKey := strings.TrimSuffix(key, "_permissions")
|
||||
schema, ok := schemas[claimKey]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rp user metadata claim is not configured: %s", claimKey)
|
||||
}
|
||||
normalized[key] = normalizeCustomClaimPermissions(rawValue, schema.ReadPermission, schema.WritePermission)
|
||||
continue
|
||||
}
|
||||
schema, ok := schemas[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rp user metadata claim is not configured: %s", key)
|
||||
}
|
||||
textValue, err := stringifyRPUserMetadataValue(rawValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rp user metadata %s is invalid: %w", key, err)
|
||||
}
|
||||
parsed, err := parseConfiguredClaimValue(textValue, schema.ValueType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rp user metadata %s is invalid: %w", key, err)
|
||||
}
|
||||
normalized[key] = parsed
|
||||
permissionKey := key + "_permissions"
|
||||
if _, exists := normalized[permissionKey]; !exists {
|
||||
normalized[permissionKey] = map[string]any{
|
||||
"readPermission": schema.ReadPermission,
|
||||
"writePermission": schema.WritePermission,
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func rpUserMetadataClaimSchemas(clientMetadata map[string]any) (map[string]rpUserMetadataClaimSchema, error) {
|
||||
rawClaims, ok := clientMetadata[domain.MetadataIDTokenClaims]
|
||||
if !ok || rawClaims == nil {
|
||||
return map[string]rpUserMetadataClaimSchema{}, nil
|
||||
}
|
||||
|
||||
claims, err := normalizeIDTokenClaimsForDevConsole(rawClaims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schemas := make(map[string]rpUserMetadataClaimSchema, len(claims))
|
||||
for _, claim := range claims {
|
||||
if claim.Namespace != "rp_claims" {
|
||||
continue
|
||||
}
|
||||
schemas[claim.Key] = rpUserMetadataClaimSchema{
|
||||
Key: claim.Key,
|
||||
ValueType: claim.ValueType,
|
||||
ReadPermission: claim.ReadPermission,
|
||||
WritePermission: claim.WritePermission,
|
||||
}
|
||||
}
|
||||
return schemas, nil
|
||||
}
|
||||
|
||||
func isEmptyRPUserMetadataValue(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
return strings.TrimSpace(text) == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stringifyRPUserMetadataValue(value any) (string, error) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed), nil
|
||||
case bool:
|
||||
return strconv.FormatBool(typed), nil
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64), nil
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(typed), 'f', -1, 32), nil
|
||||
case int:
|
||||
return strconv.Itoa(typed), nil
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10), nil
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(typed), 10), nil
|
||||
case json.Number:
|
||||
return typed.String(), nil
|
||||
default:
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DevHandler) syncHeadlessJWKSCache(ctx context.Context, client domain.HydraClient, reason string) {
|
||||
if h.HeadlessJWKS == nil {
|
||||
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil)
|
||||
@@ -2262,6 +2528,13 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
var rpMetadata domain.JSONMap
|
||||
if h.RPUserMetadataRepo != nil {
|
||||
if row, err := h.RPUserMetadataRepo.Get(c.Context(), consent.ClientID, consent.Subject); err == nil && row != nil && len(row.Metadata) > 0 {
|
||||
rpMetadata = row.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, consentSummary{
|
||||
Subject: consent.Subject,
|
||||
UserName: userName,
|
||||
@@ -2273,6 +2546,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
Status: status,
|
||||
TenantID: consent.TenantID,
|
||||
TenantName: consent.TenantName,
|
||||
RPMetadata: rpMetadata,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3107,7 +3381,7 @@ func normalizeIDTokenClaimsMetadata(metadata map[string]any) (map[string]any, er
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
normalized, err := normalizeIDTokenClaims(rawClaims)
|
||||
normalized, err := normalizeIDTokenClaimsForDevConsole(rawClaims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -3116,6 +3390,14 @@ func normalizeIDTokenClaimsMetadata(metadata map[string]any) (map[string]any, er
|
||||
}
|
||||
|
||||
func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||
return normalizeIDTokenClaimsWithOptions(rawClaims, true)
|
||||
}
|
||||
|
||||
func normalizeIDTokenClaimsForDevConsole(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||
return normalizeIDTokenClaimsWithOptions(rawClaims, false)
|
||||
}
|
||||
|
||||
func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]normalizedIDTokenClaim, error) {
|
||||
rawList, ok := rawClaims.([]any)
|
||||
if !ok {
|
||||
if typedList, ok := rawClaims.([]map[string]any); ok {
|
||||
@@ -3154,6 +3436,9 @@ func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||
if namespace != "top_level" && namespace != "rp_claims" {
|
||||
return nil, fmt.Errorf("metadata.id_token_claims namespace must be top_level or rp_claims: %s", namespace)
|
||||
}
|
||||
if !allowTopLevel && namespace == "top_level" {
|
||||
return nil, errors.New("metadata.id_token_claims top_level namespace is managed from admin user custom claims")
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(readInterfaceString(record["key"], ""))
|
||||
if key == "" {
|
||||
@@ -3168,7 +3453,7 @@ func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||
valueType = "text"
|
||||
}
|
||||
switch valueType {
|
||||
case "text", "number", "boolean", "array", "object":
|
||||
case "text", "number", "boolean", "array", "object", "date", "datetime":
|
||||
default:
|
||||
return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType)
|
||||
}
|
||||
@@ -3185,10 +3470,12 @@ func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||
seen[signature] = struct{}{}
|
||||
|
||||
normalized = append(normalized, normalizedIDTokenClaim{
|
||||
Namespace: namespace,
|
||||
Key: key,
|
||||
Value: value,
|
||||
ValueType: valueType,
|
||||
Namespace: namespace,
|
||||
Key: key,
|
||||
Value: value,
|
||||
ValueType: valueType,
|
||||
ReadPermission: normalizeCustomClaimPermission(record["readPermission"]),
|
||||
WritePermission: normalizeCustomClaimPermission(record["writePermission"]),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3258,6 +3545,25 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
|
||||
return nil, errors.New("object value must be valid JSON object")
|
||||
}
|
||||
return parsed, nil
|
||||
case "date":
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("date value is required")
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", trimmed); err != nil {
|
||||
return nil, errors.New("date value must use YYYY-MM-DD")
|
||||
}
|
||||
return trimmed, nil
|
||||
case "datetime":
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("datetime value is required")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, trimmed); err == nil {
|
||||
return trimmed, nil
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02T15:04", trimmed); err == nil {
|
||||
return trimmed, nil
|
||||
}
|
||||
return nil, errors.New("datetime value must use RFC3339 or YYYY-MM-DDTHH:mm")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported claim value type: %s", valueType)
|
||||
}
|
||||
@@ -3614,10 +3920,11 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Organization string `json:"organization"`
|
||||
Reason string `json:"reason"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"`
|
||||
Organization string `json:"organization"`
|
||||
Reason string `json:"reason"`
|
||||
TenantID string `json:"tenantId"`
|
||||
AccessPages []string `json:"accessPages"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
@@ -3626,16 +3933,16 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
|
||||
if req.TenantID == "" && profile.TenantID != nil {
|
||||
req.TenantID = *profile.TenantID
|
||||
}
|
||||
if req.TenantID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId is required")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(profile.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(req.Name)
|
||||
}
|
||||
organization := strings.TrimSpace(req.Organization)
|
||||
if h.TenantSvc != nil {
|
||||
if organization == "" {
|
||||
organization = strings.TrimSpace(profile.CompanyCode)
|
||||
}
|
||||
if req.TenantID != "" && h.TenantSvc != nil {
|
||||
if tenant, err := h.TenantSvc.GetTenant(c.Context(), req.TenantID); err == nil && tenant != nil && strings.TrimSpace(tenant.Name) != "" {
|
||||
organization = strings.TrimSpace(tenant.Name)
|
||||
}
|
||||
@@ -3650,6 +3957,7 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
|
||||
Phone: profile.Phone,
|
||||
Role: normalizeUserRole(profile.Role),
|
||||
Reason: req.Reason,
|
||||
AccessPages: req.AccessPages,
|
||||
Status: domain.DeveloperRequestStatusPending,
|
||||
}
|
||||
|
||||
@@ -3670,9 +3978,6 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error {
|
||||
if tenantID == "" && profile.TenantID != nil {
|
||||
tenantID = *profile.TenantID
|
||||
}
|
||||
if tenantID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId is required")
|
||||
}
|
||||
|
||||
status, err := h.DeveloperSvc.GetRequestStatus(c.Context(), profile.ID, tenantID)
|
||||
if err != nil {
|
||||
@@ -3680,10 +3985,10 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if status == nil {
|
||||
return c.JSON(fiber.Map{"status": "none"})
|
||||
return c.JSON(domain.DeveloperAccessStatus{Status: "none"})
|
||||
}
|
||||
if status.Status == domain.DeveloperRequestStatusApproved {
|
||||
h.ensureDeveloperGrantRelation(c, status.UserID, status.TenantID)
|
||||
h.ensureDeveloperGrantRelation(c, profile.ID, tenantID)
|
||||
}
|
||||
|
||||
return c.JSON(status)
|
||||
@@ -3792,7 +4097,7 @@ func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error {
|
||||
userID = ""
|
||||
}
|
||||
|
||||
requests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, status)
|
||||
requests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, status, "")
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
@@ -3800,6 +4105,169 @@ func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error {
|
||||
return c.JSON(requests)
|
||||
}
|
||||
|
||||
func (h *DevHandler) ListDeveloperGrants(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
||||
}
|
||||
if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only")
|
||||
}
|
||||
|
||||
tenantID := strings.TrimSpace(c.Query("tenantId"))
|
||||
grants, err := h.DeveloperSvc.ListRequests(c.Context(), "", domain.DeveloperRequestStatusApproved, tenantID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(grants)
|
||||
}
|
||||
|
||||
func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
||||
}
|
||||
if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only")
|
||||
}
|
||||
|
||||
var reqBody struct {
|
||||
UserID string `json:"userId"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Reason string `json:"reason"`
|
||||
AdminNotes string `json:"adminNotes"`
|
||||
AccessPages []string `json:"accessPages"`
|
||||
}
|
||||
if err := c.BodyParser(&reqBody); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(reqBody.UserID)
|
||||
tenantID := strings.TrimSpace(reqBody.TenantID)
|
||||
if userID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "userId is required")
|
||||
}
|
||||
if h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "required services are unavailable")
|
||||
}
|
||||
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err != nil || identity == nil {
|
||||
return errorJSON(c, fiber.StatusNotFound, "user not found")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(extractTraitString(identity.Traits, "name"))
|
||||
if name == "" {
|
||||
name = userID
|
||||
}
|
||||
organization := strings.TrimSpace(extractTraitString(identity.Traits, "companyCode"))
|
||||
if tenantID != "" && h.TenantSvc != nil {
|
||||
tenant, err := h.TenantSvc.GetTenant(c.Context(), tenantID)
|
||||
if err != nil || tenant == nil {
|
||||
return errorJSON(c, fiber.StatusNotFound, "tenant not found")
|
||||
}
|
||||
if strings.TrimSpace(tenant.Name) != "" {
|
||||
organization = strings.TrimSpace(tenant.Name)
|
||||
} else if organization == "" {
|
||||
organization = tenantID
|
||||
}
|
||||
}
|
||||
email := strings.TrimSpace(extractTraitString(identity.Traits, "email"))
|
||||
phone := strings.TrimSpace(extractTraitString(identity.Traits, "phone"))
|
||||
role := normalizeUserRole(extractTraitString(identity.Traits, "role"))
|
||||
if role == "" {
|
||||
role = domain.RoleUser
|
||||
}
|
||||
reason := strings.TrimSpace(reqBody.Reason)
|
||||
if reason == "" {
|
||||
reason = "직접 부여"
|
||||
}
|
||||
|
||||
existingRequests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, "", tenantID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
for _, existing := range existingRequests {
|
||||
if !developerAccessPagesEqual(existing.AccessPages, reqBody.AccessPages) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch existing.Status {
|
||||
case domain.DeveloperRequestStatusApproved:
|
||||
h.ensureDeveloperGrantRelation(c, userID, tenantID)
|
||||
return c.JSON(existing)
|
||||
case domain.DeveloperRequestStatusPending:
|
||||
if err := h.DeveloperSvc.ApproveRequest(c.Context(), existing.ID, reqBody.AdminNotes); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.ensureDeveloperGrantRelation(c, userID, tenantID)
|
||||
existing.Status = domain.DeveloperRequestStatusApproved
|
||||
existing.AdminNotes = reqBody.AdminNotes
|
||||
return c.JSON(existing)
|
||||
}
|
||||
}
|
||||
|
||||
grant := domain.DeveloperRequest{
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Name: name,
|
||||
Organization: organization,
|
||||
Email: email,
|
||||
Phone: phone,
|
||||
Role: role,
|
||||
Reason: reason,
|
||||
AccessPages: reqBody.AccessPages,
|
||||
Status: domain.DeveloperRequestStatusApproved,
|
||||
AdminNotes: strings.TrimSpace(reqBody.AdminNotes),
|
||||
}
|
||||
if err := h.DeveloperSvc.CreateGrant(c.Context(), grant); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.ensureDeveloperGrantRelation(c, userID, tenantID)
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(grant)
|
||||
}
|
||||
|
||||
func (h *DevHandler) RevokeDeveloperGrant(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
||||
}
|
||||
if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid grant id")
|
||||
}
|
||||
|
||||
var reqBody struct {
|
||||
AdminNotes string `json:"adminNotes"`
|
||||
}
|
||||
if err := c.BodyParser(&reqBody); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id))
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch grant details")
|
||||
}
|
||||
if devReq.Status != domain.DeveloperRequestStatusApproved {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "only approved grants can be revoked")
|
||||
}
|
||||
|
||||
if err := h.DeveloperSvc.CancelApprovedRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
h.revokeDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID)
|
||||
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *DevHandler) ApproveDeveloperRequest(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type devMockRPUserMetadataRepo struct {
|
||||
@@ -40,6 +41,14 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvalLevel",
|
||||
"valueType": "text",
|
||||
"value": "A",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
@@ -50,7 +59,9 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
repo.On("Upsert", mock.Anything, mock.MatchedBy(func(row *domain.RPUserMetadata) bool {
|
||||
return row.ClientID == "client-1" &&
|
||||
row.UserID == "user-1" &&
|
||||
row.Metadata["approvalLevel"] == "A"
|
||||
row.Metadata["approvalLevel"] == "A" &&
|
||||
row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
|
||||
row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin"
|
||||
})).Return(nil).Once()
|
||||
repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{
|
||||
ClientID: "client-1",
|
||||
@@ -74,7 +85,12 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
app.Get("/api/v1/dev/clients/:id/users/:userId/metadata", h.GetRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"approvalLevel": "A"},
|
||||
"metadata": map[string]any{
|
||||
"approvalLevel": "A",
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
},
|
||||
})
|
||||
putReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
putReq.Header.Set("Content-Type", "application/json")
|
||||
@@ -92,3 +108,171 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
assert.Equal(t, "A", got["metadata"].(map[string]any)["approvalLevel"])
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvalLevel",
|
||||
"valueType": "text",
|
||||
"value": "A",
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "admin_only",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.RPUserMetadata")).Return(nil).Once()
|
||||
kratos := new(MockKratosAdmin)
|
||||
kratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{
|
||||
ID: "user-1",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User One",
|
||||
},
|
||||
}, nil).Once()
|
||||
var capturedTraits map[string]any
|
||||
kratos.On("UpdateIdentity", mock.Anything, "user-1", mock.Anything, "active").Run(func(args mock.Arguments) {
|
||||
capturedTraits = args.Get(2).(map[string]any)
|
||||
}).Return(&service.KratosIdentity{ID: "user-1", State: "active", Traits: map[string]any{}}, nil).Once()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
KratosAdmin: kratos,
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"approvalLevel": "B"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
rpClaims := capturedTraits["rp_custom_claims"].(map[string]any)
|
||||
clientClaims := rpClaims["client-1"].(domain.JSONMap)
|
||||
require.Equal(t, "B", clientClaims["approvalLevel"])
|
||||
require.Equal(t, map[string]any{
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "admin_only",
|
||||
}, clientClaims["approvalLevel_permissions"])
|
||||
repo.AssertExpectations(t)
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataRejectsUndefinedClaimKey(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contract_date",
|
||||
"valueType": "date",
|
||||
"value": "2026-06-09",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"unknown_claim": "A"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataRejectsInvalidTypedClaimValue(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contract_date",
|
||||
"valueType": "date",
|
||||
"value": "2026-06-09",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"contract_date": "2026/06/09"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,59 @@ func (m *devMockKetoService) ListObjects(ctx context.Context, ns, rel, sub strin
|
||||
return args.Get(0).([]string), args.Error(1)
|
||||
}
|
||||
|
||||
type devMockDeveloperService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error {
|
||||
args := m.Called(ctx, req)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperAccessStatus, error) {
|
||||
args := m.Called(ctx, userID, tenantID)
|
||||
if status, ok := args.Get(0).(*domain.DeveloperAccessStatus); ok {
|
||||
return status, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if req, ok := args.Get(0).(*domain.DeveloperRequest); ok {
|
||||
return req, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) ListRequests(ctx context.Context, userID, status, tenantID string) ([]domain.DeveloperRequest, error) {
|
||||
args := m.Called(ctx, userID, status, tenantID)
|
||||
if requests, ok := args.Get(0).([]domain.DeveloperRequest); ok {
|
||||
return requests, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) CreateGrant(ctx context.Context, req domain.DeveloperRequest) error {
|
||||
args := m.Called(ctx, req)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||
args := m.Called(ctx, id, adminNotes)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||
args := m.Called(ctx, id, adminNotes)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||
args := m.Called(ctx, id, adminNotes)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type devMockRedisRepo struct {
|
||||
data map[string]string
|
||||
}
|
||||
@@ -726,7 +779,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
|
||||
"tenant_id": "tenant-1",
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []any{"tenant-1", "tenant-2"},
|
||||
"id_token_claims": []any{map[string]any{"namespace": "top_level", "key": "locale", "valueType": "text", "value": "ko-KR"}},
|
||||
"id_token_claims": []any{map[string]any{"namespace": "rp_claims", "key": "locale", "valueType": "text", "value": "ko-KR"}},
|
||||
"headless_login_enabled": true,
|
||||
"headless_jwks_uri": "https://rp.example.com/jwks.json",
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
@@ -766,7 +819,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
|
||||
"allowed_tenants": []string{"tenant-1", "tenant-2"},
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "top_level",
|
||||
"namespace": "rp_claims",
|
||||
"key": "locale",
|
||||
"valueType": "text",
|
||||
"value": "ko-KR",
|
||||
@@ -1521,6 +1574,64 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestCreateClient_ApprovedDeveloperRequestAllowsCreateWhenTenantGrantNotVisible(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
body["client_secret"] = "generated-secret"
|
||||
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
|
||||
developerSvc := new(devMockDeveloperService)
|
||||
developerSvc.On("GetRequestStatus", mock.Anything, "user-1", "tenant-a").Return(&domain.DeveloperAccessStatus{
|
||||
Status: domain.DeveloperRequestStatusApproved,
|
||||
}, nil).Maybe()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
|
||||
Redis: &devMockRedisRepo{data: make(map[string]string)},
|
||||
Keto: mockKeto,
|
||||
DeveloperSvc: developerSvc,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"id": "client-1",
|
||||
"name": "App One",
|
||||
"type": "private",
|
||||
"redirectUris": []string{"http://localhost/cb"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
mockKeto.AssertExpectations(t)
|
||||
developerSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
@@ -2306,7 +2417,7 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"id": "claim-1",
|
||||
"namespace": "top_level",
|
||||
"namespace": "rp_claims",
|
||||
"key": "locale",
|
||||
"value": " ko-KR ",
|
||||
"valueType": "text",
|
||||
@@ -2331,7 +2442,7 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
||||
if assert.True(t, ok) && assert.Len(t, claims, 2) {
|
||||
first, ok := claims[0].(map[string]any)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "top_level", first["namespace"])
|
||||
assert.Equal(t, "rp_claims", first["namespace"])
|
||||
assert.Equal(t, "locale", first["key"])
|
||||
assert.Equal(t, "ko-KR", first["value"])
|
||||
assert.Equal(t, "text", first["valueType"])
|
||||
@@ -2393,7 +2504,7 @@ func TestCreateClient_RejectsInvalidIDTokenClaimsMetadata(t *testing.T) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
assert.Contains(t, string(bodyBytes), "top-level key rp_claims is reserved")
|
||||
assert.Contains(t, string(bodyBytes), "top_level namespace is managed from admin user custom claims")
|
||||
assert.False(t, hydraCalled)
|
||||
}
|
||||
|
||||
@@ -3134,6 +3245,147 @@ func TestListConsents_UserAllowedByRPAdminsRelation(t *testing.T) {
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestListConsents_IncludesRPUserMetadata(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Get", mock.Anything, "client-1", "subject-1").Return(&domain.RPUserMetadata{
|
||||
ClientID: "client-1",
|
||||
UserID: "subject-1",
|
||||
Metadata: domain.JSONMap{
|
||||
"approvalLevel": "A",
|
||||
"reviewedAt": "2026-06-09T09:30:00+09:00",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
ConsentRepo: &mockConsentRepo{
|
||||
consents: []domain.ClientConsent{
|
||||
{
|
||||
ClientID: "client-1",
|
||||
Subject: "subject-1",
|
||||
GrantedScopes: []string{"openid", "profile"},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
},
|
||||
},
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/consents", h.ListConsents)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/consents?client_id=client-1", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result consentListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
if assert.Len(t, result.Items, 1) {
|
||||
assert.Equal(t, domain.JSONMap{
|
||||
"approvalLevel": "A",
|
||||
"reviewedAt": "2026-06-09T09:30:00+09:00",
|
||||
}, result.Items[0].RPMetadata)
|
||||
}
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestNormalizeIDTokenClaimsMetadata_AllowsDateAndDatetime(t *testing.T) {
|
||||
metadata, err := normalizeIDTokenClaimsMetadata(map[string]any{
|
||||
domain.MetadataIDTokenClaims: []any{
|
||||
map[string]any{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contract_date",
|
||||
"value": "2026-06-09",
|
||||
"valueType": "date",
|
||||
},
|
||||
map[string]any{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approved_at",
|
||||
"value": "2026-06-09T09:30:00+09:00",
|
||||
"valueType": "datetime",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
claims := metadata[domain.MetadataIDTokenClaims].([]normalizedIDTokenClaim)
|
||||
assert.Equal(t, "date", claims[0].ValueType)
|
||||
assert.Equal(t, "datetime", claims[1].ValueType)
|
||||
}
|
||||
|
||||
func TestUpdateClient_RejectsTopLevelIDTokenClaimsFromDevConsole(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"redirect_uris": []string{"http://localhost/cb"},
|
||||
"grant_types": []string{"authorization_code"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"metadata": map[string]any{"status": "active"},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
||||
t.Fatalf("hydra update should not be called for top-level id token claims")
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{
|
||||
domain.MetadataIDTokenClaims: []any{
|
||||
map[string]any{
|
||||
"namespace": "top_level",
|
||||
"key": "employee_id",
|
||||
"value": "EMP001",
|
||||
"valueType": "text",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user