1
0
forked from baron/baron-sso

chore: consolidate local integration changes

This commit is contained in:
2026-06-09 21:03:05 +09:00
parent aa2848c3b6
commit 1341f07ef9
158 changed files with 10995 additions and 1490 deletions

View File

@@ -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

View File

@@ -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 ..

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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,개인 사용자 기본 루트 테넌트,,,,
1 id name type parent_tenant_slug slug memo email_domain visibility org_unit_type worksmobile_sync
9 b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 장헌산업 COMPANY baron-group jangheon-sanup jangheon.co.kr
10 e57cb22c-383e-4489-8c2f-0c5431917e86 (주)피티씨 COMPANY baron-group ptc pre-cast.co.kr
11 4d0f26b9-702c-4bc6-8996-46e9eedfdeb7 MH_manager USER_GROUP hanmac-family mhd 맨아워 대시보드 권한 보유자그룹 private no
12 e41adf79-3d15-4807-8303-afbdb0f2bab7 SW_uploader USER_GROUP hanmac-family sw-uploader 소프트웨어 배포 권한 그룹 private no
13 9607eb7b-04d2-42ab-80fe-780fe21c7e8f Personal PERSONAL personal 개인 사용자 기본 루트 테넌트

View File

@@ -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];

View File

@@ -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 /> },
],
},

View File

@@ -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"

View File

@@ -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",

View File

@@ -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, {

View 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");
});
});

View File

@@ -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",

View File

@@ -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();

View File

@@ -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}

View 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");
});
});

View File

@@ -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,

View File

@@ -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}

View File

@@ -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"

View File

@@ -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);
});
});
});

View File

@@ -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 선택" }));

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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"]);
});
});

View File

@@ -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) ?? [];

View File

@@ -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) =>

View File

@@ -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) =>

View File

@@ -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,
});
});
});
});

View File

@@ -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">

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,
}));

View File

@@ -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[],

View File

@@ -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"

View 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>
);
}

View File

@@ -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)}

View File

@@ -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",
},
}),
}),
}),
);
});
});

View File

@@ -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

View File

@@ -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);

View File

@@ -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 */}

View File

@@ -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)}

View File

@@ -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 ??

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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"

View File

@@ -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 = "테넌트에 추가"

View File

@@ -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 = ""

View 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([]);
});
});

View 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);
}

View File

@@ -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.",
},
};

View File

@@ -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"]'),

View File

@@ -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");

View File

@@ -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,
}) => {

View File

@@ -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");
});
});

View File

@@ -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]")
}

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

View 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)
}
}

View File

@@ -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.")
}
}

View File

@@ -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)

View File

@@ -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
)
}

View 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"`
}

View 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
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)

View File

@@ -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]

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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" {

View File

@@ -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 == "" {

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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, "")

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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) {

View 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)
}

View File

@@ -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
}

View 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)
}

View File

@@ -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"),

View File

@@ -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) {

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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