From f7e4d43b16ba7a09e302730d911835b6b3099053 Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 30 Apr 2026 15:45:34 +0900 Subject: [PATCH] Implement tenant import and RP auto login policies --- .gitea/workflows/production_release.yml | 3 +- .gitea/workflows/staging_release.yml | 2 + README.md | 76 +++ adminfront/seed-tenant.csv | 3 + .../components/DomainTagInput.test.tsx | 50 ++ .../tenants/components/DomainTagInput.tsx | 187 +++++++ .../tenants/routes/TenantCreatePage.tsx | 51 +- .../tenants/routes/TenantListPage.tsx | 120 ++++- .../tenants/routes/TenantProfilePage.tsx | 55 +- .../tenants/routes/TenantSchemaPage.test.ts | 35 ++ .../tenants/routes/TenantSchemaPage.tsx | 129 +++-- .../features/tenants/utils/domainTags.test.ts | 67 +++ .../src/features/tenants/utils/domainTags.ts | 59 +++ .../tenants/utils/tenantCsvImport.test.ts | 85 ++++ .../features/tenants/utils/tenantCsvImport.ts | 193 ++++++- .../src/features/users/UserCreatePage.tsx | 11 +- .../src/features/users/UserDetailPage.tsx | 11 +- .../src/features/users/UserListPage.tsx | 20 +- .../users/components/UserBulkUploadModal.tsx | 335 +++++++++++- .../src/features/users/userSchemaFields.ts | 18 + .../features/users/utils/csvParser.test.ts | 20 + .../src/features/users/utils/csvParser.ts | 46 ++ .../users/utils/hanmacImportEmail.test.ts | 73 +++ .../features/users/utils/hanmacImportEmail.ts | 296 +++++++++++ adminfront/src/lib/adminApi.ts | 28 +- adminfront/tests/tenant_domains.spec.ts | 156 ++++++ adminfront/tests/tenant_schema.spec.ts | 124 +++++ adminfront/tests/tenants.spec.ts | 95 ++++ adminfront/tests/users.spec.ts | 3 + adminfront/tests/users_bulk.spec.ts | 74 +++ adminfront/tests/users_schema.spec.ts | 1 + adminfront/vite.config.ts | 3 + backend/cmd/server/main.go | 5 + backend/docs/openapi.yaml | 8 + backend/internal/bootstrap/bootstrap.go | 18 + backend/internal/bootstrap/tenant_seed.go | 391 ++++++++++---- .../internal/bootstrap/tenant_seed_test.go | 78 +-- backend/internal/domain/hanmac_email.go | 196 +++++++ backend/internal/domain/hanmac_email_test.go | 76 +++ backend/internal/domain/hydra_models.go | 2 + backend/internal/domain/rp_user_metadata.go | 16 + backend/internal/domain/tenant_domain.go | 4 +- backend/internal/handler/auth_handler.go | 255 ++++++++-- .../auth_handler_dynamic_claims_test.go | 98 ++++ .../handler/auth_handler_linked_test.go | 47 +- backend/internal/handler/dev_handler.go | 140 ++++- .../handler/dev_handler_rp_metadata_test.go | 94 ++++ backend/internal/handler/dev_handler_test.go | 30 ++ .../internal/handler/hanmac_email_policy.go | 244 +++++++++ backend/internal/handler/tenant_handler.go | 480 ++++++++++++++---- .../internal/handler/tenant_handler_test.go | 170 ++++++- backend/internal/handler/user_handler.go | 125 ++++- backend/internal/handler/user_handler_test.go | 224 +++++++- backend/internal/repository/main_test.go | 2 +- .../repository/rp_user_metadata_repository.go | 40 ++ .../internal/repository/tenant_repository.go | 15 + .../repository/tenant_repository_test.go | 43 ++ deploy/templates/adminfront/vite.config.ts | 3 + deploy/templates/docker-compose.yaml | 3 + .../features/clients/ClientGeneralPage.tsx | 105 ++++ .../tests/devfront-clients-lifecycle.spec.ts | 27 + docker-compose.yaml | 2 + docker/docker-compose.staging.template.yaml | 3 + docker/docker-compose.template.yaml | 3 + docker/staging_pull_compose.template.yaml | 2 + docs/custom-field-jsonb-index-policy.md | 121 +++++ docs/rp-auto-login-guide.md | 127 +++++ orgfront/scripts/runtime-mode.sh | 4 +- orgfront/tests/orgfront-auto-login.spec.ts | 43 ++ scripts/test_staging_workflow_env.sh | 6 + test/orgfront_integration_policy_test.sh | 6 +- .../dashboard/domain/linked_rp_launch.dart | 12 +- .../lib/features/dashboard/domain/models.dart | 6 + .../domain/providers/linked_rps_provider.dart | 6 + .../presentation/dashboard_screen.dart | 13 + userfront/test/linked_rp_launch_test.dart | 26 +- 76 files changed, 5307 insertions(+), 441 deletions(-) create mode 100644 adminfront/seed-tenant.csv create mode 100644 adminfront/src/features/tenants/components/DomainTagInput.test.tsx create mode 100644 adminfront/src/features/tenants/components/DomainTagInput.tsx create mode 100644 adminfront/src/features/tenants/routes/TenantSchemaPage.test.ts create mode 100644 adminfront/src/features/tenants/utils/domainTags.test.ts create mode 100644 adminfront/src/features/tenants/utils/domainTags.ts create mode 100644 adminfront/src/features/users/userSchemaFields.ts create mode 100644 adminfront/src/features/users/utils/hanmacImportEmail.test.ts create mode 100644 adminfront/src/features/users/utils/hanmacImportEmail.ts create mode 100644 adminfront/tests/tenant_domains.spec.ts create mode 100644 adminfront/tests/tenant_schema.spec.ts create mode 100644 backend/internal/domain/hanmac_email.go create mode 100644 backend/internal/domain/hanmac_email_test.go create mode 100644 backend/internal/domain/rp_user_metadata.go create mode 100644 backend/internal/handler/dev_handler_rp_metadata_test.go create mode 100644 backend/internal/handler/hanmac_email_policy.go create mode 100644 backend/internal/repository/rp_user_metadata_repository.go create mode 100644 docs/custom-field-jsonb-index-policy.md create mode 100644 docs/rp-auto-login-guide.md create mode 100644 orgfront/tests/orgfront-auto-login.spec.ts diff --git a/.gitea/workflows/production_release.yml b/.gitea/workflows/production_release.yml index 81a9517e..71c7fdb8 100644 --- a/.gitea/workflows/production_release.yml +++ b/.gitea/workflows/production_release.yml @@ -89,7 +89,7 @@ jobs: ssh-keyscan -H "${PROD_HOST}" >> ~/.ssh/known_hosts - ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}'" + ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}/adminfront'" # Create the main .env file for Baron SSO on the remote server # Note: All values are pulled from Gitea secrets and variables @@ -122,6 +122,7 @@ jobs: > .env # Copy compose template and .env file to the remote server + scp adminfront/seed-tenant.csv "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/adminfront/" scp docker/docker-compose.template.yaml .env "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/" scp docker/compose.infra.prd.yaml "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/compose.infra.yml" diff --git a/.gitea/workflows/staging_release.yml b/.gitea/workflows/staging_release.yml index a3e84d53..d3e63209 100644 --- a/.gitea/workflows/staging_release.yml +++ b/.gitea/workflows/staging_release.yml @@ -142,6 +142,7 @@ jobs: # 파일 복사 ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker" + ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/adminfront" # [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함) scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/" @@ -154,6 +155,7 @@ jobs: scp -r gateway "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/" fi + scp adminfront/seed-tenant.csv "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/adminfront/" scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/" scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml" scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml" diff --git a/README.md b/README.md index 242549fc..776bb0bc 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,82 @@ flowchart - RP 등록 및 관리 - RP별 Consent 관리 +## 관리 데이터 Export/Import 정책 + +AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접 검토하고 재반입할 수 있는 흐름을 기준으로 설계합니다. 기본 원칙은 내부 UUID를 불필요하게 노출하지 않고, 사람이 이해하기 쉬운 `slug`와 이름을 우선 사용하는 것입니다. + +### 공통 원칙 +- CSV는 Excel 호환을 위해 UTF-8 BOM을 포함해 내려받습니다. +- 기본 export는 시스템 내부 ID를 제외합니다. +- 같은 데이터를 정확히 재동기화해야 하는 운영 작업에서는 `includeIds=true` 옵션으로 내부 ID 컬럼을 포함할 수 있습니다. +- import는 preview/검토 단계를 거친 뒤 실행하는 것을 기본으로 합니다. +- 기존 데이터와 충돌 가능성이 있는 row는 자동 적용하지 않고 관리자 선택 또는 확인 상태로 표시합니다. +- 삭제는 export/import로 암묵 처리하지 않습니다. 삭제가 필요하면 별도 삭제 기능을 사용합니다. + +### Tenant Export +- 기본 컬럼은 운영자가 다시 import하기 쉬운 형태를 유지합니다. +- `includeIds=false`가 기본이며, 이 경우 내부 `tenant_id`는 제외합니다. +- `includeIds=true`를 사용하면 기존 테넌트 update 또는 staging/production 간 매핑 확인에 필요한 ID를 포함합니다. +- 주요 의미: + - `tenant_id`: 내부 UUID. 기본 export에서는 제외됩니다. + - `name`: 테넌트 표시 이름입니다. + - `type`: `PERSONAL`, `COMPANY`, `COMPANY_GROUP`, `USER_GROUP` 중 하나입니다. + - `parent_tenant_id`: 상위 테넌트 내부 ID입니다. + - `parent_tenant_slug`: 상위 테넌트를 slug로 연결할 때 사용합니다. + - `slug`: 운영상 사람이 다루는 테넌트 식별자입니다. + - `memo`: 설명 또는 비고입니다. + - `email_domain`: 테넌트에 연결된 이메일 도메인입니다. 여러 도메인은 `;`, `,`, 줄바꿈으로 구분할 수 있습니다. + +### Tenant Import +- 필수 컬럼은 `name`, `type`, `slug`입니다. +- 허용되는 header alias: + - `tenant_id`: `id`, `tenantid`, `tenant_id` + - `parent_tenant_id`: `parentid`, `parent_id`, `parenttenantid`, `parent_tenant_id` + - `parent_tenant_slug`: `parenttenantslug`, `parent_tenant_slug` + - `memo`: `memo`, `description` + - `email_domain`: `email-domain`, `emaildomain`, `email_domain`, `domain`, `domains` +- `tenant_id`가 있고 기존 테넌트가 있으면 update 대상으로 봅니다. +- `tenant_id`가 없으면 `slug` 기준으로 기존 테넌트를 찾고, 없으면 신규 생성 후보로 봅니다. +- `parent_tenant_slug`가 같은 import 파일 안에 있으면 부모 row를 먼저 처리하도록 정렬합니다. +- import preview는 이름/slug 유사도 기반 후보를 보여주며, 관리자가 기존 테넌트 사용, 신규 생성, skip 중 선택할 수 있어야 합니다. +- 외부 시스템에서 가져온 `tenant_id`처럼 현재 DB에 없는 ID는 충돌로 표시하고, 관리자가 새 slug 또는 기존 테넌트 매핑을 결정해야 합니다. + +### User Export +- 기본 컬럼은 `Email`, `Name`, `Phone`, `Status`, `tenant_slug`, `Position`, `JobTitle`, `CreatedAt`입니다. +- `includeIds=true`이면 `user_id`, `tenant_id`를 함께 포함합니다. +- 사용자 role은 export 기본 컬럼에서 제외합니다. role은 일괄 변경의 실수 위험이 크므로 명시적 관리 화면 또는 별도 정책으로 다룹니다. +- 사용자 metadata는 `Meta:` 컬럼으로 뒤에 추가됩니다. +- `includeIds=false`일 때는 `id`, `user_id`, `tenant_id`, `tenantid` 성격의 metadata key를 export에서 제외합니다. +- tenant admin의 export는 관리 가능한 테넌트 범위로 제한됩니다. + +### User Import +- 사용자 CSV의 기본 컬럼은 `email`, `name`, `phone`, `role`, `tenant_slug`, `department`, `position`, `jobTitle`입니다. +- `email`과 `name`은 CSV parsing 단계의 필수값입니다. +- backend 생성 단계에서는 `tenantSlug`도 필수입니다. +- `tenant`, `tenant_slug`, `companyCode` header는 사용자 소속 테넌트 slug로 매핑됩니다. +- `tenant_id`, `tenant_name`, `tenant_type`, `parent_tenant_id`, `parent_tenant_slug`, `parent_tenant_name`, `tenant_memo`, `email_domain` 컬럼이 있으면 사용자 import 과정에서 필요한 테넌트 생성/매핑 preview에 사용합니다. +- 위 기본 컬럼에 속하지 않는 컬럼은 사용자 metadata로 들어갑니다. +- 테넌트에 `userSchema`가 있으면 import 중 metadata required/validation/loginId 규칙을 적용합니다. +- 테넌트 schema에서 `isLoginId`로 지정된 metadata 값은 custom login ID로 동기화하며, 이메일/전화번호/예약어와 충돌하지 않아야 합니다. + +### 한맥가족 User Import Email 정책 +- 전체 시스템에서 `users.email`은 unique입니다. +- 한맥가족 테넌트 root(`hanmac-family`)와 그 하위 subtree에서는 이메일 도메인과 무관하게 `@` 앞 local-part도 unique 해야 합니다. +- 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다. +- `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다. +- 이름 기반 local-part 기본 규칙은 `이름 부분 초성 + 성 로마자`입니다. + - 예: `한치영` -> `치영`의 초성 `c + y` + 성 `han` -> `cyhan` +- 이미 `cyhan`, `cyhan1`이 있으면 다음 후보인 `cyhan2`를 제안합니다. +- 외부 로마자화 패키지는 backend 의존성으로 추가하지 않고, 내부 한글 음절 분해와 성씨/초성 매핑을 사용합니다. +- import preview의 row 상태: + - `valid`: unique와 이름 기반 권장 규칙을 모두 만족합니다. + - `suggested`: 도메인만 있거나 suffix 제안이 필요한 row입니다. + - `needsReview`: 이름 매핑이 애매해 관리자가 직접 확인해야 합니다. + - `ruleMismatch`: 최종 local-part가 `이름 이니셜 + 성 + 숫자 suffix` 규칙과 다릅니다. 예외 진행은 가능하지만 관리자에게 표시해야 합니다. + - `blockingError`: local-part 중복, email 형식 오류, 필수값 누락처럼 생성을 차단해야 하는 상태입니다. +- 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다. +- bulk import는 preview에서 제안/수정된 최종 email을 사용하되, backend가 생성 직전에 다시 unique 규칙을 검증합니다. + ### 4. 주요 시나리오 (Core Scenarios) 1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인). diff --git a/adminfront/seed-tenant.csv b/adminfront/seed-tenant.csv new file mode 100644 index 00000000..7df8c628 --- /dev/null +++ b/adminfront/seed-tenant.csv @@ -0,0 +1,3 @@ +name,type,parent_tenant_slug,slug,memo,email_domain +한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트, +Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트, diff --git a/adminfront/src/features/tenants/components/DomainTagInput.test.tsx b/adminfront/src/features/tenants/components/DomainTagInput.test.tsx new file mode 100644 index 00000000..e9940c4b --- /dev/null +++ b/adminfront/src/features/tenants/components/DomainTagInput.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { DomainTagInput } from "./DomainTagInput"; + +describe("DomainTagInput", () => { + it("shows a clear duplicate tenant warning and adds the domain after confirmation", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const onConfirmedConflictsChange = vi.fn(); + + render( + , + ); + + await user.type(screen.getByPlaceholderText("example.com"), "samaneng.com "); + + expect( + await screen.findByText( + "samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?", + ), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "계속 진행" })); + + expect(onChange).toHaveBeenCalledWith(["samaneng.com"]); + expect(onConfirmedConflictsChange).toHaveBeenCalledWith(["samaneng.com"]); + }); +}); diff --git a/adminfront/src/features/tenants/components/DomainTagInput.tsx b/adminfront/src/features/tenants/components/DomainTagInput.tsx new file mode 100644 index 00000000..ecfc5513 --- /dev/null +++ b/adminfront/src/features/tenants/components/DomainTagInput.tsx @@ -0,0 +1,187 @@ +import { X } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../../components/ui/dialog"; +import { Input } from "../../../components/ui/input"; +import type { TenantSummary } from "../../../lib/adminApi"; +import { t } from "../../../lib/i18n"; +import { + type DomainConflict, + findDomainConflict, + formatDomainConflictMessage, + normalizeDomainTokens, +} from "../utils/domainTags"; + +type DomainTagInputProps = { + id?: string; + value: string[]; + onChange: (domains: string[]) => void; + tenants?: TenantSummary[]; + currentTenantId?: string; + confirmedConflicts?: string[]; + onConfirmedConflictsChange?: (domains: string[]) => void; + placeholder?: string; +}; + +export function DomainTagInput({ + id, + value, + onChange, + tenants = [], + currentTenantId, + confirmedConflicts = [], + onConfirmedConflictsChange, + placeholder, +}: DomainTagInputProps) { + const [input, setInput] = useState(""); + const [pendingConflict, setPendingConflict] = useState( + null, + ); + + const addConfirmedConflict = (domain: string) => { + if (!confirmedConflicts.includes(domain)) { + onConfirmedConflictsChange?.([...confirmedConflicts, domain]); + } + }; + + const removeConfirmedConflict = (domain: string) => { + if (confirmedConflicts.includes(domain)) { + onConfirmedConflictsChange?.( + confirmedConflicts.filter((item) => item !== domain), + ); + } + }; + + const addDomain = (domain: string, confirmed = false) => { + if (value.includes(domain)) { + return; + } + onChange([...value, domain]); + if (confirmed) { + addConfirmedConflict(domain); + } + }; + + const tokenizeInput = () => { + const tokens = normalizeDomainTokens(input); + if (tokens.length === 0) { + setInput(""); + return; + } + + for (const token of tokens) { + if (value.includes(token)) { + continue; + } + const conflict = findDomainConflict(token, tenants, currentTenantId); + if (conflict && !confirmedConflicts.includes(token)) { + setPendingConflict(conflict); + setInput(""); + return; + } + addDomain(token, confirmedConflicts.includes(token)); + } + setInput(""); + }; + + const removeDomain = (domain: string) => { + onChange(value.filter((item) => item !== domain)); + removeConfirmedConflict(domain); + }; + + return ( + <> +
+ {value.map((domain) => ( + + {domain} + + + ))} + setInput(event.target.value)} + onBlur={tokenizeInput} + onKeyDown={(event) => { + if ( + event.key === " " || + event.key === "Enter" || + event.key === "," || + event.key === ";" + ) { + event.preventDefault(); + tokenizeInput(); + } + }} + className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0" + placeholder={value.length === 0 ? placeholder : undefined} + /> +
+ + { + if (!open) { + setPendingConflict(null); + } + }} + > + + + + {t("ui.admin.tenants.domain_conflict.title", "도메인 충돌")} + + + {pendingConflict + ? t( + "ui.admin.tenants.domain_conflict.description", + formatDomainConflictMessage(pendingConflict), + ) + : ""} + + + + + + + + + + ); +} diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index 7850849d..bddfa350 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -3,7 +3,6 @@ import type { AxiosError } from "axios"; import { Building2, Sparkles } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { Card, @@ -17,6 +16,11 @@ import { Label } from "../../../components/ui/label"; import { Textarea } from "../../../components/ui/textarea"; import { createTenant, fetchTenants } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { DomainTagInput } from "../components/DomainTagInput"; +import { + formatDomainConflictMessage, + type ServerDomainConflict, +} from "../utils/domainTags"; function TenantCreatePage() { const navigate = useNavigate(); @@ -26,7 +30,10 @@ function TenantCreatePage() { const [parentId, setParentId] = useState(""); const [description, setDescription] = useState(""); const [status, setStatus] = useState("active"); - const [domains, setDomains] = useState(""); + const [domains, setDomains] = useState([]); + const [forceDomainConflicts, setForceDomainConflicts] = useState( + [], + ); const parentQuery = useQuery({ queryKey: ["tenants", { limit: 1000 }], @@ -34,7 +41,7 @@ function TenantCreatePage() { }); const mutation = useMutation({ - mutationFn: () => + mutationFn: (overrideForceDomains?: string[]) => createTenant({ name, type, @@ -42,14 +49,34 @@ function TenantCreatePage() { parentId: parentId || undefined, description: description || undefined, status, - domains: domains - .split(",") - .map((d) => d.trim()) - .filter((d) => d !== ""), + domains, + forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts, }), onSuccess: () => { navigate("/tenants"); }, + onError: ( + err: AxiosError<{ + code?: string; + error?: string; + conflicts?: ServerDomainConflict[]; + }>, + ) => { + const conflicts = err.response?.data?.conflicts ?? []; + if ( + err.response?.data?.code === "tenant_domain_conflict" && + conflicts.length > 0 + ) { + const nextForceDomains = Array.from( + new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]), + ); + const message = conflicts.map(formatDomainConflictMessage).join("\n"); + if (window.confirm(message)) { + setForceDomainConflicts(nextForceDomains); + mutation.mutate(nextForceDomains); + } + } + }, }); const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response @@ -195,11 +222,13 @@ function TenantCreatePage() { "허용된 도메인 (콤마로 구분)", )} - setDomains(e.target.value)} + onChange={setDomains} + tenants={parentQuery.data?.items ?? []} + confirmedConflicts={forceDomainConflicts} + onConfirmedConflictsChange={setForceDomainConflicts} placeholder={t( "ui.admin.tenants.create.form.domains_placeholder", "example.com, example.kr", @@ -268,7 +297,7 @@ function TenantCreatePage() { {t("ui.common.cancel", "취소")} + + query.refetch()} /> diff --git a/adminfront/src/features/users/components/UserBulkUploadModal.tsx b/adminfront/src/features/users/components/UserBulkUploadModal.tsx index 27a54e3d..9060d22e 100644 --- a/adminfront/src/features/users/components/UserBulkUploadModal.tsx +++ b/adminfront/src/features/users/components/UserBulkUploadModal.tsx @@ -1,4 +1,4 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { AlertCircle, CheckCircle2, @@ -23,22 +23,129 @@ import { type BulkUserItem, type BulkUserResult, bulkCreateUsers, + createTenant, + fetchTenants, + fetchUsers, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { + type TenantCSVRow, + type TenantImportPreviewRow, + buildTenantImportPreview, +} from "../../tenants/utils/tenantCsvImport"; import { parseUserCSV } from "../utils/csvParser"; +import { + type HanmacImportEmailPreview, + buildHanmacImportEmailPreview, +} from "../utils/hanmacImportEmail"; +import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker"; interface UserBulkUploadModalProps { onSuccess?: () => void; } +function buildUserTenantPreviewRows( + users: BulkUserItem[], + tenants: Parameters[1], +) { + const rowsByKey = new Map(); + + users.forEach((user, index) => { + const key = tenantImportKeyFromUser(user); + if (!key || rowsByKey.has(key)) { + return; + } + + rowsByKey.set(key, { + rowNumber: index + 2, + tenantId: user.tenantImport?.sourceTenantId ?? "", + name: user.tenantImport?.name || user.tenantSlug || key, + type: user.tenantImport?.type || "COMPANY", + parentTenantId: user.tenantImport?.parentTenantId ?? "", + parentTenantSlug: user.tenantImport?.parentTenantSlug ?? "", + slug: user.tenantImport?.slug || user.tenantSlug || key, + memo: user.tenantImport?.memo ?? "", + emailDomain: user.tenantImport?.emailDomain ?? "", + }); + }); + + return buildTenantImportPreview([...rowsByKey.values()], tenants); +} + +function tenantImportKeyFromUser(user: BulkUserItem) { + return ( + user.tenantImport?.sourceTenantId || + user.tenantImport?.slug || + user.tenantSlug || + user.tenantImport?.name || + "" + ); +} + +function tenantImportKeyFromRow(row: TenantCSVRow) { + return row.tenantId || row.slug || row.name; +} + +function splitTenantImportDomains(value: string) { + return value + .replaceAll("\n", ";") + .replaceAll(",", ";") + .split(";") + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean); +} + +function emailLocalPart(email: string) { + return email.trim().toLowerCase().split("@")[0] || ""; +} + +function hanmacEmailStatusLabel(preview?: HanmacImportEmailPreview) { + if (!preview) return ""; + if (preview.status === "suggested") return "제안"; + if (preview.status === "needsReview") return "확인 필요"; + if (preview.status === "ruleMismatch") return "규칙 확인"; + if (preview.status === "blockingError") return "오류"; + return ""; +} + +function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) { + if (!preview) return "text-muted-foreground"; + if (preview.status === "blockingError") return "text-destructive"; + if (preview.status === "ruleMismatch" || preview.status === "needsReview") { + return "text-amber-600"; + } + if (preview.status === "suggested") return "text-blue-600"; + return "text-muted-foreground"; +} + export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) { const [open, setOpen] = React.useState(false); const [file, setFile] = React.useState(null); const [parsing, setParsing] = React.useState(false); const [previewData, setPreviewData] = React.useState([]); + const [tenantPreviewRows, setTenantPreviewRows] = React.useState< + TenantImportPreviewRow[] + >([]); + const [selectedTenantMatches, setSelectedTenantMatches] = React.useState< + Record + >({}); + const [selectedTenantCreateSlugs, setSelectedTenantCreateSlugs] = + React.useState>({}); const [results, setResults] = React.useState(null); + const [preparing, setPreparing] = React.useState(false); const fileInputRef = React.useRef(null); + const tenantQuery = useQuery({ + queryKey: ["tenants", "user-bulk-import"], + queryFn: () => fetchTenants(1000, 0), + }); + + const usersQuery = useQuery({ + queryKey: ["users", "user-bulk-import-email-policy"], + queryFn: () => fetchUsers(10000, 0), + enabled: open, + }); + const mutation = useMutation({ mutationFn: bulkCreateUsers, onSuccess: (data) => { @@ -62,20 +169,87 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) { const text = e.target?.result as string; const data = parseUserCSV(text); setPreviewData(data); + const tenantRows = buildUserTenantPreviewRows( + data, + tenantQuery.data?.items ?? [], + ); + setTenantPreviewRows(tenantRows); + setSelectedTenantMatches( + Object.fromEntries( + tenantRows.map((row) => [ + row.row.rowNumber, + row.defaultTenantId || "__create__", + ]), + ), + ); + setSelectedTenantCreateSlugs( + Object.fromEntries( + tenantRows.map((row) => [row.row.rowNumber, row.defaultCreateSlug]), + ), + ); setParsing(false); }; reader.readAsText(file); }; - const handleUpload = () => { + const handleUpload = async () => { if (previewData.length > 0) { - mutation.mutate(previewData); + setPreparing(true); + try { + const users = await resolveUserImportTenants(); + mutation.mutate(users); + } finally { + setPreparing(false); + } } }; + const resolveUserImportTenants = async () => { + const tenants = tenantQuery.data?.items ?? []; + const tenantSlugByKey = new Map(); + + for (const preview of tenantPreviewRows) { + const key = tenantImportKeyFromRow(preview.row); + const selected = + selectedTenantMatches[preview.row.rowNumber] ?? "__create__"; + if (selected !== "__create__") { + const tenant = tenants.find((item) => item.id === selected); + if (tenant) { + tenantSlugByKey.set(key, tenant.slug); + } + continue; + } + + const created = await createTenant({ + name: preview.row.name || preview.row.slug, + slug: + selectedTenantCreateSlugs[preview.row.rowNumber] || + preview.defaultCreateSlug, + type: preview.row.type || "COMPANY", + parentId: preview.row.parentTenantId || undefined, + description: preview.row.memo, + domains: splitTenantImportDomains(preview.row.emailDomain), + status: "active", + }); + tenantSlugByKey.set(key, created.slug); + } + + return previewData.map((user, index) => { + const key = tenantImportKeyFromUser(user); + const tenantSlug = key ? tenantSlugByKey.get(key) : user.tenantSlug; + const emailPreview = hanmacEmailPreviews[index]; + const { tenantImport: _tenantImport, ...payload } = user; + return { + ...payload, + email: emailPreview?.finalEmail ?? payload.email, + tenantSlug, + }; + }); + }; + const downloadTemplate = () => { const headers = - "email,name,phone,role,tenant,department,position,jobTitle,employee_id"; + "email,name,phone,role,tenant_slug,department,position,jobTitle,employee_id"; const example = "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001"; const blob = new Blob([`${headers}\n${example}`], { @@ -92,12 +266,47 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) { const reset = () => { setFile(null); setPreviewData([]); + setTenantPreviewRows([]); + setSelectedTenantMatches({}); + setSelectedTenantCreateSlugs({}); setResults(null); if (fileInputRef.current) fileInputRef.current.value = ""; }; const successCount = results?.filter((r) => r.success).length ?? 0; const failCount = results ? results.length - successCount : 0; + const tenants = tenantQuery.data?.items ?? []; + const existingHanmacLocalParts = React.useMemo(() => { + const values = new Set(); + for (const user of usersQuery.data?.items ?? []) { + if (!isHanmacFamilyUser(user, tenants)) { + continue; + } + const localPart = emailLocalPart(user.email); + if (localPart) values.add(localPart); + } + return values; + }, [tenants, usersQuery.data?.items]); + const hanmacEmailPreviews = React.useMemo(() => { + const batchLocalParts = new Set(); + return previewData.map((user) => { + const tenant = tenants.find( + (item) => + item.slug.toLowerCase() === user.tenantSlug?.trim().toLowerCase(), + ); + if (!isHanmacFamilyTenant(tenant, tenants)) { + return undefined; + } + return buildHanmacImportEmailPreview( + user, + existingHanmacLocalParts, + batchLocalParts, + ); + }); + }, [existingHanmacLocalParts, previewData, tenants]); + const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some( + (preview) => preview?.status === "blockingError", + ); return ( )} + {tenantPreviewRows.length > 0 && ( +
+
+ {t( + "ui.admin.users.bulk.tenant_resolution", + "테넌트 매핑", + )} +
+
+ {tenantPreviewRows.map((preview) => ( +
+
+
{preview.row.name}
+
+ {preview.row.slug} +
+
+
+ + {(selectedTenantMatches[preview.row.rowNumber] ?? + "__create__") === "__create__" && ( + + setSelectedTenantCreateSlugs((prev) => ({ + ...prev, + [preview.row.rowNumber]: event.target.value, + })) + } + /> + )} +
+
+ ))} +
+
+ )} + {previewData.length > 0 && ( @@ -193,20 +478,45 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) { + - {previewData.slice(0, 10).map((u) => ( - - + {previewData.slice(0, 10).map((u, index) => ( + + + ))} {previewData.length > 10 && (
Email Name TenantStatus
{u.email}
+ + setPreviewData((prev) => + prev.map((item, itemIndex) => + itemIndex === index + ? { ...item, email: event.target.value } + : item, + ), + ) + } + /> + {u.name} {u.tenantSlug || "-"} + {hanmacEmailStatusLabel(hanmacEmailPreviews[index])} + {hanmacEmailPreviews[index]?.reason && ( +
{hanmacEmailPreviews[index]?.reason}
+ )} +
... and {previewData.length - 10} more users @@ -277,11 +587,16 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) { {!results ? (