forked from baron/baron-sso
chore: consolidate local integration changes
This commit is contained in:
@@ -156,8 +156,9 @@ jobs:
|
||||
- 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 ..
|
||||
|
||||
@@ -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 }}
|
||||
@@ -133,12 +134,14 @@ jobs:
|
||||
ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}
|
||||
KRATOS_ALLOWED_RETURN_URLS_JSON=${{ vars.KRATOS_ALLOWED_RETURN_URLS_JSON }}
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.KRATOS_ALLOWED_RETURN_URLS_EXTRA }}
|
||||
STAGING_PUBLIC_HEALTH_URL=${{ vars.STAGING_PUBLIC_HEALTH_URL }}
|
||||
STAGING_PUBLIC_HEALTH_MAX_ATTEMPTS=${{ vars.STAGING_PUBLIC_HEALTH_MAX_ATTEMPTS }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
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
|
||||
@@ -224,36 +227,12 @@ jobs:
|
||||
return 1
|
||||
}
|
||||
|
||||
check_public_http() {
|
||||
url="$1"
|
||||
if [ -z "${url}" ]; then
|
||||
echo "ERROR: STAGING_PUBLIC_HEALTH_URL is required." >&2
|
||||
return 1
|
||||
fi
|
||||
max="${STAGING_PUBLIC_HEALTH_MAX_ATTEMPTS:-30}"
|
||||
i=1
|
||||
while [ "${i}" -le "${max}" ]; do
|
||||
if curl -fsS --max-time 10 "${url}" >/dev/null; then
|
||||
echo "Public staging URL ready: ${url}"
|
||||
return 0
|
||||
fi
|
||||
echo "Waiting for public staging URL: ${url} (${i}/${max})"
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
echo "ERROR: public staging URL not ready: ${url}" >&2
|
||||
docker compose -f staging_pull_compose.yaml ps >&2 || true
|
||||
docker logs baron_gateway --tail 200 >&2 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
check_container_url baron_backend http://127.0.0.1:3000/health
|
||||
check_container_http baron_userfront 5000
|
||||
check_container_http baron_gateway 5000
|
||||
check_container_http baron_adminfront 5173
|
||||
check_container_http baron_devfront 5173
|
||||
check_container_http baron_orgfront 5175
|
||||
check_public_http "${STAGING_PUBLIC_HEALTH_URL}"
|
||||
|
||||
echo "===== INIT-RP LOGS ====="
|
||||
docker compose -f staging_pull_compose.yaml logs init-rp || true
|
||||
|
||||
@@ -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,11 +143,16 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
53
README.md
53
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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -239,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, {
|
||||
|
||||
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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => ({
|
||||
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,32 @@ 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 () => ({
|
||||
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,33 @@ 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 +98,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 () => {
|
||||
const flushMutation = useMutation({
|
||||
mutationFn: flushIdentityCache,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["user-projection-status"],
|
||||
queryKey: ["ory-ssot-system-status"],
|
||||
});
|
||||
};
|
||||
|
||||
const reconcileMutation = useMutation({
|
||||
mutationFn: reconcileUserProjection,
|
||||
onSuccess: invalidate,
|
||||
},
|
||||
});
|
||||
|
||||
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}
|
||||
onClick={handleFlush}
|
||||
disabled={flushMutation.isPending}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
|
||||
<Trash2 size={16} />
|
||||
{t(
|
||||
"ui.admin.ory_ssot.actions.flush_identity_cache",
|
||||
"Redis cache flush",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ import {
|
||||
} from "../utils/tenantCsvImport";
|
||||
import {
|
||||
filterTenantsByScope,
|
||||
getTenantSearchMatchIds,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
type TenantViewMode,
|
||||
@@ -842,6 +843,7 @@ function TenantListPage() {
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
name="tenant-import-file"
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
@@ -1368,6 +1370,8 @@ function TenantListPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select
|
||||
id={`tenant-import-parent-select-${preview.row.rowNumber}`}
|
||||
name={`tenant-import-parent-select-${preview.row.rowNumber}`}
|
||||
className="h-9 w-full min-w-[220px] rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={
|
||||
selectedParentRefs[preview.row.rowNumber] ??
|
||||
@@ -1457,6 +1461,8 @@ function TenantListPage() {
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
id={`tenant-import-match-select-${preview.row.rowNumber}`}
|
||||
name={`tenant-import-match-select-${preview.row.rowNumber}`}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={
|
||||
selectedMatches[preview.row.rowNumber] ??
|
||||
@@ -1705,6 +1711,10 @@ const TenantHierarchyView: React.FC<{
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const shouldVirtualizeRows = !(isTest && flattenedRows.length < 100);
|
||||
const searchMatchIds = React.useMemo(
|
||||
() => new Set(getTenantSearchMatchIds(flattenedRows, search)),
|
||||
[flattenedRows, search],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isTest) return;
|
||||
@@ -1757,6 +1767,7 @@ const TenantHierarchyView: React.FC<{
|
||||
virtualRow?: { start: number; end: number },
|
||||
) => {
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const isSearchMatch = searchMatchIds.has(node.id);
|
||||
const hasChildren =
|
||||
viewMode === "tree" && node.children && node.children.length > 0;
|
||||
const isExpanded =
|
||||
@@ -1770,6 +1781,9 @@ const TenantHierarchyView: React.FC<{
|
||||
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
|
||||
className={cn(
|
||||
isSelected ? "bg-primary/5" : "",
|
||||
isSearchMatch
|
||||
? "bg-amber-50/80 ring-1 ring-inset ring-amber-300"
|
||||
: "",
|
||||
"h-[73px]",
|
||||
virtualRow ? "absolute left-0 w-full" : "",
|
||||
)}
|
||||
@@ -1851,6 +1865,15 @@ const TenantHierarchyView: React.FC<{
|
||||
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
||||
</Badge>
|
||||
)}
|
||||
{isSearchMatch && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex-shrink-0 border-amber-300 bg-amber-100 text-[10px] font-semibold text-amber-900"
|
||||
data-testid={`tenant-search-match-${node.id}`}
|
||||
>
|
||||
{t("ui.admin.tenants.search_match_badge", "검색 일치")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const parentPath = tenantParentPathMap.get(node.id) ?? [];
|
||||
|
||||
@@ -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}`}>
|
||||
<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", "기존 멤버 배정")}
|
||||
</Link>
|
||||
</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
|
||||
: 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">
|
||||
<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">
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
Baron ID
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("baron") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
Baron
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("baronOrg") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
{baronOrgColumnLabel}
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("externalKey") && (
|
||||
<TableHead className="min-w-40 whitespace-nowrap">
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
external_key
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobileDomain") && (
|
||||
<TableHead className="min-w-28 whitespace-nowrap">
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
WORKS 도메인
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobile") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
<TableHead className={worksmobileComparisonTableHeadClassName}>
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
WORKS
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobileOrg") && (
|
||||
<TableHead className="min-w-52 whitespace-nowrap">
|
||||
<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"
|
||||
|
||||
317
adminfront/src/features/users/GlobalCustomClaimsPage.tsx
Normal file
317
adminfront/src/features/users/GlobalCustomClaimsPage.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
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 || ""}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -720,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,43 @@ 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,
|
||||
@@ -111,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;
|
||||
|
||||
@@ -179,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[],
|
||||
@@ -408,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,
|
||||
);
|
||||
@@ -449,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,
|
||||
@@ -757,6 +859,9 @@ function UserDetailPage() {
|
||||
? "hanmac"
|
||||
: "external";
|
||||
setUserCategory(resolvedUserCategory);
|
||||
setGlobalCustomClaimRows(
|
||||
createGlobalCustomClaimRows(metadata, globalCustomClaimDefinitions),
|
||||
);
|
||||
const familyFallbackTenants = [
|
||||
...(user.joinedTenants ?? []),
|
||||
...(user.tenant ? [user.tenant] : []),
|
||||
@@ -814,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),
|
||||
@@ -963,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;
|
||||
@@ -1121,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"
|
||||
@@ -1793,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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -420,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],
|
||||
@@ -640,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
|
||||
@@ -963,6 +1019,8 @@ function UserListPage() {
|
||||
virtualRows.map((virtualRow) => {
|
||||
const user = items[virtualRow.index];
|
||||
if (!user) return null;
|
||||
const additionalTenantLabels =
|
||||
collectAdditionalTenantLabels(user);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -1102,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,13 +1094,20 @@ 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,
|
||||
})
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,50 @@ 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"
|
||||
|
||||
@@ -1357,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"
|
||||
@@ -1393,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"
|
||||
|
||||
@@ -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 = "상태 갱신"
|
||||
|
||||
@@ -1360,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 = "개발팀"
|
||||
@@ -1396,6 +1410,9 @@ additional = "추가 소속/관리 테넌트"
|
||||
primary = "대표 소속 테넌트"
|
||||
title = "소속 및 조직 정보"
|
||||
|
||||
[ui.admin.users.global_custom_claims]
|
||||
manage_definitions = "전역 정의 관리"
|
||||
|
||||
[ui.admin.users.list]
|
||||
add = "사용자 추가"
|
||||
add_to_tenant = "테넌트에 추가"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
40
adminfront/src/test/formFieldDiagnostics.test.ts
Normal file
40
adminfront/src/test/formFieldDiagnostics.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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");
|
||||
let match: RegExpExecArray | null;
|
||||
while ((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([]);
|
||||
});
|
||||
});
|
||||
26
adminfront/src/test/formFieldDiagnostics.ts
Normal file
26
adminfront/src/test/formFieldDiagnostics.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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,37 @@ 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 +90,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 +135,37 @@ 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 +181,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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
||||
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"]'),
|
||||
page.locator('a[href="/system/ory-ssot"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('a[href="/system/data-integrity"]'),
|
||||
@@ -209,7 +209,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"]'),
|
||||
|
||||
@@ -121,6 +121,105 @@ 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"',
|
||||
},
|
||||
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,
|
||||
}) => {
|
||||
@@ -141,7 +240,8 @@ test.describe("Tenants Management", () => {
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
memberCount: 3,
|
||||
totalMemberCount: 9,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
@@ -151,7 +251,8 @@ test.describe("Tenants Management", () => {
|
||||
status: "active",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "company-1",
|
||||
memberCount: 0,
|
||||
memberCount: 4,
|
||||
totalMemberCount: 6,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
@@ -161,19 +262,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({
|
||||
@@ -192,7 +305,14 @@ test.describe("Tenants Management", () => {
|
||||
await page
|
||||
.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
|
||||
@@ -226,7 +346,8 @@ test.describe("Tenants Management", () => {
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
memberCount: 3,
|
||||
totalMemberCount: 9,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
@@ -236,7 +357,8 @@ test.describe("Tenants Management", () => {
|
||||
status: "active",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "company-1",
|
||||
memberCount: 0,
|
||||
memberCount: 4,
|
||||
totalMemberCount: 6,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
@@ -246,7 +368,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 +403,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");
|
||||
@@ -743,16 +871,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 +977,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 +1002,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,185 @@ test.describe("User Management", () => {
|
||||
await expect(page.getByText(/저장/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should manage global custom claim permissions in user detail", async ({
|
||||
page,
|
||||
}) => {
|
||||
let updatePayload: Record<string, unknown> | undefined;
|
||||
|
||||
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.getByTestId("global-custom-claim-key-contract_date"),
|
||||
).toHaveValue("contract_date");
|
||||
await expect(
|
||||
page.getByTestId("global-custom-claim-read-permission-contract_date"),
|
||||
).toHaveValue("user_and_admin");
|
||||
await expect(
|
||||
page.getByTestId("global-custom-claim-write-permission-contract_date"),
|
||||
).toHaveValue("admin_only");
|
||||
|
||||
await page
|
||||
.getByTestId("global-custom-claim-write-permission-contract_date")
|
||||
.selectOption("user_and_admin");
|
||||
|
||||
await page.screenshot({
|
||||
path: "test-results/adminfront-global-custom-claim-permissions.png",
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /전역 Claim 저장|Save Global Claim/i })
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() => updatePayload)
|
||||
.toMatchObject({
|
||||
metadata: {
|
||||
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: "user_and_admin",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
}) => {
|
||||
|
||||
@@ -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,228 @@ 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!" }),
|
||||
},
|
||||
]);
|
||||
|
||||
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 +898,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 +1035,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 +1091,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 +1134,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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,66 @@ 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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
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,13 +11,20 @@ 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 {
|
||||
DB *gorm.DB
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
@@ -25,10 +32,25 @@ type AdminHandler struct {
|
||||
Hydra adminHydraClientLister
|
||||
AuditRepo domain.AuditRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
UserProjectionSyncer service.UserProjectionReconciler
|
||||
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 {
|
||||
return &AdminHandler{
|
||||
Keto: keto,
|
||||
@@ -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.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
|
||||
type fakeIdentityCacheAdmin struct {
|
||||
status domain.IdentityCacheStatus
|
||||
flush domain.IdentityCacheFlushResult
|
||||
err error
|
||||
calls int
|
||||
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{}
|
||||
@@ -4666,7 +4685,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 +4704,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 +5935,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 +7632,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 +7944,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -187,6 +187,7 @@ type consentSummary struct {
|
||||
Status string `json:"status"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantName string `json:"tenantName,omitempty"`
|
||||
RPMetadata domain.JSONMap `json:"rpMetadata,omitempty"`
|
||||
}
|
||||
|
||||
type consentListResponse struct {
|
||||
@@ -221,6 +222,8 @@ type normalizedIDTokenClaim struct {
|
||||
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{}{
|
||||
@@ -1535,19 +1538,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 +2448,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 +2466,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
Status: status,
|
||||
TenantID: consent.TenantID,
|
||||
TenantName: consent.TenantName,
|
||||
RPMetadata: rpMetadata,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3107,7 +3301,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 +3310,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 +3356,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 +3373,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)
|
||||
}
|
||||
@@ -3189,6 +3394,8 @@ func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||
Key: key,
|
||||
Value: value,
|
||||
ValueType: valueType,
|
||||
ReadPermission: normalizeCustomClaimPermission(record["readPermission"]),
|
||||
WritePermission: normalizeCustomClaimPermission(record["writePermission"]),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3258,6 +3465,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -726,7 +726,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 +766,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",
|
||||
@@ -2306,7 +2306,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 +2331,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 +2393,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 +3134,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" {
|
||||
|
||||
@@ -10,12 +10,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +31,7 @@ type TenantHandler struct {
|
||||
Service service.TenantService
|
||||
UserRepo repository.UserRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
OrgChartCache orgChartCacheStore
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
@@ -37,6 +41,11 @@ type TenantHandler struct {
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
}
|
||||
|
||||
type orgChartCacheStore interface {
|
||||
Get(key string) (string, error)
|
||||
Set(key string, value string, expiration time.Duration) error
|
||||
}
|
||||
|
||||
func seedTenantDeleteError(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
|
||||
}
|
||||
@@ -84,6 +93,7 @@ type tenantSummary struct {
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Config domain.JSONMap `json:"config,omitempty"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
TotalMemberCount int64 `json:"totalMemberCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
@@ -97,6 +107,18 @@ type tenantListResponse struct {
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
type orgChartSnapshotCacheInfo struct {
|
||||
Source string `json:"source"`
|
||||
Hit bool `json:"hit"`
|
||||
TTLSeconds int `json:"ttlSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type orgChartSnapshotResponse struct {
|
||||
Tenants []tenantSummary `json:"tenants"`
|
||||
Users []userSummary `json:"users"`
|
||||
Cache orgChartSnapshotCacheInfo `json:"cache"`
|
||||
}
|
||||
|
||||
func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) ([]domain.Tenant, string, error) {
|
||||
ordered := append([]domain.Tenant(nil), tenants...)
|
||||
pagination.SortByKeyDesc(ordered, func(tenant domain.Tenant) (time.Time, string) {
|
||||
@@ -360,7 +382,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
memberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
@@ -369,6 +391,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
for _, t := range tenants {
|
||||
summary := mapTenantSummary(t)
|
||||
summary.MemberCount = memberCounts[t.ID]
|
||||
summary.TotalMemberCount = totalMemberCounts[t.ID]
|
||||
items = append(items, summary)
|
||||
}
|
||||
|
||||
@@ -1656,13 +1679,14 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
memberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
|
||||
summary := mapTenantSummary(tenant)
|
||||
summary.MemberCount = memberCounts[tenant.ID]
|
||||
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
|
||||
|
||||
return c.JSON(summary)
|
||||
}
|
||||
@@ -1748,6 +1772,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
|
||||
summary := mapTenantSummary(*tenant)
|
||||
summary.MemberCount = 0
|
||||
summary.TotalMemberCount = 0
|
||||
|
||||
if req.Config != nil {
|
||||
config, err := normalizeTenantConfig(req.Config)
|
||||
@@ -2658,25 +2683,33 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
|
||||
return build(rootID)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
|
||||
counts := make(map[string]int64, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
counts[tenant.ID] = 0
|
||||
}
|
||||
if len(tenants) == 0 {
|
||||
return counts, nil
|
||||
return counts, counts, nil
|
||||
}
|
||||
if h.UserProjectionRepo == nil {
|
||||
return nil, errors.New("user projection is not configured")
|
||||
return nil, nil, errors.New("user projection is not configured")
|
||||
}
|
||||
ready, err := h.UserProjectionRepo.IsReady(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user projection status unavailable: %w", err)
|
||||
return nil, nil, fmt.Errorf("user projection status unavailable: %w", err)
|
||||
}
|
||||
if !ready {
|
||||
return nil, errors.New("user projection is not ready")
|
||||
return nil, nil, errors.New("user projection is not ready")
|
||||
}
|
||||
return h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
|
||||
directCounts, err := h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
totalCounts, err := h.UserProjectionRepo.CountTenantMembersRecursive(ctx, tenants)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return directCounts, totalCounts, nil
|
||||
}
|
||||
|
||||
func normalizeTenantStatus(value string) string {
|
||||
@@ -2736,6 +2769,230 @@ func (h *TenantHandler) DeleteShareLink(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"message": "Share link deleted successfully"})
|
||||
}
|
||||
|
||||
func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
|
||||
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
|
||||
ttl := orgChartSnapshotCacheTTL()
|
||||
|
||||
if cacheMode == "redis" && h.OrgChartCache != nil {
|
||||
if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" {
|
||||
var cached orgChartSnapshotResponse
|
||||
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
|
||||
cached.Cache = orgChartSnapshotCacheInfo{
|
||||
Source: "redis",
|
||||
Hit: true,
|
||||
TTLSeconds: int(ttl.Seconds()),
|
||||
}
|
||||
c.Set("X-Orgfront-Cache", "HIT")
|
||||
return c.JSON(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot, err := h.buildOrgChartSnapshot(c.Context(), profile)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
snapshot.Cache = orgChartSnapshotCacheInfo{
|
||||
Source: "database",
|
||||
Hit: false,
|
||||
TTLSeconds: int(ttl.Seconds()),
|
||||
}
|
||||
|
||||
if cacheMode == "redis" && h.OrgChartCache != nil {
|
||||
if raw, err := json.Marshal(snapshot); err == nil {
|
||||
_ = h.OrgChartCache.Set(cacheKey, string(raw), ttl)
|
||||
}
|
||||
c.Set("X-Orgfront-Cache", "MISS")
|
||||
} else {
|
||||
c.Set("X-Orgfront-Cache", "BYPASS")
|
||||
}
|
||||
|
||||
return c.JSON(snapshot)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *domain.UserProfileResponse) (orgChartSnapshotResponse, error) {
|
||||
tenants, err := h.listOrgChartTenantsForProfile(ctx, profile)
|
||||
if err != nil {
|
||||
return orgChartSnapshotResponse{}, err
|
||||
}
|
||||
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(ctx, tenants)
|
||||
if err != nil {
|
||||
return orgChartSnapshotResponse{}, err
|
||||
}
|
||||
|
||||
tenantSummaries := make([]tenantSummary, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
summary := mapTenantSummary(tenant)
|
||||
summary.MemberCount = memberCounts[tenant.ID]
|
||||
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
|
||||
tenantSummaries = append(tenantSummaries, summary)
|
||||
}
|
||||
|
||||
users, err := h.listOrgChartUsers(ctx, profile, tenants)
|
||||
if err != nil {
|
||||
return orgChartSnapshotResponse{}, err
|
||||
}
|
||||
|
||||
return orgChartSnapshotResponse{
|
||||
Tenants: tenantSummaries,
|
||||
Users: users,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profile *domain.UserProfileResponse) ([]domain.Tenant, error) {
|
||||
if h.Service == nil {
|
||||
return nil, errors.New("tenant service is not configured")
|
||||
}
|
||||
role := ""
|
||||
if profile != nil {
|
||||
role = domain.NormalizeRole(profile.Role)
|
||||
}
|
||||
if role == domain.RoleSuperAdmin {
|
||||
tenants, _, err := h.Service.ListTenants(ctx, 10000, 0, "", "")
|
||||
return tenants, err
|
||||
}
|
||||
|
||||
allTenants, _, err := h.Service.ListTenants(ctx, 10000, 0, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile == nil {
|
||||
return []domain.Tenant{}, nil
|
||||
}
|
||||
|
||||
baseTenantIDs := make([]string, 0, len(profile.ManageableTenants)+len(profile.JoinedTenants)+1)
|
||||
for _, tenant := range profile.ManageableTenants {
|
||||
baseTenantIDs = append(baseTenantIDs, tenant.ID)
|
||||
}
|
||||
for _, tenant := range profile.JoinedTenants {
|
||||
baseTenantIDs = append(baseTenantIDs, tenant.ID)
|
||||
}
|
||||
if profile.TenantID != nil {
|
||||
baseTenantIDs = append(baseTenantIDs, *profile.TenantID)
|
||||
}
|
||||
|
||||
parentMap := make(map[string]string)
|
||||
for _, tenant := range allTenants {
|
||||
if tenant.ParentID != nil {
|
||||
parentMap[tenant.ID] = *tenant.ParentID
|
||||
}
|
||||
}
|
||||
findRoot := func(id string) string {
|
||||
curr := id
|
||||
for {
|
||||
parentID, exists := parentMap[curr]
|
||||
if !exists || parentID == "" {
|
||||
return curr
|
||||
}
|
||||
curr = parentID
|
||||
}
|
||||
}
|
||||
|
||||
roots := make(map[string]bool)
|
||||
for _, id := range baseTenantIDs {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
roots[findRoot(id)] = true
|
||||
}
|
||||
}
|
||||
|
||||
tenants := make([]domain.Tenant, 0, len(allTenants))
|
||||
for _, tenant := range allTenants {
|
||||
if roots[findRoot(tenant.ID)] {
|
||||
tenants = append(tenants, tenant)
|
||||
}
|
||||
}
|
||||
|
||||
return h.filterPrivateTenantsForProfile(ctx, tenants, profile)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) listOrgChartUsers(ctx context.Context, profile *domain.UserProfileResponse, tenants []domain.Tenant) ([]userSummary, error) {
|
||||
if h.UserRepo == nil {
|
||||
return nil, errors.New("user repository is not configured")
|
||||
}
|
||||
role := ""
|
||||
if profile != nil {
|
||||
role = domain.NormalizeRole(profile.Role)
|
||||
}
|
||||
tenantIDs := []string{}
|
||||
if role != domain.RoleSuperAdmin {
|
||||
tenantIDs = make([]string, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
tenantIDs = append(tenantIDs, tenant.ID)
|
||||
}
|
||||
}
|
||||
|
||||
users, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", tenantIDs, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summaries := make([]userSummary, 0, len(users))
|
||||
for _, user := range users {
|
||||
summary := userSummary{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
LoginID: user.Email,
|
||||
Name: user.Name,
|
||||
Phone: user.Phone,
|
||||
Role: domain.NormalizeRole(user.Role),
|
||||
Status: normalizeStatus(user.Status),
|
||||
TenantSlug: userTenantSlug(user),
|
||||
CompanyCode: userTenantSlug(user),
|
||||
Metadata: user.Metadata,
|
||||
Tenant: user.Tenant,
|
||||
Department: user.Department,
|
||||
Grade: user.Grade,
|
||||
Position: user.Position,
|
||||
JobTitle: user.JobTitle,
|
||||
CreatedAt: formatTime(user.CreatedAt),
|
||||
UpdatedAt: formatTime(user.UpdatedAt),
|
||||
}
|
||||
if h.Service != nil {
|
||||
if joined, err := h.Service.ListJoinedTenants(ctx, user.ID); err == nil {
|
||||
summary.JoinedTenants = joined
|
||||
}
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader string) string {
|
||||
role := "anonymous"
|
||||
userID := "anonymous"
|
||||
tenantID := strings.TrimSpace(tenantHeader)
|
||||
if profile != nil {
|
||||
role = domain.NormalizeRole(profile.Role)
|
||||
userID = strings.TrimSpace(profile.ID)
|
||||
if tenantID == "" && profile.TenantID != nil {
|
||||
tenantID = strings.TrimSpace(*profile.TenantID)
|
||||
}
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
}
|
||||
if tenantID == "" {
|
||||
tenantID = "none"
|
||||
}
|
||||
return fmt.Sprintf("orgchart:snapshot:v1:%s:%s:%s", role, userID, tenantID)
|
||||
}
|
||||
|
||||
func orgChartSnapshotCacheTTL() time.Duration {
|
||||
const defaultTTL = 5 * time.Minute
|
||||
raw := strings.TrimSpace(os.Getenv("ORGFRONT_ORGCHART_CACHE_TTL_SECONDS"))
|
||||
if raw == "" {
|
||||
return defaultTTL
|
||||
}
|
||||
seconds, err := strconv.Atoi(raw)
|
||||
if err != nil || seconds <= 0 {
|
||||
return defaultTTL
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -190,6 +191,25 @@ type MockUserProjectionRepoForHandler struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type mockOrgChartCache struct {
|
||||
mock.Mock
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (m *mockOrgChartCache) Get(key string) (string, error) {
|
||||
args := m.Called(key)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockOrgChartCache) Set(key string, value string, expiration time.Duration) error {
|
||||
if m.values == nil {
|
||||
m.values = make(map[string]string)
|
||||
}
|
||||
m.values[key] = value
|
||||
args := m.Called(key, value, expiration)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) IsReady(ctx context.Context) (bool, error) {
|
||||
args := m.Called(ctx)
|
||||
return args.Bool(0), args.Error(1)
|
||||
@@ -208,6 +228,14 @@ func (m *MockUserProjectionRepoForHandler) CountTenantMembers(ctx context.Contex
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
args := m.Called(ctx, tenants)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
args := m.Called(ctx, users)
|
||||
return args.Error(0)
|
||||
@@ -278,6 +306,8 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 7}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -289,6 +319,135 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
|
||||
|
||||
require.Len(t, res.Items, 1)
|
||||
assert.Equal(t, int64(2), res.Items[0].MemberCount)
|
||||
assert.Equal(t, int64(7), res.Items[0].TotalMemberCount)
|
||||
mockProjection.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
|
||||
app := fiber.New()
|
||||
cache := &mockOrgChartCache{}
|
||||
cached := `{"tenants":[{"id":"family","type":"COMPANY_GROUP","name":"한맥가족","slug":"hanmac-family","description":"","status":"active","memberCount":0,"totalMemberCount":2,"createdAt":"2026-06-09T00:00:00Z","updatedAt":"2026-06-09T00:00:00Z"}],"users":[],"cache":{"source":"redis","hit":true}}`
|
||||
cache.On("Get", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "orgchart:snapshot:")
|
||||
})).Return(cached, nil).Once()
|
||||
|
||||
h := &TenantHandler{OrgChartCache: cache}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot?cache=redis", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "HIT", resp.Header.Get("X-Orgfront-Cache"))
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, "redis", body["cache"].(map[string]any)["source"])
|
||||
cache.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
cache := &mockOrgChartCache{}
|
||||
now := time.Date(2026, 6, 9, 0, 0, 0, 0, time.UTC)
|
||||
familyID := "family"
|
||||
samanID := "saman"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: samanID, Type: domain.TenantTypeCompany, Name: "삼안", Slug: "saman", ParentID: &familyID, Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
users := []domain.User{
|
||||
{ID: "user-1", Email: "user@example.com", Name: "User One", Role: domain.RoleUser, Status: "active", TenantID: &samanID, Tenant: &tenants[1], CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
|
||||
cache.On("Get", mock.Anything).Return("", redis.Nil).Once()
|
||||
cache.On("Set", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "orgchart:snapshot:")
|
||||
}), mock.Anything, mock.AnythingOfType("time.Duration")).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
|
||||
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1]}, nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{familyID: 1, samanID: 1}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return(users, int64(1), "", nil).Once()
|
||||
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection, OrgChartCache: cache}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot?cache=redis", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "MISS", resp.Header.Get("X-Orgfront-Cache"))
|
||||
var body struct {
|
||||
Tenants []tenantSummary `json:"tenants"`
|
||||
Users []userSummary `json:"users"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Len(t, body.Tenants, 2)
|
||||
require.Len(t, body.Users, 1)
|
||||
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
|
||||
cache.AssertExpectations(t)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockProjection.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserProjectionRepo: mockProjection,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: "super_admin",
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
|
||||
parentID := "00000000-0000-0000-0000-000000000001"
|
||||
childID := "00000000-0000-0000-0000-000000000002"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: parentID, Name: "Parent", Slug: "parent"},
|
||||
{ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(2), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
Return(map[string]int64{parentID: 3, childID: 2}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res tenantListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Len(t, res.Items, 2)
|
||||
assert.Equal(t, int64(1), res.Items[0].MemberCount)
|
||||
assert.Equal(t, int64(3), res.Items[0].TotalMemberCount)
|
||||
assert.Equal(t, int64(2), res.Items[1].MemberCount)
|
||||
assert.Equal(t, int64(2), res.Items[1].TotalMemberCount)
|
||||
mockProjection.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -321,6 +480,7 @@ func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *te
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
mockProjection.AssertNotCalled(t, "CountTenantMembers", mock.Anything, mock.Anything)
|
||||
mockProjection.AssertNotCalled(t, "CountTenantMembersRecursive", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
@@ -350,6 +510,8 @@ func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -399,6 +561,7 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
|
||||
mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -468,6 +631,9 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -517,6 +683,9 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -936,6 +1105,8 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
|
||||
mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"saman"}).
|
||||
Return(map[string]int64{"saman": 152}, nil).Maybe()
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OryProviderAPI defines the subset of Ory Provider used by UserHandler
|
||||
@@ -99,6 +100,76 @@ func sanitizeUserMetadata(metadata map[string]any) map[string]any {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func userAppointmentSliceFromRaw(raw any) []any {
|
||||
switch values := raw.(type) {
|
||||
case []any:
|
||||
return append([]any(nil), values...)
|
||||
case []map[string]any:
|
||||
appointments := make([]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
appointments = append(appointments, value)
|
||||
}
|
||||
return appointments
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func userAppointmentTenantKey(raw any) string {
|
||||
appointment, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if value := normalizeMetadataString(appointment["tenantId"]); value != "" {
|
||||
return "id:" + strings.ToLower(value)
|
||||
}
|
||||
if value := normalizeMetadataString(appointment["tenantSlug"]); value != "" {
|
||||
return "slug:" + strings.ToLower(value)
|
||||
}
|
||||
if value := normalizeMetadataString(appointment["slug"]); value != "" {
|
||||
return "slug:" + strings.ToLower(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mergeUserAddTenantAppointment(traits map[string]any, metadata map[string]any, tenant *domain.Tenant) map[string]any {
|
||||
if tenant == nil {
|
||||
return metadata
|
||||
}
|
||||
if metadata == nil {
|
||||
metadata = map[string]any{}
|
||||
}
|
||||
|
||||
appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
|
||||
if len(appointments) == 0 {
|
||||
if legacyMetadata, ok := traits["metadata"].(map[string]any); ok {
|
||||
appointments = userAppointmentSliceFromRaw(legacyMetadata["additionalAppointments"])
|
||||
}
|
||||
}
|
||||
if incoming := userAppointmentSliceFromRaw(metadata["additionalAppointments"]); len(incoming) > 0 {
|
||||
appointments = incoming
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(appointments)+1)
|
||||
for _, appointment := range appointments {
|
||||
if key := userAppointmentTenantKey(appointment); key != "" {
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
tenantIDKey := "id:" + strings.ToLower(strings.TrimSpace(tenant.ID))
|
||||
tenantSlugKey := "slug:" + strings.ToLower(strings.TrimSpace(tenant.Slug))
|
||||
if !seen[tenantIDKey] && !seen[tenantSlugKey] {
|
||||
appointments = append(appointments, map[string]any{
|
||||
"tenantId": tenant.ID,
|
||||
"tenantSlug": tenant.Slug,
|
||||
"tenantName": tenant.Name,
|
||||
"isPrimary": false,
|
||||
})
|
||||
}
|
||||
metadata["additionalAppointments"] = appointments
|
||||
return metadata
|
||||
}
|
||||
|
||||
func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService service.TenantService, metadata map[string]any, appointments []map[string]any) (bool, error) {
|
||||
if tenantService == nil || metadata == nil {
|
||||
return false, nil
|
||||
@@ -534,6 +605,66 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
if h.UserRepo != nil {
|
||||
var tenantIDs []string
|
||||
if tenantSlug != "" {
|
||||
if targetTenantID == "" {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: 0,
|
||||
Cursor: cursorRaw,
|
||||
})
|
||||
}
|
||||
if requesterRole != domain.RoleSuperAdmin && !manageableSlugs[targetTenantID] && !manageableSlugs[strings.ToLower(tenantSlug)] {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: 0,
|
||||
Cursor: cursorRaw,
|
||||
})
|
||||
}
|
||||
tenantIDs = append(tenantIDs, targetTenantID)
|
||||
} else if requesterRole != domain.RoleSuperAdmin {
|
||||
for key := range manageableSlugs {
|
||||
if _, err := uuid.Parse(key); err == nil {
|
||||
tenantIDs = append(tenantIDs, key)
|
||||
}
|
||||
}
|
||||
if len(tenantIDs) == 0 {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: 0,
|
||||
Cursor: cursorRaw,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
users, total, nextCursor, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantIDs, cursorRaw)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to list users")
|
||||
}
|
||||
items := make([]userSummary, 0, len(users))
|
||||
for _, user := range users {
|
||||
items = append(items, h.mapLocalUserSummary(c.Context(), user))
|
||||
}
|
||||
if cursorRaw != "" {
|
||||
offset = 0
|
||||
}
|
||||
return c.JSON(userListResponse{
|
||||
Items: items,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: total,
|
||||
Cursor: cursorRaw,
|
||||
NextCursor: nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
if h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
}
|
||||
@@ -1615,11 +1746,18 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||
|
||||
// 1. Fetch Users using Repo for efficiency
|
||||
var exportTenantIDs []string
|
||||
if tenantSlug != "" && h.TenantService != nil {
|
||||
t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
|
||||
if err == nil && t != nil {
|
||||
exportTenantIDs = []string{t.ID}
|
||||
if tenantSlug != "" {
|
||||
if h.TenantService == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "tenant service unavailable for scoped export")
|
||||
}
|
||||
t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve tenant for export")
|
||||
}
|
||||
if t == nil || strings.TrimSpace(t.ID) == "" {
|
||||
return errorJSON(c, fiber.StatusNotFound, "tenant not found for export")
|
||||
}
|
||||
exportTenantIDs = []string{t.ID}
|
||||
}
|
||||
users, _, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, exportTenantIDs, "")
|
||||
if err != nil {
|
||||
@@ -2087,7 +2225,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
|
||||
// All non-superadmins can only move users within tenants they can manage.
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil {
|
||||
if !req.IsRemoveTenant && req.CompanyCode != nil {
|
||||
targetSlug := strings.TrimSpace(*req.CompanyCode)
|
||||
targetAllowed := profileCanAccessTenant(requester, "", targetSlug)
|
||||
if !targetAllowed && h.TenantService != nil && targetSlug != "" {
|
||||
@@ -2096,7 +2234,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
if !targetAllowed {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: non-superadmins cannot change user's tenant to an unmanageable one")
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: non-superadmins cannot assign user's tenant to an unmanageable one")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2221,6 +2359,21 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
traits["tenant_id"] = ""
|
||||
}
|
||||
}
|
||||
} else if h.TenantService != nil && code != "" {
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code)
|
||||
if err != nil || tenant == nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
|
||||
}
|
||||
req.Metadata = mergeUserAddTenantAppointment(traits, req.Metadata, tenant)
|
||||
if h.KetoOutboxRepo != nil {
|
||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(traits, "companyCode")
|
||||
@@ -2775,6 +2928,45 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
return summary
|
||||
}
|
||||
|
||||
func (h *UserHandler) mapLocalUserSummary(ctx context.Context, user domain.User) userSummary {
|
||||
tenantSlug := userTenantSlug(user)
|
||||
customLoginIDs := make([]string, 0, len(user.UserLoginIDs))
|
||||
for _, loginID := range user.UserLoginIDs {
|
||||
if strings.TrimSpace(loginID.LoginID) != "" {
|
||||
customLoginIDs = append(customLoginIDs, strings.TrimSpace(loginID.LoginID))
|
||||
}
|
||||
}
|
||||
|
||||
summary := userSummary{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
LoginID: user.Email,
|
||||
CustomLoginIDs: customLoginIDs,
|
||||
Name: user.Name,
|
||||
Phone: user.Phone,
|
||||
Role: domain.NormalizeRole(user.Role),
|
||||
Status: normalizeStatus(user.Status),
|
||||
TenantSlug: tenantSlug,
|
||||
CompanyCode: tenantSlug,
|
||||
Department: user.Department,
|
||||
Grade: user.Grade,
|
||||
Position: user.Position,
|
||||
JobTitle: user.JobTitle,
|
||||
Metadata: user.Metadata,
|
||||
Tenant: user.Tenant,
|
||||
CreatedAt: formatTime(user.CreatedAt),
|
||||
UpdatedAt: formatTime(user.UpdatedAt),
|
||||
}
|
||||
|
||||
if h.TenantService != nil {
|
||||
if joined, err := h.TenantService.ListJoinedTenants(ctx, user.ID); err == nil {
|
||||
summary.JoinedTenants = joined
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
func (h *UserHandler) normalizePhoneNumber(phone string) string {
|
||||
return normalizePhoneNumber(phone)
|
||||
}
|
||||
@@ -3302,18 +3494,7 @@ func normalizeKratosState(status *string) string {
|
||||
}
|
||||
|
||||
func normalizePhoneNumber(phone string) string {
|
||||
normalized := strings.ReplaceAll(phone, "-", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(normalized, "010") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
if strings.HasPrefix(normalized, "82") {
|
||||
return "+" + normalized
|
||||
}
|
||||
return normalized
|
||||
return domain.NormalizePhoneNumber(phone)
|
||||
}
|
||||
|
||||
func (h *UserHandler) validateMetadata(metadata map[string]any, schema []any, checkRequired bool) error {
|
||||
|
||||
@@ -320,7 +320,8 @@ func (m *MockTenantServiceForUser) RegisterTenant(ctx context.Context, name, slu
|
||||
func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
h := &UserHandler{UserRepo: mockRepo}
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{UserRepo: mockRepo, TenantService: mockTenant}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
@@ -332,7 +333,11 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
|
||||
|
||||
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
|
||||
tenantID := "tenant-uuid"
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", []string(nil), "").
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "test-tenant",
|
||||
}, nil).Once()
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", []string{tenantID}, "").
|
||||
Return([]domain.User{
|
||||
{
|
||||
ID: "u-1",
|
||||
@@ -362,9 +367,34 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
|
||||
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,tenant-uuid,test-tenant,책임,팀장")
|
||||
assert.NotContains(t, body, "Role")
|
||||
assert.NotContains(t, body, "Department")
|
||||
mockTenant.AssertExpectations(t)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ExportUsersCSV_UnknownTenantSlugDoesNotFallbackToAllUsers(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{UserRepo: mockRepo, TenantService: mockTenant}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users/export", h.ExportUsersCSV)
|
||||
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "missing-tenant").Return(nil, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/users/export?tenantSlug=missing-tenant&includeIds=true", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
@@ -951,10 +981,11 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testing.T) {
|
||||
func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
createdAt := time.Date(2026, 6, 8, 6, 30, 0, 0, time.UTC)
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
@@ -970,14 +1001,86 @@ func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testin
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{}, errors.New("kratos down")).Maybe()
|
||||
mockRepo.On("List", mock.Anything, 0, 10, "", []string(nil), "").Return([]domain.User{
|
||||
{
|
||||
ID: "local-user-1",
|
||||
Email: "local1@example.com",
|
||||
Name: "Local One",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
},
|
||||
}, int64(1), "", nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=10&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockKratos.AssertExpectations(t)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(1), res.Total)
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "local1@example.com", res.Items[0].Email)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
createdAt := time.Date(2026, 6, 8, 6, 40, 0, 0, time.UTC)
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
UserRepo: mockRepo,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
kratosIdentities := make([]service.KratosIdentity, 250)
|
||||
for i := range kratosIdentities {
|
||||
kratosIdentities[i] = service.KratosIdentity{
|
||||
ID: "kratos-user",
|
||||
State: "active",
|
||||
CreatedAt: createdAt.Add(-time.Duration(i) * time.Second),
|
||||
Traits: map[string]any{"email": "kratos@example.com", "name": "Kratos"},
|
||||
}
|
||||
}
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Maybe()
|
||||
mockRepo.On("List", mock.Anything, 0, 50, "", []string(nil), "").Return([]domain.User{
|
||||
{
|
||||
ID: "local-user-1",
|
||||
Email: "local1@example.com",
|
||||
Name: "Local One",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
},
|
||||
}, int64(2114), "next-local-cursor", nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(2114), res.Total)
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "local1@example.com", res.Items[0].Email)
|
||||
require.Equal(t, "next-local-cursor", res.NextCursor)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
|
||||
@@ -2363,6 +2466,157 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserAddTenantKeepsPrimaryAndAddsAppointment(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "admin-id",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "primary-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": "primary-tenant-id",
|
||||
"tenantSlug": "primary-tenant",
|
||||
"tenantName": "대표 조직",
|
||||
"isPrimary": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "private-team").Return(&domain.Tenant{
|
||||
ID: "private-team-id",
|
||||
Name: "비공개 팀",
|
||||
Slug: "private-team",
|
||||
Config: domain.JSONMap{
|
||||
"visibility": "private",
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, "private-team-id").Return(&domain.Tenant{
|
||||
ID: "private-team-id",
|
||||
Name: "비공개 팀",
|
||||
Slug: "private-team",
|
||||
Config: domain.JSONMap{
|
||||
"visibility": "private",
|
||||
},
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, "primary-tenant-id").Return(&domain.Tenant{
|
||||
ID: "primary-tenant-id",
|
||||
Name: "대표 조직",
|
||||
Slug: "primary-tenant",
|
||||
}, nil).Maybe()
|
||||
|
||||
var capturedTraits map[string]any
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
|
||||
capturedTraits = args.Get(2).(map[string]any)
|
||||
}).Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "primary-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": "primary-tenant-id",
|
||||
"tenantSlug": "primary-tenant",
|
||||
"tenantName": "대표 조직",
|
||||
"isPrimary": true,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": "private-team-id",
|
||||
"tenantSlug": "private-team",
|
||||
"tenantName": "비공개 팀",
|
||||
"isPrimary": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
body := `{"tenantSlug":"private-team","isAddTenant":true}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "primary-tenant-id", capturedTraits["tenant_id"])
|
||||
appointments, ok := capturedTraits["additionalAppointments"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, appointments, 2)
|
||||
added := appointments[1].(map[string]any)
|
||||
require.Equal(t, "private-team-id", added["tenantId"])
|
||||
require.Equal(t, "private-team", added["tenantSlug"])
|
||||
require.Equal(t, "비공개 팀", added["tenantName"])
|
||||
require.Equal(t, false, added["isPrimary"])
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserAddTenantRejectsUnmanageableTenantForTenantAdmin(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
allowedTenantID := "allowed-tenant-id"
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "tenant-admin-id",
|
||||
Role: "tenant_admin",
|
||||
ManageableTenants: []domain.Tenant{
|
||||
{ID: allowedTenantID, Slug: "allowed-team"},
|
||||
},
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": allowedTenantID,
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "outside-team").Return(&domain.Tenant{
|
||||
ID: "outside-tenant-id",
|
||||
Name: "관리 외부 팀",
|
||||
Slug: "outside-team",
|
||||
}, nil).Once()
|
||||
|
||||
body := `{"tenantSlug":"outside-team","isAddTenant":true}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
|
||||
@@ -72,11 +72,17 @@ func (h *WorksmobileHandler) DeleteOrgUnit(c *fiber.Ctx) error {
|
||||
|
||||
func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
|
||||
userID := strings.TrimSpace(c.Params("userId"))
|
||||
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)
|
||||
credentialRequest, err := parseWorksmobileCredentialRequest(c)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID)
|
||||
job, err := h.Service.EnqueueUserSync(
|
||||
c.Context(),
|
||||
strings.TrimSpace(c.Params("tenantId")),
|
||||
userID,
|
||||
credentialRequest.CredentialBatchID,
|
||||
credentialRequest.InitialPassword,
|
||||
)
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "sync_user", "user_id", userID)
|
||||
}
|
||||
@@ -158,21 +164,30 @@ func (h *WorksmobileHandler) DeleteCredentialBatchPasswords(c *fiber.Ctx) error
|
||||
|
||||
type worksmobileCredentialBatchRequest struct {
|
||||
CredentialBatchID string `json:"credentialBatchId"`
|
||||
InitialPassword string `json:"initialPassword"`
|
||||
}
|
||||
|
||||
func parseWorksmobileCredentialBatchID(c *fiber.Ctx) (string, error) {
|
||||
req, err := parseWorksmobileCredentialRequest(c)
|
||||
return req.CredentialBatchID, err
|
||||
}
|
||||
|
||||
func parseWorksmobileCredentialRequest(c *fiber.Ctx) (worksmobileCredentialBatchRequest, error) {
|
||||
batchID := strings.TrimSpace(c.Query("credentialBatchId"))
|
||||
req := worksmobileCredentialBatchRequest{CredentialBatchID: batchID}
|
||||
if len(bytes.TrimSpace(c.Body())) == 0 {
|
||||
return batchID, nil
|
||||
return req, nil
|
||||
}
|
||||
var req worksmobileCredentialBatchRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return "", err
|
||||
return worksmobileCredentialBatchRequest{}, err
|
||||
}
|
||||
req.InitialPassword = strings.TrimSpace(req.InitialPassword)
|
||||
if bodyBatchID := strings.TrimSpace(req.CredentialBatchID); bodyBatchID != "" {
|
||||
return bodyBatchID, nil
|
||||
req.CredentialBatchID = bodyBatchID
|
||||
return req, nil
|
||||
}
|
||||
return batchID, nil
|
||||
req.CredentialBatchID = batchID
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func worksmobileOverviewAllowed(overview service.WorksmobileTenantOverview) bool {
|
||||
|
||||
@@ -97,13 +97,14 @@ func TestWorksmobileHandlerPassesSyncUserCredentialBatchID(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", h.SyncUser)
|
||||
|
||||
req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", strings.NewReader(`{"credentialBatchId":"batch-1"}`))
|
||||
req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", strings.NewReader(`{"credentialBatchId":"batch-1","initialPassword":"InputPass1!"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusAccepted, resp.StatusCode)
|
||||
require.Equal(t, "batch-1", fakeService.syncUserCredentialBatchID)
|
||||
require.Equal(t, "InputPass1!", fakeService.syncUserInitialPassword)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerPassesPasswordResetCredentialBatchID(t *testing.T) {
|
||||
@@ -199,6 +200,7 @@ type fakeWorksmobileAdminService struct {
|
||||
credentials []service.WorksmobileInitialPasswordCredential
|
||||
syncUserErr error
|
||||
syncUserCredentialBatchID string
|
||||
syncUserInitialPassword string
|
||||
resetPasswordCredentialBatchID string
|
||||
downloadCredentialBatchID string
|
||||
deletedCredentialBatchID string
|
||||
@@ -227,8 +229,9 @@ func (f *fakeWorksmobileAdminService) EnqueueOrgUnitDelete(ctx context.Context,
|
||||
return &domain.WorksmobileOutbox{ID: "job-orgunit-delete", ResourceID: orgUnitID, Action: domain.WorksmobileActionDelete}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID, initialPassword string) (*domain.WorksmobileOutbox, error) {
|
||||
f.syncUserCredentialBatchID = credentialBatchID
|
||||
f.syncUserInitialPassword = initialPassword
|
||||
if f.syncUserErr != nil {
|
||||
return nil, f.syncUserErr
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type UserProjectionRepository interface {
|
||||
IsReady(ctx context.Context) (bool, error)
|
||||
GetStatus(ctx context.Context) (domain.UserProjectionStatus, error)
|
||||
CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error)
|
||||
CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error)
|
||||
ReplaceAllFromKratos(ctx context.Context, users []domain.User) error
|
||||
MarkFailed(ctx context.Context, syncErr error) error
|
||||
}
|
||||
@@ -108,10 +109,63 @@ func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenan
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
counts := make(map[string]int64, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
counts[tenant.ID] = 0
|
||||
}
|
||||
if len(tenants) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
valuePlaceholders := make([]string, 0, len(tenants))
|
||||
args := make([]any, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
valuePlaceholders = append(valuePlaceholders, "(?)")
|
||||
args = append(args, strings.TrimSpace(tenant.ID))
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH RECURSIVE requested(tenant_id) AS (
|
||||
VALUES %s
|
||||
),
|
||||
descendants(root_tenant_id, tenant_id) AS (
|
||||
SELECT requested.tenant_id, requested.tenant_id
|
||||
FROM requested
|
||||
UNION ALL
|
||||
SELECT descendants.root_tenant_id, child.id::text
|
||||
FROM descendants
|
||||
JOIN tenants child
|
||||
ON child.parent_id::text = descendants.tenant_id
|
||||
AND child.deleted_at IS NULL
|
||||
)
|
||||
SELECT requested.tenant_id, COUNT(DISTINCT users.id) AS count
|
||||
FROM requested
|
||||
LEFT JOIN descendants
|
||||
ON descendants.root_tenant_id = requested.tenant_id
|
||||
LEFT JOIN users
|
||||
ON users.deleted_at IS NULL
|
||||
AND users.tenant_id::text = descendants.tenant_id
|
||||
GROUP BY requested.tenant_id
|
||||
`, strings.Join(valuePlaceholders, ","))
|
||||
|
||||
type result struct {
|
||||
TenantID string
|
||||
Count int64
|
||||
}
|
||||
var rows []result
|
||||
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
counts[row.TenantID] = row.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
ids := make([]string, 0, len(users))
|
||||
for i := range users {
|
||||
users[i].DeletedAt = gorm.DeletedAt{}
|
||||
if users[i].CreatedAt.IsZero() {
|
||||
@@ -120,7 +174,6 @@ func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, use
|
||||
if users[i].UpdatedAt.IsZero() {
|
||||
users[i].UpdatedAt = now
|
||||
}
|
||||
ids = append(ids, users[i].ID)
|
||||
}
|
||||
|
||||
if len(users) > 0 {
|
||||
@@ -138,11 +191,6 @@ func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, use
|
||||
}).Create(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("id NOT IN ?", ids).Delete(&domain.User{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := tx.Where("1 = 1").Delete(&domain.User{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return upsertUserProjectionState(tx, domain.UserProjectionStatusReady, &now, "")
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyAndRemovesStaleUsers(t *testing.T) {
|
||||
func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyWithoutDeletingUsersMissingFromPartialList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewUserProjectionRepository(testDB)
|
||||
|
||||
@@ -28,13 +28,14 @@ func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyAndRemovesStaleU
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}).Error)
|
||||
stale := &domain.User{
|
||||
existing := &domain.User{
|
||||
ID: "00000000-0000-0000-0000-000000000099",
|
||||
Email: "stale@example.com",
|
||||
Name: "Stale",
|
||||
Email: "existing@example.com",
|
||||
Name: "Existing",
|
||||
CompanyCode: tenantSlug,
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
require.NoError(t, NewUserRepository(testDB).Create(ctx, stale))
|
||||
require.NoError(t, NewUserRepository(testDB).Create(ctx, existing))
|
||||
|
||||
users := []domain.User{
|
||||
{
|
||||
@@ -66,11 +67,91 @@ func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyAndRemovesStaleU
|
||||
{ID: tenantID, Slug: tenantSlug},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), counts[tenantID])
|
||||
assert.Equal(t, int64(3), counts[tenantID])
|
||||
|
||||
var activeCount int64
|
||||
require.NoError(t, testDB.Model(&domain.User{}).Count(&activeCount).Error)
|
||||
assert.Equal(t, int64(2), activeCount)
|
||||
assert.Equal(t, int64(3), activeCount)
|
||||
|
||||
var existingCount int64
|
||||
require.NoError(t, testDB.Model(&domain.User{}).Where("id = ?", existing.ID).Count(&existingCount).Error)
|
||||
assert.Equal(t, int64(1), existingCount)
|
||||
|
||||
var existingRow domain.User
|
||||
require.NoError(t, testDB.Unscoped().First(&existingRow, "id = ?", existing.ID).Error)
|
||||
assert.False(t, existingRow.DeletedAt.Valid)
|
||||
}
|
||||
|
||||
func TestUserProjectionRepository_CountTenantMembersRecursiveIncludesDescendantsAndExcludesSoftDeletedUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewUserProjectionRepository(testDB)
|
||||
|
||||
parentID := "20000000-0000-0000-0000-000000000001"
|
||||
childID := "20000000-0000-0000-0000-000000000002"
|
||||
grandchildID := "20000000-0000-0000-0000-000000000003"
|
||||
siblingID := "20000000-0000-0000-0000-000000000004"
|
||||
tenantIDs := []string{parentID, childID, grandchildID, siblingID}
|
||||
|
||||
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
|
||||
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
|
||||
require.NoError(t, testDB.Unscoped().Where("id IN ?", tenantIDs).Delete(&domain.Tenant{}).Error)
|
||||
|
||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
||||
ID: parentID,
|
||||
Name: "Recursive Parent",
|
||||
Slug: "recursive-parent",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}).Error)
|
||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
||||
ID: childID,
|
||||
Name: "Recursive Child",
|
||||
Slug: "recursive-child",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
Status: domain.TenantStatusActive,
|
||||
ParentID: &parentID,
|
||||
}).Error)
|
||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
||||
ID: grandchildID,
|
||||
Name: "Recursive Grandchild",
|
||||
Slug: "recursive-grandchild",
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
Status: domain.TenantStatusActive,
|
||||
ParentID: &childID,
|
||||
}).Error)
|
||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
||||
ID: siblingID,
|
||||
Name: "Recursive Sibling",
|
||||
Slug: "recursive-sibling",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}).Error)
|
||||
|
||||
users := []domain.User{
|
||||
{ID: "21000000-0000-0000-0000-000000000001", Email: "parent@example.com", Name: "Parent", TenantID: &parentID},
|
||||
{ID: "21000000-0000-0000-0000-000000000002", Email: "child@example.com", Name: "Child", TenantID: &childID},
|
||||
{ID: "21000000-0000-0000-0000-000000000003", Email: "grandchild@example.com", Name: "Grandchild", TenantID: &grandchildID},
|
||||
{ID: "21000000-0000-0000-0000-000000000004", Email: "deleted-grandchild@example.com", Name: "Deleted Grandchild", TenantID: &grandchildID},
|
||||
{ID: "21000000-0000-0000-0000-000000000005", Email: "sibling@example.com", Name: "Sibling", TenantID: &siblingID},
|
||||
}
|
||||
for i := range users {
|
||||
require.NoError(t, testDB.Create(&users[i]).Error)
|
||||
}
|
||||
require.NoError(t, testDB.Delete(&domain.User{}, "id = ?", users[3].ID).Error)
|
||||
|
||||
directCounts, err := repo.CountTenantMembers(ctx, []domain.Tenant{{ID: parentID}, {ID: childID}, {ID: grandchildID}, {ID: siblingID}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), directCounts[parentID])
|
||||
assert.Equal(t, int64(1), directCounts[childID])
|
||||
assert.Equal(t, int64(1), directCounts[grandchildID])
|
||||
assert.Equal(t, int64(1), directCounts[siblingID])
|
||||
|
||||
recursiveCounts, err := repo.CountTenantMembersRecursive(ctx, []domain.Tenant{{ID: parentID}, {ID: childID}, {ID: grandchildID}, {ID: siblingID}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), recursiveCounts[parentID])
|
||||
assert.Equal(t, int64(2), recursiveCounts[childID])
|
||||
assert.Equal(t, int64(1), recursiveCounts[grandchildID])
|
||||
assert.Equal(t, int64(1), recursiveCounts[siblingID])
|
||||
}
|
||||
|
||||
func TestUserProjectionRepository_MarkFailedMakesProjectionNotReady(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/pagination"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -46,6 +47,31 @@ func (r *userRepository) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
func (r *userRepository) withTenantMembershipFilter(db *gorm.DB, tenantIDs []string) *gorm.DB {
|
||||
if len(tenantIDs) == 0 {
|
||||
return db
|
||||
}
|
||||
clauses := []string{"tenant_id IN ?"}
|
||||
args := []any{tenantIDs}
|
||||
for _, tenantID := range tenantIDs {
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID == "" {
|
||||
continue
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"additionalAppointments": []map[string]string{
|
||||
{"tenantId": tenantID},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
clauses = append(clauses, "metadata @> ?::jsonb")
|
||||
args = append(args, string(payload))
|
||||
}
|
||||
return db.Where("("+strings.Join(clauses, " OR ")+")", args...)
|
||||
}
|
||||
|
||||
func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
|
||||
return r.db.WithContext(ctx).Create(user).Error
|
||||
}
|
||||
@@ -124,7 +150,7 @@ func (r *userRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.
|
||||
|
||||
func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
|
||||
var users []domain.User
|
||||
if err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&users).Error; err != nil {
|
||||
if err := r.withTenantMembershipFilter(r.db.WithContext(ctx), []string{tenantID}).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
@@ -132,40 +158,23 @@ func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]d
|
||||
|
||||
func (r *userRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&domain.User{}).Where("tenant_id = ?", tenantID).Count(&count).Error
|
||||
err := r.withTenantMembershipFilter(r.db.WithContext(ctx).Model(&domain.User{}), []string{tenantID}).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||
type result struct {
|
||||
TenantID *string
|
||||
Count int64
|
||||
}
|
||||
var results []result
|
||||
counts := make(map[string]int64)
|
||||
|
||||
if len(tenantIDs) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
if err := r.db.WithContext(ctx).Model(&domain.User{}).
|
||||
Select("tenant_id, count(*) as count").
|
||||
Where("tenant_id IN ?", tenantIDs).
|
||||
Group("tenant_id").
|
||||
Find(&results).Error; err != nil {
|
||||
for _, tenantID := range tenantIDs {
|
||||
var count int64
|
||||
if err := r.withTenantMembershipFilter(r.db.WithContext(ctx).Model(&domain.User{}), []string{tenantID}).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, res := range results {
|
||||
if res.TenantID != nil && *res.TenantID != "" {
|
||||
counts[*res.TenantID] = res.Count
|
||||
}
|
||||
}
|
||||
// Ensure all requested tenant IDs are in the map, even if count is 0
|
||||
for _, id := range tenantIDs {
|
||||
if _, ok := counts[id]; !ok {
|
||||
counts[id] = 0
|
||||
}
|
||||
counts[tenantID] = count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
@@ -222,7 +231,7 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
||||
db := r.db.WithContext(ctx).Model(&domain.User{})
|
||||
|
||||
if len(tenantIDs) > 0 {
|
||||
db = db.Where("tenant_id IN ?", tenantIDs)
|
||||
db = r.withTenantMembershipFilter(db, tenantIDs)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
@@ -311,7 +320,7 @@ func (r *userRepository) FindTenantIDByLoginID(ctx context.Context, loginID stri
|
||||
|
||||
func (r *userRepository) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
|
||||
var users []domain.User
|
||||
err := r.db.WithContext(ctx).Where("tenant_id IN ?", tenantIDs).Find(&users).Error
|
||||
err := r.withTenantMembershipFilter(r.db.WithContext(ctx), tenantIDs).Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,60 @@ func TestUserRepository(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserRepository_ListIncludesAdditionalTenantAppointments(t *testing.T) {
|
||||
repo := NewUserRepository(testDB)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
|
||||
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
|
||||
|
||||
primaryTenant := createUserRepositoryTestTenant(t, "repo-primary-tenant")
|
||||
additionalTenant := createUserRepositoryTestTenant(t, "repo-additional-tenant")
|
||||
primaryTenantID := primaryTenant.ID
|
||||
additionalTenantID := additionalTenant.ID
|
||||
users := []domain.User{
|
||||
{
|
||||
ID: uuid.NewString(),
|
||||
Email: "primary-member@example.com",
|
||||
Name: "Primary Member",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &additionalTenantID,
|
||||
},
|
||||
{
|
||||
ID: uuid.NewString(),
|
||||
Email: "additional-member@example.com",
|
||||
Name: "Additional Member",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &primaryTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": additionalTenant.ID,
|
||||
"tenantSlug": additionalTenant.Slug,
|
||||
"tenantName": additionalTenant.Name,
|
||||
"isPrimary": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for i := range users {
|
||||
require.NoError(t, repo.Create(ctx, &users[i]))
|
||||
}
|
||||
|
||||
listed, total, _, err := repo.List(ctx, 0, 20, "", []string{additionalTenant.ID}, "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), total)
|
||||
require.Len(t, listed, 2)
|
||||
emails := []string{listed[0].Email, listed[1].Email}
|
||||
assert.Contains(t, emails, "primary-member@example.com")
|
||||
assert.Contains(t, emails, "additional-member@example.com")
|
||||
|
||||
counts, err := repo.CountByTenantIDs(ctx, []string{additionalTenant.ID})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), counts[additionalTenant.ID])
|
||||
}
|
||||
|
||||
func createUserRepositoryTestTenant(t *testing.T, slug string) domain.Tenant {
|
||||
t.Helper()
|
||||
require.NoError(t, testDB.Unscoped().Where("slug = ?", slug).Delete(&domain.Tenant{}).Error)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -68,27 +69,76 @@ func NewKratosAdminService() KratosAdminService {
|
||||
|
||||
func (s *kratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
var identities []KratosIdentity
|
||||
pageToken := ""
|
||||
seenTokens := make(map[string]bool)
|
||||
|
||||
for {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := req.URL.Query()
|
||||
query.Set("page_size", "250")
|
||||
if pageToken != "" {
|
||||
query.Set("page_token", pageToken)
|
||||
}
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("kratos admin list identities failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var identities []KratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
||||
var page []KratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
|
||||
_ = resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
identities = append(identities, page...)
|
||||
|
||||
nextToken := kratosNextPageToken(resp.Header.Values("Link"))
|
||||
if nextToken == "" {
|
||||
return identities, nil
|
||||
}
|
||||
if seenTokens[nextToken] {
|
||||
return nil, fmt.Errorf("kratos admin list identities pagination loop detected page_token=%s", nextToken)
|
||||
}
|
||||
seenTokens[nextToken] = true
|
||||
pageToken = nextToken
|
||||
}
|
||||
}
|
||||
|
||||
func kratosNextPageToken(linkHeaders []string) string {
|
||||
for _, header := range linkHeaders {
|
||||
for _, part := range strings.Split(header, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if !strings.Contains(part, `rel="next"`) && !strings.Contains(part, `rel=next`) {
|
||||
continue
|
||||
}
|
||||
start := strings.Index(part, "<")
|
||||
end := strings.Index(part, ">")
|
||||
if start < 0 || end <= start+1 {
|
||||
continue
|
||||
}
|
||||
rawURL := part[start+1 : end]
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if token := strings.TrimSpace(parsed.Query().Get("page_token")); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
|
||||
63
backend/internal/service/kratos_admin_service_test.go
Normal file
63
backend/internal/service/kratos_admin_service_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestKratosAdminService_ListIdentitiesFollowsNextPagination(t *testing.T) {
|
||||
var requestedTokens []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
require.Equal(t, "/admin/identities", r.URL.Path)
|
||||
token := r.URL.Query().Get("page_token")
|
||||
requestedTokens = append(requestedTokens, token)
|
||||
|
||||
header := make(http.Header)
|
||||
header.Set("Content-Type", "application/json")
|
||||
status := http.StatusOK
|
||||
body := "[]"
|
||||
switch token {
|
||||
case "":
|
||||
header.Set(
|
||||
"Link",
|
||||
`</admin/identities?page_size=2&page_token=identity-2>; rel="next"`,
|
||||
)
|
||||
body = `[{"id":"identity-1","traits":{"email":"one@example.com"}},{"id":"identity-2","traits":{"email":"two@example.com"}}]`
|
||||
case "identity-2":
|
||||
body = `[{"id":"identity-3","traits":{"email":"three@example.com"}}]`
|
||||
default:
|
||||
t.Fatalf("unexpected page_token %q", token)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: header,
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
service := &kratosAdminService{
|
||||
AdminURL: "http://kratos.example",
|
||||
HTTPClient: client,
|
||||
}
|
||||
|
||||
identities, err := service.ListIdentities(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"", "identity-2"}, requestedTokens)
|
||||
require.Len(t, identities, 3)
|
||||
require.Equal(t, "identity-1", identities[0].ID)
|
||||
require.Equal(t, "identity-2", identities[1].ID)
|
||||
require.Equal(t, "identity-3", identities[2].ID)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +16,14 @@ type RedisService struct {
|
||||
Client *redis.Client
|
||||
}
|
||||
|
||||
type identityMirrorStateStore struct {
|
||||
Status string `json:"status"`
|
||||
LastRefreshedAt *time.Time `json:"lastRefreshedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
ObservedCount int64 `json:"observedCount,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// NewRedisService creates and returns a new RedisService
|
||||
func NewRedisService() (*RedisService, error) {
|
||||
redisAddr := os.Getenv("REDIS_ADDR")
|
||||
@@ -90,3 +100,139 @@ func (s *RedisService) Get(key string) (string, error) {
|
||||
func (s *RedisService) Delete(key string) error {
|
||||
return s.Client.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func (s *RedisService) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "unavailable",
|
||||
RedisReady: false,
|
||||
LastError: "redis service unavailable",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := s.Client.Ping(ctx).Err(); err != nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "failed",
|
||||
RedisReady: false,
|
||||
LastError: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
keyCount, err := s.countIdentityCacheKeys(ctx)
|
||||
if err != nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "failed",
|
||||
RedisReady: true,
|
||||
LastError: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
raw, err := s.Client.Get(ctx, "identity:mirror:state").Result()
|
||||
if err == redis.Nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "empty",
|
||||
RedisReady: true,
|
||||
KeyCount: keyCount,
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "failed",
|
||||
RedisReady: true,
|
||||
KeyCount: keyCount,
|
||||
LastError: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var stored identityMirrorStateStore
|
||||
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "failed",
|
||||
RedisReady: true,
|
||||
KeyCount: keyCount,
|
||||
LastError: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
status := stored.Status
|
||||
if status == "" {
|
||||
status = "unknown"
|
||||
}
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: status,
|
||||
RedisReady: true,
|
||||
ObservedCount: stored.ObservedCount,
|
||||
KeyCount: keyCount,
|
||||
LastRefreshedAt: stored.LastRefreshedAt,
|
||||
LastError: stored.LastError,
|
||||
UpdatedAt: stored.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RedisService) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return domain.IdentityCacheFlushResult{}, os.ErrInvalid
|
||||
}
|
||||
|
||||
keys, err := s.identityCacheKeys(ctx)
|
||||
if err != nil {
|
||||
return domain.IdentityCacheFlushResult{}, err
|
||||
}
|
||||
var deleted int64
|
||||
for len(keys) > 0 {
|
||||
chunkSize := len(keys)
|
||||
if chunkSize > 500 {
|
||||
chunkSize = 500
|
||||
}
|
||||
chunk := keys[:chunkSize]
|
||||
count, err := s.Client.Del(ctx, chunk...).Result()
|
||||
if err != nil {
|
||||
return domain.IdentityCacheFlushResult{}, err
|
||||
}
|
||||
deleted += count
|
||||
keys = keys[chunkSize:]
|
||||
}
|
||||
|
||||
return domain.IdentityCacheFlushResult{
|
||||
Status: "success",
|
||||
FlushedKeys: deleted,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RedisService) countIdentityCacheKeys(ctx context.Context) (int64, error) {
|
||||
keys, err := s.identityCacheKeys(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(len(keys)), nil
|
||||
}
|
||||
|
||||
func (s *RedisService) identityCacheKeys(ctx context.Context) ([]string, error) {
|
||||
seen := make(map[string]bool)
|
||||
patterns := []string{
|
||||
"identity:mirror:*",
|
||||
"identity:index:*",
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, next, err := s.Client.Scan(ctx, cursor, pattern, 250).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range keys {
|
||||
seen[key] = true
|
||||
}
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(seen))
|
||||
for key := range seen {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
150
backend/internal/service/redis_service_test.go
Normal file
150
backend/internal/service/redis_service_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type redisCommandStub struct {
|
||||
scans map[string][]string
|
||||
stateValue string
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (h *redisCommandStub) BeforeProcess(ctx context.Context, cmd redis.Cmder) (context.Context, error) {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (h *redisCommandStub) AfterProcess(ctx context.Context, cmd redis.Cmder) error {
|
||||
switch cmd.Name() {
|
||||
case "ping":
|
||||
if status, ok := cmd.(*redis.StatusCmd); ok {
|
||||
status.SetVal("PONG")
|
||||
}
|
||||
case "scan":
|
||||
if scan, ok := cmd.(*redis.ScanCmd); ok {
|
||||
scan.SetVal(h.scans[scanPattern(cmd.Args())], 0)
|
||||
}
|
||||
case "get":
|
||||
if str, ok := cmd.(*redis.StringCmd); ok {
|
||||
if h.stateValue == "" {
|
||||
str.SetErr(redis.Nil)
|
||||
return nil
|
||||
}
|
||||
str.SetVal(h.stateValue)
|
||||
}
|
||||
case "del":
|
||||
args := cmd.Args()
|
||||
keys := make([]string, 0, len(args)-1)
|
||||
for _, arg := range args[1:] {
|
||||
keys = append(keys, arg.(string))
|
||||
}
|
||||
h.deleted = append(h.deleted, keys...)
|
||||
if count, ok := cmd.(*redis.IntCmd); ok {
|
||||
count.SetVal(int64(len(keys)))
|
||||
}
|
||||
}
|
||||
cmd.SetErr(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *redisCommandStub) BeforeProcessPipeline(ctx context.Context, cmds []redis.Cmder) (context.Context, error) {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (h *redisCommandStub) AfterProcessPipeline(ctx context.Context, cmds []redis.Cmder) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanPattern(args []interface{}) string {
|
||||
for index := 0; index < len(args)-1; index++ {
|
||||
value, ok := args[index].(string)
|
||||
if ok && value == "match" {
|
||||
if pattern, ok := args[index+1].(string); ok {
|
||||
return pattern
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newStubbedRedisService(stub *redisCommandStub) *RedisService {
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: "127.0.0.1:1",
|
||||
MaxRetries: -1,
|
||||
})
|
||||
client.AddHook(stub)
|
||||
return &RedisService{Client: client}
|
||||
}
|
||||
|
||||
func TestRedisServiceGetIdentityCacheStatusReadsStateAndCountsCacheKeys(t *testing.T) {
|
||||
now := time.Date(2026, 6, 9, 3, 20, 0, 0, time.UTC)
|
||||
state, err := json.Marshal(identityMirrorStateStore{
|
||||
Status: "ready",
|
||||
LastRefreshedAt: &now,
|
||||
ObservedCount: 42,
|
||||
UpdatedAt: &now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
stub := &redisCommandStub{
|
||||
stateValue: string(state),
|
||||
scans: map[string][]string{
|
||||
"identity:mirror:*": {"identity:mirror:state", "identity:mirror:user:1"},
|
||||
"identity:index:*": {"identity:index:email:a", "identity:mirror:user:1"},
|
||||
},
|
||||
}
|
||||
service := newStubbedRedisService(stub)
|
||||
|
||||
status, err := service.GetIdentityCacheStatus(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ready", status.Status)
|
||||
require.True(t, status.RedisReady)
|
||||
require.Equal(t, int64(42), status.ObservedCount)
|
||||
require.Equal(t, int64(3), status.KeyCount)
|
||||
require.Equal(t, &now, status.LastRefreshedAt)
|
||||
require.Equal(t, &now, status.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestRedisServiceFlushIdentityCacheDeletesOnlyIdentityMirrorAndIndexKeys(t *testing.T) {
|
||||
stub := &redisCommandStub{
|
||||
scans: map[string][]string{
|
||||
"identity:mirror:*": {"identity:mirror:state", "identity:mirror:user:1"},
|
||||
"identity:index:*": {"identity:index:email:a", "identity:mirror:user:1"},
|
||||
},
|
||||
}
|
||||
service := newStubbedRedisService(stub)
|
||||
|
||||
result, err := service.FlushIdentityCache(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "success", result.Status)
|
||||
require.Equal(t, int64(3), result.FlushedKeys)
|
||||
require.ElementsMatch(t, []string{
|
||||
"identity:mirror:state",
|
||||
"identity:mirror:user:1",
|
||||
"identity:index:email:a",
|
||||
}, stub.deleted)
|
||||
}
|
||||
|
||||
func TestRedisServiceGetIdentityCacheStatusReturnsUnavailableWithoutClient(t *testing.T) {
|
||||
status, err := (*RedisService)(nil).GetIdentityCacheStatus(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "unavailable", status.Status)
|
||||
require.False(t, status.RedisReady)
|
||||
require.NotEmpty(t, status.LastError)
|
||||
}
|
||||
|
||||
func TestRedisServiceFlushIdentityCacheFailsWithoutClient(t *testing.T) {
|
||||
_, err := (*RedisService)(nil).FlushIdentityCache(context.Background())
|
||||
|
||||
require.ErrorIs(t, err, os.ErrInvalid)
|
||||
}
|
||||
@@ -222,44 +222,19 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
||||
tenant, _ = s.tenantRepo.FindByID(ctx, group.TenantID)
|
||||
}
|
||||
|
||||
var updatedIdentity *KratosIdentity
|
||||
|
||||
// [Fix] Sync Kratos Traits & Local DB when a user is added to an organization
|
||||
if s.kratos != nil && tenant != nil {
|
||||
// Fetch Kratos Identity
|
||||
identity, err := s.kratos.GetIdentity(ctx, userID)
|
||||
if err == nil && identity != nil {
|
||||
traits := identity.Traits
|
||||
if traits == nil {
|
||||
traits = make(map[string]any)
|
||||
}
|
||||
delete(traits, "companyCode")
|
||||
delete(traits, "companyCodes")
|
||||
traits["tenant_id"] = tenant.ID
|
||||
traits["department"] = group.Name
|
||||
|
||||
// Update Kratos
|
||||
updated, updateErr := s.kratos.UpdateIdentity(ctx, userID, traits, identity.State)
|
||||
if updateErr != nil {
|
||||
slog.Error("Failed to update identity traits during AddMember", "user", userID, "error", updateErr)
|
||||
} else if updated != nil {
|
||||
updatedIdentity = updated
|
||||
} else {
|
||||
identity.Traits = traits
|
||||
updatedIdentity = identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local user repo
|
||||
// Kratos는 identity SSOT이고 조직/부서 정보의 원장이 아니므로 AddMember에서 traits를 수정하지 않습니다.
|
||||
if s.userRepo != nil && tenant != nil {
|
||||
localUser, err := s.userRepo.FindByID(ctx, userID)
|
||||
if err != nil || localUser == nil {
|
||||
if updatedIdentity != nil {
|
||||
localUser = mapUserGroupKratosIdentityToLocalUser(*updatedIdentity)
|
||||
if s.kratos != nil {
|
||||
identity, identityErr := s.kratos.GetIdentity(ctx, userID)
|
||||
if identityErr == nil && identity != nil {
|
||||
localUser = mapUserGroupKratosIdentityToLocalUser(*identity)
|
||||
} else {
|
||||
slog.Warn("Skipping local user sync during AddMember because identity read is unavailable", "user", userID, "error", identityErr)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("Skipping local user sync during AddMember because identity projection is unavailable", "user", userID, "error", err)
|
||||
localUser = nil
|
||||
}
|
||||
}
|
||||
if localUser != nil {
|
||||
@@ -326,7 +301,7 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
|
||||
ID: identity.ID,
|
||||
Email: userGroupTraitString(traits, "email"),
|
||||
Name: userGroupTraitString(traits, "name"),
|
||||
Phone: userGroupTraitString(traits, "phone_number"),
|
||||
Phone: domain.NormalizePhoneNumber(userGroupTraitString(traits, "phone_number")),
|
||||
Role: role,
|
||||
Status: userGroupIdentityStatus(identity.State),
|
||||
Department: userGroupTraitString(traits, "department"),
|
||||
|
||||
@@ -272,14 +272,6 @@ func TestUserGroupService_AddMember(t *testing.T) {
|
||||
mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{ID: userID}, nil)
|
||||
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil)
|
||||
|
||||
// Mock Kratos
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]any{"email": "user@test.com"},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil)
|
||||
|
||||
// Mock local user repo update (Ignored since Update is hardcoded to return nil without calling m.Called)
|
||||
// mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
|
||||
// return u.CompanyCode == tenantSlug && *u.TenantID == tenantID && u.Department == "Sales"
|
||||
@@ -299,6 +291,8 @@ func TestUserGroupService_AddMember(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, userID)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything)
|
||||
// mockUserRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -326,19 +320,6 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
|
||||
},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
|
||||
_, hasCompanyCode := traits["companyCode"]
|
||||
return !hasCompanyCode && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
|
||||
}), "active").Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "User Test",
|
||||
"tenant_id": tenantID,
|
||||
"department": "Sales",
|
||||
},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||
})).Return(nil).Once()
|
||||
@@ -356,6 +337,7 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
|
||||
assert.Equal(t, "Sales", mockUserRepo.updatedUsers[0].Department)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
|
||||
@@ -380,16 +362,6 @@ func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
|
||||
Status: "active",
|
||||
}, nil)
|
||||
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: "tenant-slug"}, nil)
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]any{"email": "user@test.com"},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]any{"email": "user@test.com", "tenant_id": tenantID, "department": "Sales"},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||
})).Return(nil).Once()
|
||||
@@ -407,6 +379,8 @@ func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
|
||||
assert.Equal(t, "Sales", worksmobile.userUpserts[0].Department)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, userID)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
|
||||
|
||||
@@ -75,7 +75,7 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
|
||||
ID: identity.ID,
|
||||
Email: kratosProjectionTraitString(traits, "email"),
|
||||
Name: kratosProjectionTraitString(traits, "name"),
|
||||
Phone: kratosProjectionTraitString(traits, "phone_number"),
|
||||
Phone: domain.NormalizePhoneNumber(kratosProjectionTraitString(traits, "phone_number")),
|
||||
Role: role,
|
||||
Status: normalizeProjectionStatus(identity.State),
|
||||
Department: kratosProjectionTraitString(traits, "department"),
|
||||
|
||||
@@ -28,6 +28,10 @@ func (f *fakeUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
f.replacedUsers = append([]domain.User(nil), users...)
|
||||
return f.replaceErr
|
||||
@@ -79,6 +83,33 @@ func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *test
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserProjectionSyncService_ReconcileDeduplicatesKoreanCountryCodePhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kratos := new(MockKratosAdminServiceShared)
|
||||
repo := &fakeUserProjectionRepo{}
|
||||
svc := NewUserProjectionSyncService(kratos, repo)
|
||||
|
||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000102",
|
||||
Traits: map[string]any{
|
||||
"email": "two@example.com",
|
||||
"name": "Two",
|
||||
"phone_number": "+82 +821091917771",
|
||||
},
|
||||
State: "active",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
count, err := svc.Reconcile(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
require.Len(t, repo.replacedUsers, 1)
|
||||
assert.Equal(t, "+821091917771", repo.replacedUsers[0].Phone)
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kratos := new(MockKratosAdminServiceShared)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
@@ -17,11 +18,13 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWorksmobileOAuthScope = "directory"
|
||||
worksmobileAPIRateLimitPerMinute = 240
|
||||
)
|
||||
|
||||
type WorksmobileDirectoryClient interface {
|
||||
@@ -43,6 +46,7 @@ type WorksmobileHTTPClient struct {
|
||||
DirectoryToken string
|
||||
SCIMToken string
|
||||
HTTPClient *http.Client
|
||||
RateLimiter WorksmobileRateLimiter
|
||||
OAuthConfig WorksmobileOAuthConfig
|
||||
DomainIDs []int64
|
||||
OrgUnitWriteDelay time.Duration
|
||||
@@ -50,6 +54,16 @@ type WorksmobileHTTPClient struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type WorksmobileRateLimiter interface {
|
||||
Wait(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
type worksmobileAPIRateLimiter struct {
|
||||
interval time.Duration
|
||||
mu sync.Mutex
|
||||
next map[string]time.Time
|
||||
}
|
||||
|
||||
type WorksmobileOAuthConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
@@ -64,6 +78,46 @@ type worksmobileAccessTokenCache struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func NewWorksmobileAPIRateLimiter(limit int, window time.Duration) WorksmobileRateLimiter {
|
||||
if limit <= 0 || window <= 0 {
|
||||
return &worksmobileAPIRateLimiter{}
|
||||
}
|
||||
return &worksmobileAPIRateLimiter{
|
||||
interval: window / time.Duration(limit),
|
||||
next: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *worksmobileAPIRateLimiter) Wait(ctx context.Context, key string) error {
|
||||
if l == nil || l.interval <= 0 {
|
||||
return nil
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
key = "UNKNOWN"
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
now := time.Now()
|
||||
waitUntil := l.next[key]
|
||||
if waitUntil.Before(now) {
|
||||
waitUntil = now
|
||||
}
|
||||
l.next[key] = waitUntil.Add(l.interval)
|
||||
l.mu.Unlock()
|
||||
|
||||
if delay := time.Until(waitUntil); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c WorksmobileOAuthConfig) normalized() WorksmobileOAuthConfig {
|
||||
c.ClientID = strings.Trim(strings.TrimSpace(c.ClientID), `"`)
|
||||
c.ClientSecret = strings.Trim(strings.TrimSpace(c.ClientSecret), `"`)
|
||||
@@ -280,7 +334,10 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(payload.UserExternalKey)
|
||||
}
|
||||
return c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload))
|
||||
if patchErr := c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload)); patchErr != nil {
|
||||
return fmt.Errorf("worksmobile user create conflict: %w; patch after conflict failed: %v", err, patchErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -306,6 +363,23 @@ func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID st
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) RemoveUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
email = strings.TrimSpace(email)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
if email == "" {
|
||||
return fmt.Errorf("worksmobile alias email is required")
|
||||
}
|
||||
return c.sendDirectoryJSON(
|
||||
ctx,
|
||||
http.MethodDelete,
|
||||
"/v1.0/users/"+url.PathEscape(userID)+"/alias-emails/"+url.PathEscape(email),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
password = strings.TrimSpace(password)
|
||||
@@ -315,15 +389,38 @@ func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID st
|
||||
if password == "" {
|
||||
return fmt.Errorf("worksmobile password is required")
|
||||
}
|
||||
changePasswordAtNextLogin := true
|
||||
payload := map[string]any{
|
||||
"passwordConfig": WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: password,
|
||||
ChangePasswordAtNextLogin: &changePasswordAtNextLogin,
|
||||
},
|
||||
}
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(userID), payload)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) GetUser(ctx context.Context, userID string) (*WorksmobileRemoteUser, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
var response map[string]any
|
||||
if err := c.getDirectoryJSON(ctx, "/v1.0/users/"+url.PathEscape(userID), &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := parseWorksmobileDirectoryUser(response)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) UndeleteUser(ctx context.Context, userID string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users/"+url.PathEscape(userID)+"/undelete", nil)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
@@ -484,6 +581,9 @@ func (c *WorksmobileHTTPClient) getJSON(ctx context.Context, path string, target
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -512,6 +612,9 @@ func (c *WorksmobileHTTPClient) getDirectoryJSON(ctx context.Context, path strin
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -665,6 +768,9 @@ func (c *WorksmobileHTTPClient) requestDirectoryAccessToken(ctx context.Context,
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
@@ -729,6 +835,9 @@ func (c *WorksmobileHTTPClient) sendJSONWithToken(ctx context.Context, method st
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -801,6 +910,7 @@ type WorksmobileRemoteUser struct {
|
||||
ExternalID string `json:"externalId"`
|
||||
UserName string `json:"userName"`
|
||||
Email string `json:"email"`
|
||||
AliasEmails []string `json:"aliasEmails,omitempty"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CellPhone string `json:"cellPhone,omitempty"`
|
||||
EmployeeNumber string `json:"employeeNumber,omitempty"`
|
||||
@@ -817,6 +927,10 @@ type WorksmobileRemoteUser struct {
|
||||
OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"`
|
||||
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
IsAwaiting bool `json:"isAwaiting"`
|
||||
IsPending bool `json:"isPending"`
|
||||
IsSuspended bool `json:"isSuspended"`
|
||||
IsDeleted bool `json:"isDeleted"`
|
||||
}
|
||||
|
||||
type WorksmobileRemoteGroup struct {
|
||||
@@ -852,18 +966,22 @@ func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSC
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(payload.CellPhone) != "" {
|
||||
result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: strings.TrimSpace(payload.CellPhone), Primary: true, Type: "mobile"}}
|
||||
result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: normalizeWorksmobileOutboundCellPhone(payload.CellPhone), Primary: true, Type: "mobile"}}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeWorksmobileOutboundCellPhone(value string) string {
|
||||
return domain.NormalizePhoneNumber(value)
|
||||
}
|
||||
|
||||
func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload {
|
||||
return WorksmobileUserPatchPayload{
|
||||
DomainID: payload.DomainID,
|
||||
Email: strings.TrimSpace(payload.Email),
|
||||
UserExternalKey: strings.TrimSpace(payload.UserExternalKey),
|
||||
UserName: payload.UserName,
|
||||
CellPhone: strings.TrimSpace(payload.CellPhone),
|
||||
CellPhone: normalizeWorksmobileOutboundCellPhone(payload.CellPhone),
|
||||
EmployeeNumber: strings.TrimSpace(payload.EmployeeNumber),
|
||||
AliasEmails: payload.AliasEmails,
|
||||
Locale: strings.TrimSpace(payload.Locale),
|
||||
@@ -937,6 +1055,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
ExternalID: firstStringFromMap(resource, "userExternalKey", "externalKey", "externalId"),
|
||||
UserName: email,
|
||||
Email: email,
|
||||
AliasEmails: stringListFromMap(resource, "aliasEmails"),
|
||||
DisplayName: parseWorksmobileDirectoryUserName(resource),
|
||||
CellPhone: firstStringFromMap(resource, "cellPhone", "phoneNumber", "phone", "mobile", "mobilePhone"),
|
||||
EmployeeNumber: firstStringFromMap(
|
||||
@@ -954,6 +1073,10 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
if active, ok := resource["active"].(bool); ok {
|
||||
user.Active = active
|
||||
}
|
||||
user.IsAwaiting = boolFromMap(resource, "isAwaiting")
|
||||
user.IsPending = boolFromMap(resource, "isPending")
|
||||
user.IsSuspended = boolFromMap(resource, "isSuspended")
|
||||
user.IsDeleted = boolFromMap(resource, "isDeleted")
|
||||
primaryOrgUnit := parseWorksmobilePrimaryOrgUnitDetail(resource)
|
||||
user.PrimaryOrgUnitID = primaryOrgUnit.ID
|
||||
user.PrimaryOrgUnitName = primaryOrgUnit.Name
|
||||
@@ -1285,6 +1408,25 @@ func firstStringFromMap(values map[string]any, keys ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringListFromMap(values map[string]any, key string) []string {
|
||||
raw, ok := values[key].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
value, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func boolFromMap(values map[string]any, key string) bool {
|
||||
value, _ := values[key].(bool)
|
||||
return value
|
||||
@@ -1324,6 +1466,42 @@ func (c *WorksmobileHTTPClient) requestURL(path string) (string, error) {
|
||||
return strings.TrimRight(baseURL, "/") + path, nil
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) waitForWorksmobileAPI(ctx context.Context, method string, requestURL *url.URL) error {
|
||||
if c.RateLimiter == nil {
|
||||
return nil
|
||||
}
|
||||
return c.RateLimiter.Wait(ctx, worksmobileRateLimitKey(method, requestURL))
|
||||
}
|
||||
|
||||
func worksmobileRateLimitKey(method string, requestURL *url.URL) string {
|
||||
normalizedMethod := strings.ToUpper(strings.TrimSpace(method))
|
||||
if normalizedMethod == "" {
|
||||
normalizedMethod = "GET"
|
||||
}
|
||||
return normalizedMethod + " " + normalizeWorksmobileRateLimitPath(requestURL)
|
||||
}
|
||||
|
||||
func normalizeWorksmobileRateLimitPath(requestURL *url.URL) string {
|
||||
if requestURL == nil {
|
||||
return "/"
|
||||
}
|
||||
path := requestURL.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
segments := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(segments) == 1 && segments[0] == "" {
|
||||
return "/"
|
||||
}
|
||||
for i := 1; i < len(segments); i++ {
|
||||
switch strings.ToLower(segments[i-1]) {
|
||||
case "users", "orgunits", "groups", "alias-emails":
|
||||
segments[i] = "{id}"
|
||||
}
|
||||
}
|
||||
return "/" + strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user