forked from baron/baron-sso
Implement tenant import and RP auto login policies
This commit is contained in:
@@ -89,7 +89,7 @@ jobs:
|
|||||||
|
|
||||||
ssh-keyscan -H "${PROD_HOST}" >> ~/.ssh/known_hosts
|
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
|
# Create the main .env file for Baron SSO on the remote server
|
||||||
# Note: All values are pulled from Gitea secrets and variables
|
# Note: All values are pulled from Gitea secrets and variables
|
||||||
@@ -122,6 +122,7 @@ jobs:
|
|||||||
> .env
|
> .env
|
||||||
|
|
||||||
# Copy compose template and .env file to the remote server
|
# 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/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"
|
scp docker/compose.infra.prd.yaml "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/compose.infra.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}/docker"
|
||||||
|
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/adminfront"
|
||||||
|
|
||||||
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
|
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
|
||||||
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
|
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}/"
|
scp -r gateway "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
|
||||||
fi
|
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/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.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml"
|
||||||
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
|
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
|
||||||
|
|||||||
76
README.md
76
README.md
@@ -95,6 +95,82 @@ flowchart
|
|||||||
- RP 등록 및 관리
|
- RP 등록 및 관리
|
||||||
- RP별 Consent 관리
|
- 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:<key>` 컬럼으로 뒤에 추가됩니다.
|
||||||
|
- `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)
|
### 4. 주요 시나리오 (Core Scenarios)
|
||||||
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
|
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
|
||||||
|
|||||||
3
adminfront/seed-tenant.csv
Normal file
3
adminfront/seed-tenant.csv
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
name,type,parent_tenant_slug,slug,memo,email_domain
|
||||||
|
한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,
|
||||||
|
Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,
|
||||||
|
@@ -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(
|
||||||
|
<DomainTagInput
|
||||||
|
value={[]}
|
||||||
|
onChange={onChange}
|
||||||
|
tenants={[
|
||||||
|
{
|
||||||
|
id: "tenant-1",
|
||||||
|
name: "한맥가족",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
type: "COMPANY",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
domains: ["samaneng.com"],
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
currentTenantId="tenant-2"
|
||||||
|
confirmedConflicts={[]}
|
||||||
|
onConfirmedConflictsChange={onConfirmedConflictsChange}
|
||||||
|
placeholder="example.com"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
187
adminfront/src/features/tenants/components/DomainTagInput.tsx
Normal file
187
adminfront/src/features/tenants/components/DomainTagInput.tsx
Normal file
@@ -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<DomainConflict | null>(
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="flex min-h-10 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-within:ring-1 focus-within:ring-ring">
|
||||||
|
{value.map((domain) => (
|
||||||
|
<Badge
|
||||||
|
key={domain}
|
||||||
|
variant={confirmedConflicts.includes(domain) ? "warning" : "muted"}
|
||||||
|
className="gap-1 rounded-md"
|
||||||
|
>
|
||||||
|
<span>{domain}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
||||||
|
onClick={() => removeDomain(domain)}
|
||||||
|
aria-label={t("ui.common.remove", "삭제")}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={pendingConflict !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setPendingConflict(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("ui.admin.tenants.domain_conflict.title", "도메인 충돌")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{pendingConflict
|
||||||
|
? t(
|
||||||
|
"ui.admin.tenants.domain_conflict.description",
|
||||||
|
formatDomainConflictMessage(pendingConflict),
|
||||||
|
)
|
||||||
|
: ""}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPendingConflict(null)}
|
||||||
|
>
|
||||||
|
{t("ui.common.cancel", "취소")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingConflict) {
|
||||||
|
addDomain(pendingConflict.domain, true);
|
||||||
|
}
|
||||||
|
setPendingConflict(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("ui.common.continue", "계속 진행")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
|
|||||||
import { Building2, Sparkles } from "lucide-react";
|
import { Building2, Sparkles } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -17,6 +16,11 @@ import { Label } from "../../../components/ui/label";
|
|||||||
import { Textarea } from "../../../components/ui/textarea";
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
import { DomainTagInput } from "../components/DomainTagInput";
|
||||||
|
import {
|
||||||
|
formatDomainConflictMessage,
|
||||||
|
type ServerDomainConflict,
|
||||||
|
} from "../utils/domainTags";
|
||||||
|
|
||||||
function TenantCreatePage() {
|
function TenantCreatePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -26,7 +30,10 @@ function TenantCreatePage() {
|
|||||||
const [parentId, setParentId] = useState("");
|
const [parentId, setParentId] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState("active");
|
const [status, setStatus] = useState("active");
|
||||||
const [domains, setDomains] = useState("");
|
const [domains, setDomains] = useState<string[]>([]);
|
||||||
|
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const parentQuery = useQuery({
|
const parentQuery = useQuery({
|
||||||
queryKey: ["tenants", { limit: 1000 }],
|
queryKey: ["tenants", { limit: 1000 }],
|
||||||
@@ -34,7 +41,7 @@ function TenantCreatePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (overrideForceDomains?: string[]) =>
|
||||||
createTenant({
|
createTenant({
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
@@ -42,14 +49,34 @@ function TenantCreatePage() {
|
|||||||
parentId: parentId || undefined,
|
parentId: parentId || undefined,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
status,
|
status,
|
||||||
domains: domains
|
domains,
|
||||||
.split(",")
|
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter((d) => d !== ""),
|
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
navigate("/tenants");
|
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
|
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
|
||||||
@@ -195,11 +222,13 @@ function TenantCreatePage() {
|
|||||||
"허용된 도메인 (콤마로 구분)",
|
"허용된 도메인 (콤마로 구분)",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DomainTagInput
|
||||||
id="tenant-domains"
|
id="tenant-domains"
|
||||||
name="domains"
|
|
||||||
value={domains}
|
value={domains}
|
||||||
onChange={(e) => setDomains(e.target.value)}
|
onChange={setDomains}
|
||||||
|
tenants={parentQuery.data?.items ?? []}
|
||||||
|
confirmedConflicts={forceDomainConflicts}
|
||||||
|
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.tenants.create.form.domains_placeholder",
|
"ui.admin.tenants.create.form.domains_placeholder",
|
||||||
"example.com, example.kr",
|
"example.com, example.kr",
|
||||||
@@ -268,7 +297,7 @@ function TenantCreatePage() {
|
|||||||
{t("ui.common.cancel", "취소")}
|
{t("ui.common.cancel", "취소")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => mutation.mutate()}
|
onClick={() => mutation.mutate(undefined)}
|
||||||
disabled={mutation.isPending || name.trim() === ""}
|
disabled={mutation.isPending || name.trim() === ""}
|
||||||
>
|
>
|
||||||
{t("ui.common.create", "생성")}
|
{t("ui.common.create", "생성")}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import {
|
import {
|
||||||
|
type TenantImportResolution,
|
||||||
type TenantImportPreviewRow,
|
type TenantImportPreviewRow,
|
||||||
buildTenantImportPreview,
|
buildTenantImportPreview,
|
||||||
parseTenantCSV,
|
parseTenantCSV,
|
||||||
@@ -58,7 +59,7 @@ import {
|
|||||||
} from "../utils/tenantCsvImport";
|
} from "../utils/tenantCsvImport";
|
||||||
|
|
||||||
const tenantCSVTemplate =
|
const tenantCSVTemplate =
|
||||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n";
|
"name,type,parent_tenant_slug,slug,memo,email_domain\n";
|
||||||
|
|
||||||
function TenantListPage() {
|
function TenantListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -72,6 +73,9 @@ function TenantListPage() {
|
|||||||
const [selectedMatches, setSelectedMatches] = React.useState<
|
const [selectedMatches, setSelectedMatches] = React.useState<
|
||||||
Record<number, string>
|
Record<number, string>
|
||||||
>({});
|
>({});
|
||||||
|
const [selectedCreateSlugs, setSelectedCreateSlugs] = React.useState<
|
||||||
|
Record<number, string>
|
||||||
|
>({});
|
||||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
@@ -117,7 +121,7 @@ function TenantListPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const exportMutation = useMutation({
|
const exportMutation = useMutation({
|
||||||
mutationFn: exportTenantsCSV,
|
mutationFn: (includeIds: boolean) => exportTenantsCSV(includeIds),
|
||||||
onSuccess: ({ blob, filename }) => {
|
onSuccess: ({ blob, filename }) => {
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
@@ -265,16 +269,44 @@ function TenantListPage() {
|
|||||||
setPreviewRows(preview);
|
setPreviewRows(preview);
|
||||||
setSelectedMatches(
|
setSelectedMatches(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
preview
|
preview.map((row) => [
|
||||||
.filter((row) => row.defaultTenantId)
|
row.row.rowNumber,
|
||||||
.map((row) => [row.row.rowNumber, row.defaultTenantId]),
|
row.defaultTenantId || "__create__",
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setSelectedCreateSlugs(
|
||||||
|
Object.fromEntries(
|
||||||
|
preview.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setPreviewOpen(true);
|
setPreviewOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImportConfirm = () => {
|
const handleImportConfirm = () => {
|
||||||
const csv = serializeTenantImportCSV(previewRows, selectedMatches);
|
const resolutions: Record<number, TenantImportResolution> =
|
||||||
|
Object.fromEntries(
|
||||||
|
previewRows.map((preview) => {
|
||||||
|
const selected = selectedMatches[preview.row.rowNumber] ?? "";
|
||||||
|
if (selected && selected !== "__create__") {
|
||||||
|
return [
|
||||||
|
preview.row.rowNumber,
|
||||||
|
{ mode: "existing", tenantId: selected },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
preview.row.rowNumber,
|
||||||
|
{
|
||||||
|
mode: "create",
|
||||||
|
slug:
|
||||||
|
selectedCreateSlugs[preview.row.rowNumber] ||
|
||||||
|
preview.defaultCreateSlug,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const csv = serializeTenantImportCSV(previewRows, resolutions);
|
||||||
const file = new File([csv], "tenants.csv", { type: "text/csv" });
|
const file = new File([csv], "tenants.csv", { type: "text/csv" });
|
||||||
importMutation.mutate(file);
|
importMutation.mutate(file);
|
||||||
};
|
};
|
||||||
@@ -343,12 +375,21 @@ function TenantListPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => exportMutation.mutate()}
|
onClick={() => exportMutation.mutate(false)}
|
||||||
disabled={exportMutation.isPending}
|
disabled={exportMutation.isPending}
|
||||||
data-testid="tenant-export-btn"
|
data-testid="tenant-export-btn"
|
||||||
>
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
{t("ui.admin.tenants.export", "내보내기")}
|
{t("ui.admin.tenants.export_without_ids", "UUID 제외 내보내기")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => exportMutation.mutate(true)}
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
data-testid="tenant-export-with-ids-btn"
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
{t("ui.admin.tenants.export_with_ids", "UUID 포함")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -622,19 +663,41 @@ function TenantListPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell className="font-mono text-xs">
|
||||||
{preview.row.slug}
|
{preview.row.slug}
|
||||||
|
{preview.conflicts.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{preview.conflicts.map((conflict) => (
|
||||||
|
<Badge
|
||||||
|
key={conflict}
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{conflict === "external_tenant_id"
|
||||||
|
? t(
|
||||||
|
"ui.admin.tenants.import_preview.external_id",
|
||||||
|
"외부 ID",
|
||||||
|
)
|
||||||
|
: conflict === "slug_exists"
|
||||||
|
? t(
|
||||||
|
"ui.admin.tenants.import_preview.slug_exists",
|
||||||
|
"slug 충돌",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"ui.admin.tenants.import_preview.parent_unresolved",
|
||||||
|
"부모 확인 필요",
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{preview.row.tenantId ? (
|
<div className="space-y-2">
|
||||||
<Badge variant="outline">
|
|
||||||
{t(
|
|
||||||
"ui.admin.tenants.import_preview.fixed_id",
|
|
||||||
"ID 지정됨",
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<select
|
<select
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
value={selectedMatches[preview.row.rowNumber] ?? ""}
|
value={
|
||||||
|
selectedMatches[preview.row.rowNumber] ??
|
||||||
|
"__create__"
|
||||||
|
}
|
||||||
data-testid={`tenant-import-match-select-${preview.row.rowNumber}`}
|
data-testid={`tenant-import-match-select-${preview.row.rowNumber}`}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSelectedMatches((prev) => ({
|
setSelectedMatches((prev) => ({
|
||||||
@@ -643,10 +706,10 @@ function TenantListPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="__create__">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.tenants.import_preview.create_new",
|
"ui.admin.tenants.import_preview.create_new_reset",
|
||||||
"신규 생성",
|
"신규 생성 (ID/slug 재설정)",
|
||||||
)}
|
)}
|
||||||
</option>
|
</option>
|
||||||
{preview.candidates.map((candidate) => (
|
{preview.candidates.map((candidate) => (
|
||||||
@@ -658,7 +721,22 @@ function TenantListPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
{(selectedMatches[preview.row.rowNumber] ??
|
||||||
|
"__create__") === "__create__" && (
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
selectedCreateSlugs[preview.row.rowNumber] ?? ""
|
||||||
|
}
|
||||||
|
data-testid={`tenant-import-create-slug-${preview.row.rowNumber}`}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedCreateSlugs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[preview.row.rowNumber]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{preview.candidates.length > 0 ? (
|
{preview.candidates.length > 0 ? (
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import {
|
|||||||
updateTenant,
|
updateTenant,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
import { DomainTagInput } from "../components/DomainTagInput";
|
||||||
|
import {
|
||||||
|
formatDomainConflictMessage,
|
||||||
|
type ServerDomainConflict,
|
||||||
|
} from "../utils/domainTags";
|
||||||
|
|
||||||
export function TenantProfilePage() {
|
export function TenantProfilePage() {
|
||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
@@ -53,7 +58,10 @@ export function TenantProfilePage() {
|
|||||||
const [slug, setSlug] = useState("");
|
const [slug, setSlug] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState("active");
|
const [status, setStatus] = useState("active");
|
||||||
const [domains, setDomains] = useState("");
|
const [domains, setDomains] = useState<string[]>([]);
|
||||||
|
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [parentId, setParentId] = useState("");
|
const [parentId, setParentId] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,13 +71,14 @@ export function TenantProfilePage() {
|
|||||||
setSlug(tenantQuery.data.slug);
|
setSlug(tenantQuery.data.slug);
|
||||||
setDescription(tenantQuery.data.description ?? "");
|
setDescription(tenantQuery.data.description ?? "");
|
||||||
setStatus(tenantQuery.data.status);
|
setStatus(tenantQuery.data.status);
|
||||||
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
setDomains(tenantQuery.data.domains ?? []);
|
||||||
|
setForceDomainConflicts([]);
|
||||||
setParentId(tenantQuery.data.parentId ?? "");
|
setParentId(tenantQuery.data.parentId ?? "");
|
||||||
}
|
}
|
||||||
}, [tenantQuery.data]);
|
}, [tenantQuery.data]);
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (overrideForceDomains?: string[]) =>
|
||||||
updateTenant(tenantId, {
|
updateTenant(tenantId, {
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
@@ -77,17 +86,36 @@ export function TenantProfilePage() {
|
|||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
status,
|
status,
|
||||||
parentId: parentId || undefined,
|
parentId: parentId || undefined,
|
||||||
domains: domains
|
domains,
|
||||||
.split(",")
|
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter((d) => d !== ""),
|
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
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);
|
||||||
|
updateMutation.mutate(nextForceDomains);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error(
|
toast.error(
|
||||||
err.response?.data?.error ||
|
err.response?.data?.error ||
|
||||||
t("err.common.unknown", "오류가 발생했습니다."),
|
t("err.common.unknown", "오류가 발생했습니다."),
|
||||||
@@ -257,9 +285,14 @@ export function TenantProfilePage() {
|
|||||||
"허용된 도메인 (콤마로 구분)",
|
"허용된 도메인 (콤마로 구분)",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DomainTagInput
|
||||||
|
id="tenant-domains"
|
||||||
value={domains}
|
value={domains}
|
||||||
onChange={(e) => setDomains(e.target.value)}
|
onChange={setDomains}
|
||||||
|
tenants={parentQuery.data?.items ?? []}
|
||||||
|
currentTenantId={tenantId}
|
||||||
|
confirmedConflicts={forceDomainConflicts}
|
||||||
|
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||||
placeholder="example.com, example.kr"
|
placeholder="example.com, example.kr"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -322,7 +355,7 @@ export function TenantProfilePage() {
|
|||||||
{t("ui.common.cancel", "취소")}
|
{t("ui.common.cancel", "취소")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => updateMutation.mutate()}
|
onClick={() => updateMutation.mutate(undefined)}
|
||||||
disabled={
|
disabled={
|
||||||
updateMutation.isPending ||
|
updateMutation.isPending ||
|
||||||
tenantQuery.isLoading ||
|
tenantQuery.isLoading ||
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage";
|
||||||
|
|
||||||
|
describe("TenantSchemaPage schema field helpers", () => {
|
||||||
|
it("creates text fields without varchar maxLength policy", () => {
|
||||||
|
const field = createSchemaField();
|
||||||
|
|
||||||
|
expect(field.type).toBe("text");
|
||||||
|
expect("maxLength" in field).toBe(false);
|
||||||
|
expect(field.indexed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add maxLength to legacy text schema fields", () => {
|
||||||
|
const field = normalizeSchemaField({
|
||||||
|
key: "emp_id",
|
||||||
|
label: "사번",
|
||||||
|
type: "text",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect("maxLength" in field).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces indexed when a field can be used as login ID", () => {
|
||||||
|
const field = normalizeSchemaField({
|
||||||
|
key: "emp_id",
|
||||||
|
label: "사번",
|
||||||
|
type: "text",
|
||||||
|
indexed: false,
|
||||||
|
isLoginId: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(field.indexed).toBe(true);
|
||||||
|
expect(field.isLoginId).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ import { toast } from "../../../components/ui/use-toast";
|
|||||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
type SchemaFieldType =
|
export type SchemaFieldType =
|
||||||
| "text"
|
| "text"
|
||||||
| "number"
|
| "number"
|
||||||
| "boolean"
|
| "boolean"
|
||||||
@@ -25,7 +25,7 @@ type SchemaFieldType =
|
|||||||
| "float"
|
| "float"
|
||||||
| "datetime";
|
| "datetime";
|
||||||
|
|
||||||
type SchemaField = {
|
export type SchemaField = {
|
||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -35,6 +35,7 @@ type SchemaField = {
|
|||||||
validation?: string;
|
validation?: string;
|
||||||
unsigned?: boolean;
|
unsigned?: boolean;
|
||||||
isLoginId?: boolean;
|
isLoginId?: boolean;
|
||||||
|
indexed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createFieldId() {
|
function createFieldId() {
|
||||||
@@ -44,6 +45,54 @@ function createFieldId() {
|
|||||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSchemaFieldType(value: unknown): value is SchemaFieldType {
|
||||||
|
return (
|
||||||
|
value === "text" ||
|
||||||
|
value === "number" ||
|
||||||
|
value === "boolean" ||
|
||||||
|
value === "date" ||
|
||||||
|
value === "float" ||
|
||||||
|
value === "datetime"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSchemaField(field: unknown): SchemaField {
|
||||||
|
const source =
|
||||||
|
typeof field === "object" && field !== null
|
||||||
|
? (field as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const type = isSchemaFieldType(source.type) ? source.type : "text";
|
||||||
|
const isLoginId = Boolean(source.isLoginId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof source.id === "string" ? source.id : createFieldId(),
|
||||||
|
key: typeof source.key === "string" ? source.key : "",
|
||||||
|
label: typeof source.label === "string" ? source.label : "",
|
||||||
|
type,
|
||||||
|
required: Boolean(source.required),
|
||||||
|
adminOnly: Boolean(source.adminOnly),
|
||||||
|
validation:
|
||||||
|
typeof source.validation === "string" ? source.validation : "",
|
||||||
|
unsigned: Boolean(source.unsigned),
|
||||||
|
isLoginId,
|
||||||
|
indexed: isLoginId || Boolean(source.indexed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSchemaField(): SchemaField {
|
||||||
|
return {
|
||||||
|
id: createFieldId(),
|
||||||
|
key: "",
|
||||||
|
label: "",
|
||||||
|
type: "text",
|
||||||
|
required: false,
|
||||||
|
adminOnly: false,
|
||||||
|
validation: "",
|
||||||
|
unsigned: false,
|
||||||
|
indexed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function TenantSchemaPage() {
|
export function TenantSchemaPage() {
|
||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -71,27 +120,7 @@ export function TenantSchemaPage() {
|
|||||||
const rawSchema = tenantQuery.data?.config?.userSchema;
|
const rawSchema = tenantQuery.data?.config?.userSchema;
|
||||||
|
|
||||||
if (Array.isArray(rawSchema)) {
|
if (Array.isArray(rawSchema)) {
|
||||||
setFields(
|
setFields(rawSchema.map(normalizeSchemaField));
|
||||||
rawSchema.map((field) => ({
|
|
||||||
id: typeof field?.id === "string" ? field.id : createFieldId(),
|
|
||||||
key: typeof field?.key === "string" ? field.key : "",
|
|
||||||
label: typeof field?.label === "string" ? field.label : "",
|
|
||||||
type:
|
|
||||||
field?.type === "number" ||
|
|
||||||
field?.type === "boolean" ||
|
|
||||||
field?.type === "date" ||
|
|
||||||
field?.type === "float" ||
|
|
||||||
field?.type === "datetime"
|
|
||||||
? field.type
|
|
||||||
: "text",
|
|
||||||
required: Boolean(field?.required),
|
|
||||||
adminOnly: Boolean(field?.adminOnly),
|
|
||||||
validation:
|
|
||||||
typeof field?.validation === "string" ? field.validation : "",
|
|
||||||
unsigned: Boolean(field?.unsigned),
|
|
||||||
isLoginId: Boolean(field?.isLoginId),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [tenantQuery.data]);
|
}, [tenantQuery.data]);
|
||||||
|
|
||||||
@@ -158,19 +187,7 @@ export function TenantSchemaPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addField = () => {
|
const addField = () => {
|
||||||
setFields([
|
setFields([...fields, createSchemaField()]);
|
||||||
...fields,
|
|
||||||
{
|
|
||||||
id: createFieldId(),
|
|
||||||
key: "",
|
|
||||||
label: "",
|
|
||||||
type: "text",
|
|
||||||
required: false,
|
|
||||||
adminOnly: false,
|
|
||||||
validation: "",
|
|
||||||
unsigned: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeField = (index: number) => {
|
const removeField = (index: number) => {
|
||||||
@@ -261,16 +278,15 @@ export function TenantSchemaPage() {
|
|||||||
value={field.type}
|
value={field.type}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const nextType = e.target.value;
|
const nextType = e.target.value;
|
||||||
if (
|
if (isSchemaFieldType(nextType)) {
|
||||||
nextType === "text" ||
|
|
||||||
nextType === "number" ||
|
|
||||||
nextType === "boolean" ||
|
|
||||||
nextType === "date" ||
|
|
||||||
nextType === "float" ||
|
|
||||||
nextType === "datetime"
|
|
||||||
) {
|
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
type: nextType as SchemaFieldType,
|
type: nextType,
|
||||||
|
isLoginId:
|
||||||
|
nextType === "text" ? field.isLoginId : false,
|
||||||
|
indexed:
|
||||||
|
nextType === "text"
|
||||||
|
? field.indexed || field.isLoginId || false
|
||||||
|
: field.indexed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -351,7 +367,11 @@ export function TenantSchemaPage() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.isLoginId || false}
|
checked={field.isLoginId || false}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, { isLoginId: e.target.checked })
|
updateField(index, {
|
||||||
|
isLoginId: e.target.checked,
|
||||||
|
indexed: e.target.checked ? true : field.indexed,
|
||||||
|
type: e.target.checked ? "text" : field.type,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
@@ -362,6 +382,23 @@ export function TenantSchemaPage() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.indexed || field.isLoginId || false}
|
||||||
|
disabled={field.isLoginId}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(index, { indexed: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.schema.field.indexed",
|
||||||
|
"검색 인덱스 필요",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
{(field.type === "number" || field.type === "float") && (
|
{(field.type === "number" || field.type === "float") && (
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|||||||
67
adminfront/src/features/tenants/utils/domainTags.test.ts
Normal file
67
adminfront/src/features/tenants/utils/domainTags.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
findDomainConflict,
|
||||||
|
formatDomainConflictMessage,
|
||||||
|
normalizeDomainTokens,
|
||||||
|
} from "./domainTags";
|
||||||
|
|
||||||
|
describe("domainTags", () => {
|
||||||
|
it("splits domains by comma and whitespace", () => {
|
||||||
|
expect(
|
||||||
|
normalizeDomainTokens("samaneng.com, hanmaceng.co.kr login.hmac.kr"),
|
||||||
|
).toEqual(["samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a domain already assigned to another tenant", () => {
|
||||||
|
const conflict = findDomainConflict("hanmaceng.co.kr", [
|
||||||
|
{
|
||||||
|
id: "tenant-1",
|
||||||
|
name: "한맥기술",
|
||||||
|
slug: "hanmac",
|
||||||
|
type: "COMPANY",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
domains: ["hanmaceng.co.kr"],
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(conflict?.tenant.name).toBe("한맥기술");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the current tenant when checking domain conflicts", () => {
|
||||||
|
const conflict = findDomainConflict(
|
||||||
|
"hanmaceng.co.kr",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "tenant-1",
|
||||||
|
name: "한맥기술",
|
||||||
|
slug: "hanmac",
|
||||||
|
type: "COMPANY",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
domains: ["hanmaceng.co.kr"],
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"tenant-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(conflict).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats a duplicate domain message with tenant context", () => {
|
||||||
|
expect(
|
||||||
|
formatDomainConflictMessage({
|
||||||
|
domain: "samaneng.com",
|
||||||
|
tenantName: "한맥가족",
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
adminfront/src/features/tenants/utils/domainTags.ts
Normal file
59
adminfront/src/features/tenants/utils/domainTags.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { TenantSummary } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
export type DomainConflict = {
|
||||||
|
domain: string;
|
||||||
|
tenant: TenantSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServerDomainConflict = {
|
||||||
|
domain: string;
|
||||||
|
tenantId?: string;
|
||||||
|
tenantName?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeDomainTokens(value: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const tokens: string[] = [];
|
||||||
|
for (const raw of value.split(/[,\s;]+/)) {
|
||||||
|
const token = raw.trim().toLowerCase();
|
||||||
|
if (!token || seen.has(token)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(token);
|
||||||
|
tokens.push(token);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDomainConflict(
|
||||||
|
domain: string,
|
||||||
|
tenants: TenantSummary[] = [],
|
||||||
|
currentTenantId?: string,
|
||||||
|
): DomainConflict | null {
|
||||||
|
const normalized = domain.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
if (tenant.id === currentTenantId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const domains = tenant.domains ?? [];
|
||||||
|
if (domains.some((item) => item.trim().toLowerCase() === normalized)) {
|
||||||
|
return { domain: normalized, tenant };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDomainConflictMessage(
|
||||||
|
conflict: DomainConflict | ServerDomainConflict,
|
||||||
|
): string {
|
||||||
|
const tenantName =
|
||||||
|
"tenant" in conflict
|
||||||
|
? conflict.tenant.name
|
||||||
|
: conflict.tenantName || conflict.tenantSlug || conflict.tenantId || "다른";
|
||||||
|
return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ describe("tenantCsvImport", () => {
|
|||||||
name: "Hanmac Tech",
|
name: "Hanmac Tech",
|
||||||
type: "COMPANY",
|
type: "COMPANY",
|
||||||
parentTenantId: "",
|
parentTenantId: "",
|
||||||
|
parentTenantSlug: "",
|
||||||
slug: "hanmac-tech",
|
slug: "hanmac-tech",
|
||||||
memo: "Memo",
|
memo: "Memo",
|
||||||
emailDomain: "hanmac-tech.example.com",
|
emailDomain: "hanmac-tech.example.com",
|
||||||
@@ -89,4 +90,88 @@ describe("tenantCsvImport", () => {
|
|||||||
"tenant-1,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com",
|
"tenant-1,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serializes create resolutions by resetting external tenant id and conflicting slug", () => {
|
||||||
|
const rows = parseTenantCSV(
|
||||||
|
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\nlocal-tenant-id,Hanmac Technology,COMPANY,,hanmac,Memo,hanmac.example.com\n",
|
||||||
|
);
|
||||||
|
const preview = buildTenantImportPreview(rows, tenants);
|
||||||
|
|
||||||
|
expect(preview[0].conflicts).toEqual(
|
||||||
|
expect.arrayContaining(["external_tenant_id", "slug_exists"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const csv = serializeTenantImportCSV(preview, {
|
||||||
|
2: {
|
||||||
|
mode: "create",
|
||||||
|
tenantId: "staging-new-tenant-id",
|
||||||
|
slug: "hanmac-imported",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(csv).toContain(
|
||||||
|
"staging-new-tenant-id,Hanmac Technology,COMPANY,,hanmac-imported,Memo,hanmac.example.com",
|
||||||
|
);
|
||||||
|
expect(csv).not.toContain("local-tenant-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remaps child parent_tenant_id from source ids to selected staging ids", () => {
|
||||||
|
const rows = parseTenantCSV(
|
||||||
|
[
|
||||||
|
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain",
|
||||||
|
"local-parent-id,Parent Tenant,COMPANY,,parent-local,,",
|
||||||
|
"local-child-id,Child Tenant,USER_GROUP,local-parent-id,child-local,,",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
const preview = buildTenantImportPreview(rows, tenants);
|
||||||
|
const csv = serializeTenantImportCSV(preview, {
|
||||||
|
2: {
|
||||||
|
mode: "create",
|
||||||
|
tenantId: "staging-parent-id",
|
||||||
|
slug: "parent-staging",
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
mode: "create",
|
||||||
|
tenantId: "staging-child-id",
|
||||||
|
slug: "child-staging",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(csv).toContain(
|
||||||
|
"staging-parent-id,Parent Tenant,COMPANY,,parent-staging,,",
|
||||||
|
);
|
||||||
|
expect(csv).toContain(
|
||||||
|
"staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-staging,,",
|
||||||
|
);
|
||||||
|
expect(csv).not.toContain("local-parent-id");
|
||||||
|
expect(csv).not.toContain("local-child-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses parent_tenant_slug and remaps it to selected staging ids", () => {
|
||||||
|
const rows = parseTenantCSV(
|
||||||
|
[
|
||||||
|
"name,type,parent_tenant_slug,slug,memo,email_domain",
|
||||||
|
"Parent Tenant,COMPANY,,parent-slug,,",
|
||||||
|
"Child Tenant,USER_GROUP,parent-slug,child-slug,,",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
const preview = buildTenantImportPreview(rows, tenants);
|
||||||
|
const csv = serializeTenantImportCSV(preview, {
|
||||||
|
2: {
|
||||||
|
mode: "create",
|
||||||
|
tenantId: "staging-parent-id",
|
||||||
|
slug: "parent-slug",
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
mode: "create",
|
||||||
|
tenantId: "staging-child-id",
|
||||||
|
slug: "child-slug",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rows[1].parentTenantSlug).toBe("parent-slug");
|
||||||
|
expect(csv).toContain(
|
||||||
|
"staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-slug,,",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type TenantCSVRow = {
|
|||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
parentTenantId: string;
|
parentTenantId: string;
|
||||||
|
parentTenantSlug: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
memo: string;
|
memo: string;
|
||||||
emailDomain: string;
|
emailDomain: string;
|
||||||
@@ -23,8 +24,30 @@ export type TenantImportPreviewRow = {
|
|||||||
row: TenantCSVRow;
|
row: TenantCSVRow;
|
||||||
candidates: TenantImportCandidate[];
|
candidates: TenantImportCandidate[];
|
||||||
defaultTenantId: string;
|
defaultTenantId: string;
|
||||||
|
defaultCreateSlug: string;
|
||||||
|
conflicts: TenantImportConflict[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TenantImportConflict =
|
||||||
|
| "external_tenant_id"
|
||||||
|
| "slug_exists"
|
||||||
|
| "parent_tenant_id_unresolved";
|
||||||
|
|
||||||
|
export type TenantImportResolution =
|
||||||
|
| {
|
||||||
|
mode: "existing";
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: "create";
|
||||||
|
tenantId?: string;
|
||||||
|
slug?: string;
|
||||||
|
parentTenantId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: "skip";
|
||||||
|
};
|
||||||
|
|
||||||
const importHeaders = [
|
const importHeaders = [
|
||||||
"tenant_id",
|
"tenant_id",
|
||||||
"name",
|
"name",
|
||||||
@@ -45,6 +68,8 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
|
|||||||
parent_id: "parentTenantId",
|
parent_id: "parentTenantId",
|
||||||
parenttenantid: "parentTenantId",
|
parenttenantid: "parentTenantId",
|
||||||
parent_tenant_id: "parentTenantId",
|
parent_tenant_id: "parentTenantId",
|
||||||
|
parenttenantslug: "parentTenantSlug",
|
||||||
|
parent_tenant_slug: "parentTenantSlug",
|
||||||
slug: "slug",
|
slug: "slug",
|
||||||
memo: "memo",
|
memo: "memo",
|
||||||
description: "memo",
|
description: "memo",
|
||||||
@@ -80,6 +105,7 @@ export function parseTenantCSV(text: string): TenantCSVRow[] {
|
|||||||
name: value("name"),
|
name: value("name"),
|
||||||
type: value("type"),
|
type: value("type"),
|
||||||
parentTenantId: value("parentTenantId"),
|
parentTenantId: value("parentTenantId"),
|
||||||
|
parentTenantSlug: value("parentTenantSlug"),
|
||||||
slug: value("slug"),
|
slug: value("slug"),
|
||||||
memo: value("memo"),
|
memo: value("memo"),
|
||||||
emailDomain: value("emailDomain"),
|
emailDomain: value("emailDomain"),
|
||||||
@@ -93,14 +119,17 @@ export function buildTenantImportPreview(
|
|||||||
): TenantImportPreviewRow[] {
|
): TenantImportPreviewRow[] {
|
||||||
return rows
|
return rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const candidates = row.tenantId ? [] : findTenantCandidates(row, tenants);
|
const candidates = findTenantCandidates(row, tenants);
|
||||||
|
const conflicts = findTenantImportConflicts(row, tenants);
|
||||||
return {
|
return {
|
||||||
row,
|
row,
|
||||||
candidates,
|
candidates,
|
||||||
|
conflicts,
|
||||||
defaultTenantId:
|
defaultTenantId:
|
||||||
candidates[0] && candidates[0].score >= 0.95
|
candidates[0] && candidates[0].score >= 0.95
|
||||||
? candidates[0].tenantId
|
? candidates[0].tenantId
|
||||||
: "",
|
: "",
|
||||||
|
defaultCreateSlug: suggestUniqueTenantSlug(row.slug || row.name, tenants),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -113,24 +142,148 @@ export function buildTenantImportPreview(
|
|||||||
|
|
||||||
export function serializeTenantImportCSV(
|
export function serializeTenantImportCSV(
|
||||||
previewRows: TenantImportPreviewRow[],
|
previewRows: TenantImportPreviewRow[],
|
||||||
selectedTenantIds: Record<number, string>,
|
selectedTenantIds: Record<number, string | TenantImportResolution>,
|
||||||
) {
|
) {
|
||||||
const lines = [importHeaders];
|
const lines = [importHeaders];
|
||||||
for (const preview of [...previewRows].sort(
|
const sortedRows = [...previewRows].sort(
|
||||||
(a, b) => a.row.rowNumber - b.row.rowNumber,
|
(a, b) => a.row.rowNumber - b.row.rowNumber,
|
||||||
)) {
|
);
|
||||||
const selectedTenantId = selectedTenantIds[preview.row.rowNumber] ?? "";
|
const targetTenantIds = buildTargetTenantIds(
|
||||||
|
sortedRows,
|
||||||
|
selectedTenantIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const preview of sortedRows) {
|
||||||
|
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
||||||
|
if (typeof resolution === "object" && resolution.mode === "skip") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTenantId =
|
||||||
|
typeof resolution === "string"
|
||||||
|
? resolution
|
||||||
|
: resolution.mode === "existing"
|
||||||
|
? resolution.tenantId
|
||||||
|
: "";
|
||||||
|
const slug =
|
||||||
|
typeof resolution === "object" && resolution.mode === "create"
|
||||||
|
? resolution.slug || preview.defaultCreateSlug
|
||||||
|
: preview.row.slug;
|
||||||
|
const parentTenantId =
|
||||||
|
typeof resolution === "object" && resolution.mode === "create"
|
||||||
|
? (resolution.parentTenantId ??
|
||||||
|
remapParentTenantId(
|
||||||
|
preview.row.parentTenantId,
|
||||||
|
preview.row.parentTenantSlug,
|
||||||
|
targetTenantIds,
|
||||||
|
))
|
||||||
|
: preview.row.parentTenantId;
|
||||||
|
const tenantId =
|
||||||
|
typeof resolution === "object" && resolution.mode === "create"
|
||||||
|
? (resolution.tenantId ??
|
||||||
|
targetTenantIds.bySourceId.get(preview.row.tenantId) ??
|
||||||
|
createTenantImportId())
|
||||||
|
: selectedTenantId || preview.row.tenantId;
|
||||||
|
|
||||||
lines.push([
|
lines.push([
|
||||||
preview.row.tenantId || selectedTenantId,
|
tenantId,
|
||||||
preview.row.name,
|
preview.row.name,
|
||||||
preview.row.type,
|
preview.row.type,
|
||||||
preview.row.parentTenantId,
|
parentTenantId,
|
||||||
preview.row.slug,
|
slug,
|
||||||
preview.row.memo,
|
preview.row.memo,
|
||||||
preview.row.emailDomain,
|
preview.row.emailDomain,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return lines.map(formatCSVRecord).join("\n") + "\n";
|
return `${lines.map(formatCSVRecord).join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTargetTenantIds(
|
||||||
|
previewRows: TenantImportPreviewRow[],
|
||||||
|
selectedTenantIds: Record<number, string | TenantImportResolution>,
|
||||||
|
) {
|
||||||
|
const bySourceId = new Map<string, string>();
|
||||||
|
const bySourceSlug = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const preview of previewRows) {
|
||||||
|
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
||||||
|
if (typeof resolution === "object" && resolution.mode === "skip") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTenantId =
|
||||||
|
typeof resolution === "string"
|
||||||
|
? resolution || preview.row.tenantId
|
||||||
|
: resolution.mode === "existing"
|
||||||
|
? resolution.tenantId
|
||||||
|
: resolution.tenantId || createTenantImportId();
|
||||||
|
|
||||||
|
if (preview.row.tenantId) {
|
||||||
|
bySourceId.set(preview.row.tenantId, targetTenantId);
|
||||||
|
}
|
||||||
|
if (preview.row.slug) {
|
||||||
|
bySourceSlug.set(preview.row.slug.toLowerCase(), targetTenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bySourceId, bySourceSlug };
|
||||||
|
}
|
||||||
|
|
||||||
|
function remapParentTenantId(
|
||||||
|
parentTenantId: string,
|
||||||
|
parentTenantSlug: string,
|
||||||
|
targetTenantIds: {
|
||||||
|
bySourceId: Map<string, string>;
|
||||||
|
bySourceSlug: Map<string, string>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (parentTenantId) {
|
||||||
|
return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId;
|
||||||
|
}
|
||||||
|
if (parentTenantSlug) {
|
||||||
|
return targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTenantImportId() {
|
||||||
|
if (globalThis.crypto?.randomUUID) {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `00000000-0000-4000-8000-${Math.random()
|
||||||
|
.toString(16)
|
||||||
|
.slice(2, 14)
|
||||||
|
.padEnd(12, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTenantImportConflicts(
|
||||||
|
row: TenantCSVRow,
|
||||||
|
tenants: TenantSummary[],
|
||||||
|
): TenantImportConflict[] {
|
||||||
|
const conflicts: TenantImportConflict[] = [];
|
||||||
|
const matchingId = row.tenantId
|
||||||
|
? tenants.find((tenant) => tenant.id === row.tenantId)
|
||||||
|
: undefined;
|
||||||
|
const matchingSlug = row.slug
|
||||||
|
? tenants.find(
|
||||||
|
(tenant) => normalizeToken(tenant.slug) === normalizeToken(row.slug),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (row.tenantId && !matchingId) {
|
||||||
|
conflicts.push("external_tenant_id");
|
||||||
|
}
|
||||||
|
if (matchingSlug && matchingSlug.id !== row.tenantId) {
|
||||||
|
conflicts.push("slug_exists");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
row.parentTenantId &&
|
||||||
|
!tenants.some((tenant) => tenant.id === row.parentTenantId)
|
||||||
|
) {
|
||||||
|
conflicts.push("parent_tenant_id_unresolved");
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findTenantCandidates(
|
function findTenantCandidates(
|
||||||
@@ -230,6 +383,28 @@ function normalizeToken(value: string) {
|
|||||||
.replace(/[^\p{L}\p{N}]/gu, "");
|
.replace(/[^\p{L}\p{N}]/gu, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function suggestUniqueTenantSlug(value: string, tenants: TenantSummary[]) {
|
||||||
|
const base = slugify(value) || "tenant";
|
||||||
|
const used = new Set(tenants.map((tenant) => tenant.slug.toLowerCase()));
|
||||||
|
if (!used.has(base)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 2;
|
||||||
|
while (used.has(`${base}-${index}`)) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return `${base}-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
function similarity(left: string, right: string) {
|
function similarity(left: string, right: string) {
|
||||||
const a = normalizeToken(left);
|
const a = normalizeToken(left);
|
||||||
const b = normalizeToken(right);
|
const b = normalizeToken(right);
|
||||||
|
|||||||
@@ -54,16 +54,7 @@ import {
|
|||||||
filterNonHanmacFamilyTenants,
|
filterNonHanmacFamilyTenants,
|
||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
|
import type { UserSchemaField } from "./userSchemaFields";
|
||||||
type UserSchemaField = {
|
|
||||||
key: string;
|
|
||||||
label?: string;
|
|
||||||
type?: "text" | "number" | "boolean" | "date";
|
|
||||||
required?: boolean;
|
|
||||||
adminOnly?: boolean;
|
|
||||||
validation?: string;
|
|
||||||
isLoginId?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
||||||
type UserType = "hanmac" | "external" | "personal";
|
type UserType = "hanmac" | "external" | "personal";
|
||||||
|
|||||||
@@ -77,16 +77,7 @@ import {
|
|||||||
isHanmacFamilyUser,
|
isHanmacFamilyUser,
|
||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
|
import type { UserSchemaField } from "./userSchemaFields";
|
||||||
type UserSchemaField = {
|
|
||||||
key: string;
|
|
||||||
label?: string;
|
|
||||||
type?: "text" | "number" | "boolean" | "date";
|
|
||||||
required?: boolean;
|
|
||||||
adminOnly?: boolean;
|
|
||||||
validation?: string;
|
|
||||||
isLoginId?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||||
|
|||||||
@@ -145,7 +145,8 @@ function UserListPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const exportMutation = useMutation({
|
const exportMutation = useMutation({
|
||||||
mutationFn: () => exportUsersCSV(search, selectedCompany),
|
mutationFn: (includeIds: boolean) =>
|
||||||
|
exportUsersCSV(search, selectedCompany, includeIds),
|
||||||
onSuccess: ({ blob, filename }) => {
|
onSuccess: ({ blob, filename }) => {
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
@@ -190,8 +191,8 @@ function UserListPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = (includeIds = false) => {
|
||||||
exportMutation.mutate();
|
exportMutation.mutate(includeIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
@@ -311,12 +312,21 @@ function UserListPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleExport}
|
onClick={() => handleExport(false)}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
disabled={exportMutation.isPending}
|
disabled={exportMutation.isPending}
|
||||||
>
|
>
|
||||||
<FileDown size={16} />
|
<FileDown size={16} />
|
||||||
{t("ui.common.export", "내보내기")}
|
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleExport(true)}
|
||||||
|
className="gap-2"
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
>
|
||||||
|
<FileDown size={16} />
|
||||||
|
{t("ui.common.export_with_ids", "UUID 포함")}
|
||||||
</Button>
|
</Button>
|
||||||
<UserBulkUploadModal onSuccess={() => query.refetch()} />
|
<UserBulkUploadModal onSuccess={() => query.refetch()} />
|
||||||
<Dialog>
|
<Dialog>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -23,22 +23,129 @@ import {
|
|||||||
type BulkUserItem,
|
type BulkUserItem,
|
||||||
type BulkUserResult,
|
type BulkUserResult,
|
||||||
bulkCreateUsers,
|
bulkCreateUsers,
|
||||||
|
createTenant,
|
||||||
|
fetchTenants,
|
||||||
|
fetchUsers,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
import {
|
||||||
|
type TenantCSVRow,
|
||||||
|
type TenantImportPreviewRow,
|
||||||
|
buildTenantImportPreview,
|
||||||
|
} from "../../tenants/utils/tenantCsvImport";
|
||||||
import { parseUserCSV } from "../utils/csvParser";
|
import { parseUserCSV } from "../utils/csvParser";
|
||||||
|
import {
|
||||||
|
type HanmacImportEmailPreview,
|
||||||
|
buildHanmacImportEmailPreview,
|
||||||
|
} from "../utils/hanmacImportEmail";
|
||||||
|
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
|
||||||
|
|
||||||
interface UserBulkUploadModalProps {
|
interface UserBulkUploadModalProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildUserTenantPreviewRows(
|
||||||
|
users: BulkUserItem[],
|
||||||
|
tenants: Parameters<typeof buildTenantImportPreview>[1],
|
||||||
|
) {
|
||||||
|
const rowsByKey = new Map<string, TenantCSVRow>();
|
||||||
|
|
||||||
|
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) {
|
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [file, setFile] = React.useState<File | null>(null);
|
const [file, setFile] = React.useState<File | null>(null);
|
||||||
const [parsing, setParsing] = React.useState(false);
|
const [parsing, setParsing] = React.useState(false);
|
||||||
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
|
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
|
||||||
|
const [tenantPreviewRows, setTenantPreviewRows] = React.useState<
|
||||||
|
TenantImportPreviewRow[]
|
||||||
|
>([]);
|
||||||
|
const [selectedTenantMatches, setSelectedTenantMatches] = React.useState<
|
||||||
|
Record<number, string>
|
||||||
|
>({});
|
||||||
|
const [selectedTenantCreateSlugs, setSelectedTenantCreateSlugs] =
|
||||||
|
React.useState<Record<number, string>>({});
|
||||||
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
|
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
|
||||||
|
const [preparing, setPreparing] = React.useState(false);
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
const fileInputRef = React.useRef<HTMLInputElement>(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({
|
const mutation = useMutation({
|
||||||
mutationFn: bulkCreateUsers,
|
mutationFn: bulkCreateUsers,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -62,20 +169,87 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
const text = e.target?.result as string;
|
const text = e.target?.result as string;
|
||||||
const data = parseUserCSV(text);
|
const data = parseUserCSV(text);
|
||||||
setPreviewData(data);
|
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);
|
setParsing(false);
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = () => {
|
const handleUpload = async () => {
|
||||||
if (previewData.length > 0) {
|
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<string, string>();
|
||||||
|
|
||||||
|
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 downloadTemplate = () => {
|
||||||
const headers =
|
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 =
|
const example =
|
||||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
|
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
|
||||||
const blob = new Blob([`${headers}\n${example}`], {
|
const blob = new Blob([`${headers}\n${example}`], {
|
||||||
@@ -92,12 +266,47 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
const reset = () => {
|
const reset = () => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setPreviewData([]);
|
setPreviewData([]);
|
||||||
|
setTenantPreviewRows([]);
|
||||||
|
setSelectedTenantMatches({});
|
||||||
|
setSelectedTenantCreateSlugs({});
|
||||||
setResults(null);
|
setResults(null);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const successCount = results?.filter((r) => r.success).length ?? 0;
|
const successCount = results?.filter((r) => r.success).length ?? 0;
|
||||||
const failCount = results ? results.length - successCount : 0;
|
const failCount = results ? results.length - successCount : 0;
|
||||||
|
const tenants = tenantQuery.data?.items ?? [];
|
||||||
|
const existingHanmacLocalParts = React.useMemo(() => {
|
||||||
|
const values = new Set<string>();
|
||||||
|
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<string>();
|
||||||
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -185,6 +394,82 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tenantPreviewRows.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-md border p-3 text-sm"
|
||||||
|
data-testid="user-import-tenant-resolution"
|
||||||
|
>
|
||||||
|
<div className="mb-2 font-medium">
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.bulk.tenant_resolution",
|
||||||
|
"테넌트 매핑",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tenantPreviewRows.map((preview) => (
|
||||||
|
<div
|
||||||
|
key={preview.row.rowNumber}
|
||||||
|
className="grid gap-2 sm:grid-cols-[1fr_1fr]"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{preview.row.name}</div>
|
||||||
|
<div className="font-mono text-xs text-muted-foreground">
|
||||||
|
{preview.row.slug}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={
|
||||||
|
selectedTenantMatches[preview.row.rowNumber] ??
|
||||||
|
"__create__"
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedTenantMatches((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[preview.row.rowNumber]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="__create__">
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.bulk.create_missing_tenant",
|
||||||
|
"신규 생성",
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
{preview.candidates.map((candidate) => (
|
||||||
|
<option
|
||||||
|
key={candidate.tenantId}
|
||||||
|
value={candidate.tenantId}
|
||||||
|
>
|
||||||
|
{candidate.name} ({candidate.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(selectedTenantMatches[preview.row.rowNumber] ??
|
||||||
|
"__create__") === "__create__" && (
|
||||||
|
<input
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
|
||||||
|
value={
|
||||||
|
selectedTenantCreateSlugs[
|
||||||
|
preview.row.rowNumber
|
||||||
|
] ?? ""
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedTenantCreateSlugs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[preview.row.rowNumber]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{previewData.length > 0 && (
|
{previewData.length > 0 && (
|
||||||
<ScrollArea className="h-[200px] rounded-md border">
|
<ScrollArea className="h-[200px] rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -193,20 +478,45 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
<th className="p-2 text-left">Email</th>
|
<th className="p-2 text-left">Email</th>
|
||||||
<th className="p-2 text-left">Name</th>
|
<th className="p-2 text-left">Name</th>
|
||||||
<th className="p-2 text-left">Tenant</th>
|
<th className="p-2 text-left">Tenant</th>
|
||||||
|
<th className="p-2 text-left">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{previewData.slice(0, 10).map((u) => (
|
{previewData.slice(0, 10).map((u, index) => (
|
||||||
<tr key={u.email} className="border-t">
|
<tr key={`${u.email}-${index}`} className="border-t">
|
||||||
<td className="p-2">{u.email}</td>
|
<td className="p-2">
|
||||||
|
<input
|
||||||
|
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 ?? u.email}
|
||||||
|
onChange={(event) =>
|
||||||
|
setPreviewData((prev) =>
|
||||||
|
prev.map((item, itemIndex) =>
|
||||||
|
itemIndex === index
|
||||||
|
? { ...item, email: event.target.value }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="p-2">{u.name}</td>
|
<td className="p-2">{u.name}</td>
|
||||||
<td className="p-2">{u.tenantSlug || "-"}</td>
|
<td className="p-2">{u.tenantSlug || "-"}</td>
|
||||||
|
<td
|
||||||
|
className={`p-2 text-xs ${hanmacEmailStatusClass(
|
||||||
|
hanmacEmailPreviews[index],
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
|
||||||
|
{hanmacEmailPreviews[index]?.reason && (
|
||||||
|
<div>{hanmacEmailPreviews[index]?.reason}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{previewData.length > 10 && (
|
{previewData.length > 10 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={3}
|
colSpan={4}
|
||||||
className="p-2 text-center text-muted-foreground italic"
|
className="p-2 text-center text-muted-foreground italic"
|
||||||
>
|
>
|
||||||
... and {previewData.length - 10} more users
|
... and {previewData.length - 10} more users
|
||||||
@@ -277,11 +587,16 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
{!results ? (
|
{!results ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={previewData.length === 0 || mutation.isPending}
|
disabled={
|
||||||
|
previewData.length === 0 ||
|
||||||
|
mutation.isPending ||
|
||||||
|
preparing ||
|
||||||
|
hasBlockingHanmacEmailRows
|
||||||
|
}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
data-testid="bulk-start-btn"
|
data-testid="bulk-start-btn"
|
||||||
>
|
>
|
||||||
{mutation.isPending && (
|
{(mutation.isPending || preparing) && (
|
||||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
|
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
|
||||||
|
|||||||
18
adminfront/src/features/users/userSchemaFields.ts
Normal file
18
adminfront/src/features/users/userSchemaFields.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export type UserSchemaFieldType =
|
||||||
|
| "text"
|
||||||
|
| "number"
|
||||||
|
| "boolean"
|
||||||
|
| "date"
|
||||||
|
| "float"
|
||||||
|
| "datetime";
|
||||||
|
|
||||||
|
export type UserSchemaField = {
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
type?: UserSchemaFieldType;
|
||||||
|
required?: boolean;
|
||||||
|
adminOnly?: boolean;
|
||||||
|
validation?: string;
|
||||||
|
isLoginId?: boolean;
|
||||||
|
indexed?: boolean;
|
||||||
|
};
|
||||||
@@ -43,4 +43,24 @@ test@test.com,Test,baron`;
|
|||||||
expect(result[0].email).toBe("test@test.com");
|
expect(result[0].email).toBe("test@test.com");
|
||||||
expect(result[0].tenantSlug).toBe("baron");
|
expect(result[0].tenantSlug).toBe("baron");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should parse tenant conflict metadata for import resolution", () => {
|
||||||
|
const csv = `email,name,tenant_id,tenant_slug,tenant_name,tenant_type,parent_tenant_slug,tenant_memo,email_domain
|
||||||
|
test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-slug,Imported memo,missing.example.com`;
|
||||||
|
|
||||||
|
const result = parseUserCSV(csv);
|
||||||
|
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
tenantSlug: "missing-slug",
|
||||||
|
tenantImport: {
|
||||||
|
sourceTenantId: "local-tenant-id",
|
||||||
|
slug: "missing-slug",
|
||||||
|
name: "Missing Tenant",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentTenantSlug: "parent-slug",
|
||||||
|
memo: "Imported memo",
|
||||||
|
emailDomain: "missing.example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,52 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
|||||||
item.role = value;
|
item.role = value;
|
||||||
} else if (header === "tenant") {
|
} else if (header === "tenant") {
|
||||||
item.tenantSlug = value;
|
item.tenantSlug = value;
|
||||||
|
} else if (header === "tenant_slug" || header === "companycode") {
|
||||||
|
item.tenantSlug = value;
|
||||||
|
item.tenantImport = {
|
||||||
|
...(item.tenantImport ?? {}),
|
||||||
|
slug: value,
|
||||||
|
};
|
||||||
|
} else if (header === "tenant_id") {
|
||||||
|
item.tenantImport = {
|
||||||
|
...(item.tenantImport ?? {}),
|
||||||
|
sourceTenantId: value,
|
||||||
|
};
|
||||||
|
} else if (header === "tenant_name") {
|
||||||
|
item.tenantImport = {
|
||||||
|
...(item.tenantImport ?? {}),
|
||||||
|
name: value,
|
||||||
|
};
|
||||||
|
} else if (header === "tenant_type") {
|
||||||
|
item.tenantImport = {
|
||||||
|
...(item.tenantImport ?? {}),
|
||||||
|
type: value,
|
||||||
|
};
|
||||||
|
} else if (header === "parent_tenant_id") {
|
||||||
|
item.tenantImport = {
|
||||||
|
...(item.tenantImport ?? {}),
|
||||||
|
parentTenantId: value,
|
||||||
|
};
|
||||||
|
} else if (header === "parent_tenant_slug") {
|
||||||
|
item.tenantImport = {
|
||||||
|
...(item.tenantImport ?? {}),
|
||||||
|
parentTenantSlug: value,
|
||||||
|
};
|
||||||
|
} else if (header === "parent_tenant_name") {
|
||||||
|
item.tenantImport = {
|
||||||
|
...(item.tenantImport ?? {}),
|
||||||
|
parentTenantName: value,
|
||||||
|
};
|
||||||
|
} else if (header === "tenant_memo") {
|
||||||
|
item.tenantImport = {
|
||||||
|
...(item.tenantImport ?? {}),
|
||||||
|
memo: value,
|
||||||
|
};
|
||||||
|
} else if (header === "email_domain" || header === "tenant_domain") {
|
||||||
|
item.tenantImport = {
|
||||||
|
...(item.tenantImport ?? {}),
|
||||||
|
emailDomain: value,
|
||||||
|
};
|
||||||
} else if (header === "department") {
|
} else if (header === "department") {
|
||||||
item.department = value;
|
item.department = value;
|
||||||
} else if (header === "position") {
|
} else if (header === "position") {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildHanmacImportEmailPreview,
|
||||||
|
buildKoreanNameEmailBase,
|
||||||
|
matchesSuggestedNameRule,
|
||||||
|
} from "./hanmacImportEmail";
|
||||||
|
|
||||||
|
describe("hanmac import email policy", () => {
|
||||||
|
it("builds name initials plus surname base", () => {
|
||||||
|
expect(buildKoreanNameEmailBase("한치영")).toEqual({
|
||||||
|
base: "cyhan",
|
||||||
|
needsReview: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches base plus numeric suffix only", () => {
|
||||||
|
expect(matchesSuggestedNameRule("cyhan", "cyhan")).toBe(true);
|
||||||
|
expect(matchesSuggestedNameRule("cyhan2", "cyhan")).toBe(true);
|
||||||
|
expect(matchesSuggestedNameRule("hcy", "cyhan")).toBe(false);
|
||||||
|
expect(matchesSuggestedNameRule("cyhan-a", "cyhan")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggests the next available local part for domain-only email", () => {
|
||||||
|
const preview = buildHanmacImportEmailPreview(
|
||||||
|
{
|
||||||
|
email: "@hanmaceng.co.kr",
|
||||||
|
name: "한치영",
|
||||||
|
tenantSlug: "hanmac",
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
new Set(["cyhan", "cyhan1"]),
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(preview.finalEmail).toBe("cyhan2@hanmaceng.co.kr");
|
||||||
|
expect(preview.status).toBe("suggested");
|
||||||
|
expect(preview.warnings).toContain("suggested");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks rule mismatch as a warning without blocking the row", () => {
|
||||||
|
const preview = buildHanmacImportEmailPreview(
|
||||||
|
{
|
||||||
|
email: "hcy@hanmaceng.co.kr",
|
||||||
|
name: "한치영",
|
||||||
|
tenantSlug: "hanmac",
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
new Set(),
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(preview.finalEmail).toBe("hcy@hanmaceng.co.kr");
|
||||||
|
expect(preview.status).toBe("ruleMismatch");
|
||||||
|
expect(preview.warnings).toContain("ruleMismatch");
|
||||||
|
expect(preview.blockingErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks duplicate full local part for Hanmac family", () => {
|
||||||
|
const preview = buildHanmacImportEmailPreview(
|
||||||
|
{
|
||||||
|
email: "han@samaneng.com",
|
||||||
|
name: "한치영",
|
||||||
|
tenantSlug: "hanmac",
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
new Set(["han"]),
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(preview.status).toBe("blockingError");
|
||||||
|
expect(preview.blockingErrors).toContain("duplicateLocalPart");
|
||||||
|
});
|
||||||
|
});
|
||||||
296
adminfront/src/features/users/utils/hanmacImportEmail.ts
Normal file
296
adminfront/src/features/users/utils/hanmacImportEmail.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import type { BulkUserItem } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
export type HanmacImportEmailStatus =
|
||||||
|
| "valid"
|
||||||
|
| "suggested"
|
||||||
|
| "needsReview"
|
||||||
|
| "ruleMismatch"
|
||||||
|
| "blockingError";
|
||||||
|
|
||||||
|
export type HanmacImportEmailPreview = {
|
||||||
|
originalEmail: string;
|
||||||
|
suggestedEmail?: string;
|
||||||
|
finalEmail: string;
|
||||||
|
status: HanmacImportEmailStatus;
|
||||||
|
warnings: string[];
|
||||||
|
blockingErrors: string[];
|
||||||
|
reason?: string;
|
||||||
|
localPart?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const surnameRomanization: Record<string, string> = {
|
||||||
|
한: "han",
|
||||||
|
김: "kim",
|
||||||
|
이: "lee",
|
||||||
|
박: "park",
|
||||||
|
최: "choi",
|
||||||
|
정: "jung",
|
||||||
|
조: "cho",
|
||||||
|
강: "kang",
|
||||||
|
윤: "yoon",
|
||||||
|
장: "jang",
|
||||||
|
임: "lim",
|
||||||
|
림: "lim",
|
||||||
|
신: "shin",
|
||||||
|
오: "oh",
|
||||||
|
서: "seo",
|
||||||
|
권: "kwon",
|
||||||
|
황: "hwang",
|
||||||
|
안: "ahn",
|
||||||
|
송: "song",
|
||||||
|
전: "jeon",
|
||||||
|
홍: "hong",
|
||||||
|
유: "yoo",
|
||||||
|
고: "ko",
|
||||||
|
문: "moon",
|
||||||
|
양: "yang",
|
||||||
|
손: "son",
|
||||||
|
배: "bae",
|
||||||
|
백: "baek",
|
||||||
|
허: "heo",
|
||||||
|
남: "nam",
|
||||||
|
심: "sim",
|
||||||
|
노: "noh",
|
||||||
|
하: "ha",
|
||||||
|
곽: "kwak",
|
||||||
|
성: "sung",
|
||||||
|
차: "cha",
|
||||||
|
주: "joo",
|
||||||
|
우: "woo",
|
||||||
|
구: "koo",
|
||||||
|
민: "min",
|
||||||
|
류: "ryu",
|
||||||
|
나: "na",
|
||||||
|
진: "jin",
|
||||||
|
지: "ji",
|
||||||
|
엄: "um",
|
||||||
|
채: "chae",
|
||||||
|
원: "won",
|
||||||
|
천: "cheon",
|
||||||
|
방: "bang",
|
||||||
|
공: "gong",
|
||||||
|
현: "hyun",
|
||||||
|
함: "ham",
|
||||||
|
여: "yeo",
|
||||||
|
추: "choo",
|
||||||
|
도: "do",
|
||||||
|
소: "so",
|
||||||
|
석: "seok",
|
||||||
|
선: "sun",
|
||||||
|
설: "seol",
|
||||||
|
마: "ma",
|
||||||
|
길: "gil",
|
||||||
|
연: "yeon",
|
||||||
|
위: "wi",
|
||||||
|
표: "pyo",
|
||||||
|
명: "myung",
|
||||||
|
기: "ki",
|
||||||
|
반: "ban",
|
||||||
|
라: "ra",
|
||||||
|
왕: "wang",
|
||||||
|
금: "geum",
|
||||||
|
옥: "ok",
|
||||||
|
육: "yook",
|
||||||
|
인: "in",
|
||||||
|
맹: "maeng",
|
||||||
|
제: "je",
|
||||||
|
모: "mo",
|
||||||
|
탁: "tak",
|
||||||
|
국: "guk",
|
||||||
|
어: "eo",
|
||||||
|
은: "eun",
|
||||||
|
편: "pyeon",
|
||||||
|
용: "yong",
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialRomanization = [
|
||||||
|
"g",
|
||||||
|
"g",
|
||||||
|
"n",
|
||||||
|
"d",
|
||||||
|
"d",
|
||||||
|
"r",
|
||||||
|
"m",
|
||||||
|
"b",
|
||||||
|
"b",
|
||||||
|
"s",
|
||||||
|
"s",
|
||||||
|
"y",
|
||||||
|
"j",
|
||||||
|
"j",
|
||||||
|
"c",
|
||||||
|
"k",
|
||||||
|
"t",
|
||||||
|
"p",
|
||||||
|
"h",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildKoreanNameEmailBase(name: string) {
|
||||||
|
const runes = [...name.trim()].filter((char) => !/\s/.test(char));
|
||||||
|
if (runes.length < 2) {
|
||||||
|
return { base: "", needsReview: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const surname = surnameRomanization[runes[0]];
|
||||||
|
if (!surname) {
|
||||||
|
return { base: "", needsReview: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = runes.slice(1).map((char) => romanizedHangulInitial(char));
|
||||||
|
if (initials.some((value) => !value)) {
|
||||||
|
return { base: "", needsReview: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { base: `${initials.join("")}${surname}`, needsReview: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesSuggestedNameRule(localPart: string, base: string) {
|
||||||
|
const normalizedLocalPart = localPart.trim().toLowerCase();
|
||||||
|
const normalizedBase = base.trim().toLowerCase();
|
||||||
|
if (!normalizedLocalPart || !normalizedBase) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizedLocalPart === normalizedBase) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return new RegExp(`^${escapeRegExp(normalizedBase)}\\d+$`).test(
|
||||||
|
normalizedLocalPart,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHanmacImportEmailPreview(
|
||||||
|
user: BulkUserItem,
|
||||||
|
existingLocalParts: Set<string>,
|
||||||
|
batchLocalParts: Set<string>,
|
||||||
|
): HanmacImportEmailPreview {
|
||||||
|
const originalEmail = user.email.trim();
|
||||||
|
const split = splitEmailDomain(originalEmail);
|
||||||
|
if (!split) {
|
||||||
|
return {
|
||||||
|
originalEmail,
|
||||||
|
finalEmail: originalEmail,
|
||||||
|
status: "blockingError",
|
||||||
|
warnings: [],
|
||||||
|
blockingErrors: ["invalidEmail"],
|
||||||
|
reason: "이메일 형식을 확인해 주세요.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedLocalParts = new Set([
|
||||||
|
...[...existingLocalParts].map((value) => value.toLowerCase()),
|
||||||
|
...[...batchLocalParts].map((value) => value.toLowerCase()),
|
||||||
|
]);
|
||||||
|
const { base, needsReview } = buildKoreanNameEmailBase(user.name);
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (needsReview) {
|
||||||
|
warnings.push("needsReview");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!split.localPart) {
|
||||||
|
if (!base) {
|
||||||
|
return {
|
||||||
|
originalEmail,
|
||||||
|
finalEmail: originalEmail,
|
||||||
|
status: "blockingError",
|
||||||
|
warnings,
|
||||||
|
blockingErrors: ["missingLocalPart"],
|
||||||
|
reason: "이름으로 이메일 ID를 제안할 수 없습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLocalPart = nextAvailableLocalPart(base, usedLocalParts);
|
||||||
|
const finalEmail = `${nextLocalPart}@${split.domain}`;
|
||||||
|
batchLocalParts.add(nextLocalPart);
|
||||||
|
return {
|
||||||
|
originalEmail,
|
||||||
|
suggestedEmail: finalEmail,
|
||||||
|
finalEmail,
|
||||||
|
status: "suggested",
|
||||||
|
warnings: appendUnique(warnings, "suggested"),
|
||||||
|
blockingErrors: [],
|
||||||
|
localPart: nextLocalPart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPart = split.localPart.toLowerCase();
|
||||||
|
if (usedLocalParts.has(localPart)) {
|
||||||
|
return {
|
||||||
|
originalEmail,
|
||||||
|
finalEmail: originalEmail,
|
||||||
|
status: "blockingError",
|
||||||
|
warnings,
|
||||||
|
blockingErrors: ["duplicateLocalPart"],
|
||||||
|
reason: "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.",
|
||||||
|
localPart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
batchLocalParts.add(localPart);
|
||||||
|
|
||||||
|
if (base && !matchesSuggestedNameRule(localPart, base)) {
|
||||||
|
return {
|
||||||
|
originalEmail,
|
||||||
|
finalEmail: originalEmail,
|
||||||
|
status: "ruleMismatch",
|
||||||
|
warnings: appendUnique(warnings, "ruleMismatch"),
|
||||||
|
blockingErrors: [],
|
||||||
|
reason: "권장 이메일 ID 규칙과 다릅니다.",
|
||||||
|
localPart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalEmail,
|
||||||
|
finalEmail: originalEmail,
|
||||||
|
status: warnings.includes("needsReview") ? "needsReview" : "valid",
|
||||||
|
warnings,
|
||||||
|
blockingErrors: [],
|
||||||
|
localPart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitEmailDomain(email: string) {
|
||||||
|
const normalized = email.trim().toLowerCase();
|
||||||
|
const parts = normalized.split("@");
|
||||||
|
if (parts.length !== 2 || !parts[1] || !parts[1].includes(".")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
localPart: parts[0],
|
||||||
|
domain: parts[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function romanizedHangulInitial(char: string) {
|
||||||
|
const code = char.codePointAt(0);
|
||||||
|
if (code === undefined || code < 0xac00 || code > 0xd7a3) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const index = Math.floor((code - 0xac00) / 588);
|
||||||
|
return initialRomanization[index] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAvailableLocalPart(base: string, usedLocalParts: Set<string>) {
|
||||||
|
const normalizedBase = base.trim().toLowerCase();
|
||||||
|
if (!usedLocalParts.has(normalizedBase)) {
|
||||||
|
return normalizedBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 1;
|
||||||
|
while (usedLocalParts.has(`${normalizedBase}${index}`)) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return `${normalizedBase}${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendUnique(values: string[], value: string) {
|
||||||
|
if (values.includes(value)) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
return [...values, value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export type TenantCreateRequest = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
domains?: string[];
|
domains?: string[];
|
||||||
|
forceDomainConflicts?: string[];
|
||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ export type TenantUpdateRequest = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
domains?: string[];
|
domains?: string[];
|
||||||
|
forceDomainConflicts?: string[];
|
||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,8 +154,9 @@ export async function deleteTenantsBulk(ids: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportTenantsCSV() {
|
export async function exportTenantsCSV(includeIds = false) {
|
||||||
const response = await apiClient.get<Blob>("/v1/admin/tenants/export", {
|
const response = await apiClient.get<Blob>("/v1/admin/tenants/export", {
|
||||||
|
params: { includeIds },
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
const dispositionHeader = response.headers["content-disposition"];
|
const dispositionHeader = response.headers["content-disposition"];
|
||||||
@@ -459,11 +462,26 @@ export type BulkUserItem = {
|
|||||||
department?: string;
|
department?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
|
tenantImport?: {
|
||||||
|
sourceTenantId?: string;
|
||||||
|
slug?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
parentTenantId?: string;
|
||||||
|
parentTenantSlug?: string;
|
||||||
|
parentTenantName?: string;
|
||||||
|
memo?: string;
|
||||||
|
emailDomain?: string;
|
||||||
|
};
|
||||||
metadata: Record<string, string>;
|
metadata: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BulkUserResult = {
|
export type BulkUserResult = {
|
||||||
email: string;
|
email: string;
|
||||||
|
originalEmail?: string;
|
||||||
|
suggestedEmail?: string;
|
||||||
|
status?: string;
|
||||||
|
warnings?: string[];
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@@ -508,9 +526,13 @@ export async function createUser(payload: UserCreateRequest) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportUsersCSV(search?: string, tenantSlug?: string) {
|
export async function exportUsersCSV(
|
||||||
|
search?: string,
|
||||||
|
tenantSlug?: string,
|
||||||
|
includeIds = false,
|
||||||
|
) {
|
||||||
const response = await apiClient.get<Blob>("/v1/admin/users/export", {
|
const response = await apiClient.get<Blob>("/v1/admin/users/export", {
|
||||||
params: { search, tenantSlug },
|
params: { search, tenantSlug, includeIds },
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
const dispositionHeader = response.headers["content-disposition"];
|
const dispositionHeader = response.headers["content-disposition"];
|
||||||
|
|||||||
156
adminfront/tests/tenant_domains.spec.ts
Normal file
156
adminfront/tests/tenant_domains.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Tenant Allowed Domains", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem("locale", "ko");
|
||||||
|
window.localStorage.setItem("admin_session", "fake-token");
|
||||||
|
window.localStorage.setItem("RoleSwitcher-Collapsed", "true");
|
||||||
|
(
|
||||||
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||||
|
)._IS_TEST_MODE = true;
|
||||||
|
|
||||||
|
const authority = "http://localhost:5000/oidc";
|
||||||
|
const client_id = "adminfront";
|
||||||
|
const key = `oidc.user:${authority}:${client_id}`;
|
||||||
|
window.localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: "fake-token",
|
||||||
|
token_type: "Bearer",
|
||||||
|
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/oidc/**", async (route) => {
|
||||||
|
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds samaneng.com to the current tenant after duplicate warning confirmation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let savedPayload:
|
||||||
|
| {
|
||||||
|
domains?: string[];
|
||||||
|
forceDomainConflicts?: string[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
const method = route.request().method();
|
||||||
|
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||||
|
|
||||||
|
if (url.includes("/user/me")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "admin-user",
|
||||||
|
name: "Admin",
|
||||||
|
role: "super_admin",
|
||||||
|
manageableTenants: [],
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/admin/tenants/current") && method === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "current",
|
||||||
|
name: "현재 테넌트",
|
||||||
|
slug: "current",
|
||||||
|
type: "COMPANY",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
domains: [],
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/admin/tenants/current") && method === "PUT") {
|
||||||
|
savedPayload = route.request().postDataJSON();
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "current",
|
||||||
|
name: "현재 테넌트",
|
||||||
|
slug: "current",
|
||||||
|
type: "COMPANY",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
domains: savedPayload?.domains ?? [],
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/admin/tenants")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "current",
|
||||||
|
name: "현재 테넌트",
|
||||||
|
slug: "current",
|
||||||
|
type: "COMPANY",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
domains: [],
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "existing",
|
||||||
|
name: "한맥가족",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
type: "COMPANY",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
domains: ["samaneng.com"],
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.fulfill({ json: {}, headers });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/tenants/current");
|
||||||
|
await page.locator("#tenant-domains").fill("samaneng.com");
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "계속 진행" }).click();
|
||||||
|
await expect(page.getByText("samaneng.com")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "저장" }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => savedPayload).toMatchObject({
|
||||||
|
domains: ["samaneng.com"],
|
||||||
|
forceDomainConflicts: ["samaneng.com"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
124
adminfront/tests/tenant_schema.spec.ts
Normal file
124
adminfront/tests/tenant_schema.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Tenant Schema Management", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem("locale", "ko");
|
||||||
|
window.localStorage.setItem("admin_session", "fake-token");
|
||||||
|
window.localStorage.setItem("RoleSwitcher-Collapsed", "true");
|
||||||
|
(
|
||||||
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||||
|
)._IS_TEST_MODE = true;
|
||||||
|
|
||||||
|
const authority = "http://localhost:5000/oidc";
|
||||||
|
const client_id = "adminfront";
|
||||||
|
const key = `oidc.user:${authority}:${client_id}`;
|
||||||
|
const authData = {
|
||||||
|
access_token: "fake-token",
|
||||||
|
token_type: "Bearer",
|
||||||
|
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/oidc/**", async (route) => {
|
||||||
|
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should force indexed when schema field is used as login ID", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let savedConfig: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
const method = route.request().method();
|
||||||
|
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||||
|
|
||||||
|
if (url.includes("/user/me")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "admin-user",
|
||||||
|
name: "Admin",
|
||||||
|
role: "super_admin",
|
||||||
|
manageableTenants: [],
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/admin/tenants/t-1") && method === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "t-1",
|
||||||
|
name: "Test Tenant",
|
||||||
|
slug: "test-tenant",
|
||||||
|
type: "COMPANY",
|
||||||
|
status: "active",
|
||||||
|
config: { userSchema: [] },
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/admin/tenants/t-1") && method === "PUT") {
|
||||||
|
const payload = route.request().postDataJSON() as {
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
savedConfig = payload.config;
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "t-1",
|
||||||
|
name: "Test Tenant",
|
||||||
|
slug: "test-tenant",
|
||||||
|
type: "COMPANY",
|
||||||
|
status: "active",
|
||||||
|
config: savedConfig,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/tenants/t-1/schema");
|
||||||
|
await expect(
|
||||||
|
page.getByText(/사용자 스키마 확장|User Schema Extension/),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /필드 추가/ }).click();
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(/예: employee_id|e\.g\. employee_id/)
|
||||||
|
.fill("emp_no");
|
||||||
|
await page.getByPlaceholder("예: 사번").fill("사번");
|
||||||
|
|
||||||
|
const indexedCheckbox = page.getByLabel("검색 인덱스 필요");
|
||||||
|
await expect(indexedCheckbox).not.toBeChecked();
|
||||||
|
await expect(indexedCheckbox).toBeEnabled();
|
||||||
|
|
||||||
|
await page.getByLabel("로그인 ID로 사용").check();
|
||||||
|
await expect(indexedCheckbox).toBeChecked();
|
||||||
|
await expect(indexedCheckbox).toBeDisabled();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /변경사항 저장|스키마 저장|Save/ })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => savedConfig)
|
||||||
|
.toMatchObject({
|
||||||
|
userSchema: [
|
||||||
|
{
|
||||||
|
key: "emp_no",
|
||||||
|
label: "사번",
|
||||||
|
type: "text",
|
||||||
|
indexed: true,
|
||||||
|
isLoginId: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -121,6 +121,7 @@ test.describe("Tenants Management", () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
let exportRequested = false;
|
let exportRequested = false;
|
||||||
|
let exportUrl = "";
|
||||||
let importRequested = false;
|
let importRequested = false;
|
||||||
let importBody = "";
|
let importBody = "";
|
||||||
|
|
||||||
@@ -131,6 +132,7 @@ test.describe("Tenants Management", () => {
|
|||||||
|
|
||||||
if (url.includes("/export")) {
|
if (url.includes("/export")) {
|
||||||
exportRequested = true;
|
exportRequested = true;
|
||||||
|
exportUrl = url;
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
body: "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n",
|
body: "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n",
|
||||||
contentType: "text/csv",
|
contentType: "text/csv",
|
||||||
@@ -191,6 +193,7 @@ test.describe("Tenants Management", () => {
|
|||||||
await page.getByTestId("tenant-export-btn").click();
|
await page.getByTestId("tenant-export-btn").click();
|
||||||
await download;
|
await download;
|
||||||
expect(exportRequested).toBe(true);
|
expect(exportRequested).toBe(true);
|
||||||
|
expect(exportUrl).toContain("includeIds=false");
|
||||||
|
|
||||||
await page.getByTestId("tenant-import-input").setInputFiles({
|
await page.getByTestId("tenant-import-input").setInputFiles({
|
||||||
name: "tenants.csv",
|
name: "tenants.csv",
|
||||||
@@ -213,6 +216,98 @@ test.describe("Tenants Management", () => {
|
|||||||
expect(importBody).toContain("tenant-alpha-id");
|
expect(importBody).toContain("tenant-alpha-id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should resolve tenant CSV conflicts by choosing create and remapping parent ids", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let importBody = "";
|
||||||
|
|
||||||
|
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
const method = route.request().method();
|
||||||
|
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||||
|
|
||||||
|
if (url.includes("/import")) {
|
||||||
|
importBody = route.request().postData() ?? "";
|
||||||
|
return route.fulfill({
|
||||||
|
json: { created: 2, updated: 0, failed: 0, errors: [] },
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "staging-existing-id",
|
||||||
|
name: "Existing Parent",
|
||||||
|
slug: "parent-local",
|
||||||
|
status: "active",
|
||||||
|
type: "COMPANY",
|
||||||
|
domains: [],
|
||||||
|
memberCount: 0,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/tenants");
|
||||||
|
await expect(page.locator("h2").last()).toContainText(
|
||||||
|
/테넌트 목록|Tenants/i,
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId("tenant-import-input").setInputFiles({
|
||||||
|
name: "tenants.csv",
|
||||||
|
mimeType: "text/csv",
|
||||||
|
buffer: Buffer.from(
|
||||||
|
[
|
||||||
|
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain",
|
||||||
|
"local-parent-id,Parent Tenant,COMPANY,,parent-local,,",
|
||||||
|
"local-child-id,Child Tenant,USER_GROUP,local-parent-id,child-local,,",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인");
|
||||||
|
await page
|
||||||
|
.getByTestId("tenant-import-match-select-2")
|
||||||
|
.selectOption("__create__");
|
||||||
|
await page
|
||||||
|
.getByTestId("tenant-import-create-slug-2")
|
||||||
|
.fill("parent-created");
|
||||||
|
await page
|
||||||
|
.getByTestId("tenant-import-match-select-3")
|
||||||
|
.selectOption("__create__");
|
||||||
|
await page
|
||||||
|
.getByTestId("tenant-import-create-slug-3")
|
||||||
|
.fill("child-created");
|
||||||
|
await page.getByTestId("tenant-import-confirm-btn").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("tenant-import-result")).toContainText(
|
||||||
|
/생성 2|Created 2/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(importBody).not.toContain("local-parent-id");
|
||||||
|
expect(importBody).not.toContain("local-child-id");
|
||||||
|
const parentMatch = importBody.match(
|
||||||
|
/([0-9a-f-]{36}),Parent Tenant,COMPANY,,parent-created/,
|
||||||
|
);
|
||||||
|
expect(parentMatch?.[1]).toBeTruthy();
|
||||||
|
expect(importBody).toContain(
|
||||||
|
`,Child Tenant,USER_GROUP,${parentMatch?.[1]},child-created`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("should show validation error on empty name", async ({ page }) => {
|
test("should show validation error on empty name", async ({ page }) => {
|
||||||
await page.goto("/tenants/new");
|
await page.goto("/tenants/new");
|
||||||
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
|
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
|
||||||
|
|||||||
@@ -404,9 +404,11 @@ test.describe("User Management", () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
let authorizationHeader: string | undefined;
|
let authorizationHeader: string | undefined;
|
||||||
|
let exportUrl = "";
|
||||||
|
|
||||||
await page.route(/\/admin\/users\/export(\?.*)?$/, async (route) => {
|
await page.route(/\/admin\/users\/export(\?.*)?$/, async (route) => {
|
||||||
authorizationHeader = route.request().headers().authorization;
|
authorizationHeader = route.request().headers().authorization;
|
||||||
|
exportUrl = route.request().url();
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -425,6 +427,7 @@ test.describe("User Management", () => {
|
|||||||
|
|
||||||
expect(download.suggestedFilename()).toBe("users.csv");
|
expect(download.suggestedFilename()).toBe("users.csv");
|
||||||
expect(authorizationHeader).toBe("Bearer fake-token");
|
expect(authorizationHeader).toBe("Bearer fake-token");
|
||||||
|
expect(exportUrl).toContain("includeIds=false");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show contact info in one row, hide roles, and toggle user status", async ({
|
test("should show contact info in one row, hide roles, and toggle user status", async ({
|
||||||
|
|||||||
@@ -112,4 +112,78 @@ test.describe("Users Bulk Upload", () => {
|
|||||||
const uploadBtn = page.getByTestId("bulk-start-btn");
|
const uploadBtn = page.getByTestId("bulk-start-btn");
|
||||||
await expect(uploadBtn).toBeDisabled();
|
await expect(uploadBtn).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should create missing tenant before user bulk import", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
let bulkPayload = "";
|
||||||
|
|
||||||
|
await page.route("**/api/v1/admin/tenants", async (route) => {
|
||||||
|
const method = route.request().method();
|
||||||
|
requests.push(`${method} ${route.request().url()}`);
|
||||||
|
|
||||||
|
if (method === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
json: { items: [], total: 0, limit: 100, offset: 0 },
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
json: {
|
||||||
|
id: "staging-missing-tenant-id",
|
||||||
|
name: "Missing Tenant",
|
||||||
|
slug: "missing-slug",
|
||||||
|
type: "COMPANY",
|
||||||
|
description: "Imported memo",
|
||||||
|
status: "active",
|
||||||
|
domains: ["missing.example.com"],
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/v1/admin/users/bulk", async (route) => {
|
||||||
|
bulkPayload = route.request().postData() ?? "";
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
results: [{ email: "new@test.com", success: true, userId: "u-1" }],
|
||||||
|
},
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/users");
|
||||||
|
await expect(page.getByTestId("page-title")).toContainText(
|
||||||
|
/사용자|Users/i,
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId("bulk-import-btn").click();
|
||||||
|
await page.locator('input[type="file"]').setInputFiles({
|
||||||
|
name: "users.csv",
|
||||||
|
mimeType: "text/csv",
|
||||||
|
buffer: Buffer.from(
|
||||||
|
"email,name,tenant_id,tenant_slug,tenant_name,tenant_type,tenant_memo,email_domain\nnew@test.com,New User,local-tenant-id,missing-slug,Missing Tenant,COMPANY,Imported memo,missing.example.com\n",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByTestId("user-import-tenant-resolution")).toContainText(
|
||||||
|
/신규 생성|Create new/i,
|
||||||
|
);
|
||||||
|
await page.getByTestId("bulk-start-btn").click();
|
||||||
|
|
||||||
|
await expect(page.getByText("new@test.com")).toBeVisible();
|
||||||
|
expect(requests.some((request) => request.startsWith("POST "))).toBe(true);
|
||||||
|
expect(bulkPayload).toContain('"tenantSlug":"missing-slug"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -230,4 +230,5 @@ test.describe("User Schema Dynamic Form", () => {
|
|||||||
.first();
|
.first();
|
||||||
await expect(errorMsg).toBeVisible();
|
await expect(errorMsg).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const buildOutDir =
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
|
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
|
||||||
|
cacheDir:
|
||||||
|
process.env.ADMINFRONT_VITE_CACHE_DIR ??
|
||||||
|
"/tmp/baron-sso-adminfront-vite-cache",
|
||||||
build: {
|
build: {
|
||||||
outDir: buildOutDir,
|
outDir: buildOutDir,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -285,15 +285,18 @@ func main() {
|
|||||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
|
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
|
||||||
secretRepo := repository.NewClientSecretRepository(db)
|
secretRepo := repository.NewClientSecretRepository(db)
|
||||||
consentRepo := repository.NewClientConsentRepository(db)
|
consentRepo := repository.NewClientConsentRepository(db)
|
||||||
|
rpUserMetadataRepo := repository.NewRPUserMetadataRepository(db)
|
||||||
developerService := service.NewDeveloperService(db)
|
developerService := service.NewDeveloperService(db)
|
||||||
|
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||||
authHandler.HeadlessJWKS = headlessJWKSCache
|
authHandler.HeadlessJWKS = headlessJWKSCache
|
||||||
|
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||||
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
|
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
|
||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
|
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
|
||||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||||
devHandler.AuditRepo = auditRepo
|
devHandler.AuditRepo = auditRepo
|
||||||
|
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
|
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
|
||||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
@@ -706,6 +709,8 @@ func main() {
|
|||||||
dev.Get("/users", devHandler.SearchUsers)
|
dev.Get("/users", devHandler.SearchUsers)
|
||||||
dev.Get("/clients", devHandler.ListClients)
|
dev.Get("/clients", devHandler.ListClients)
|
||||||
dev.Post("/clients", devHandler.CreateClient)
|
dev.Post("/clients", devHandler.CreateClient)
|
||||||
|
dev.Get("/clients/:id/users/:userId/metadata", devHandler.GetRPUserMetadata)
|
||||||
|
dev.Put("/clients/:id/users/:userId/metadata", devHandler.UpsertRPUserMetadata)
|
||||||
dev.Get("/clients/:id", devHandler.GetClient)
|
dev.Get("/clients/:id", devHandler.GetClient)
|
||||||
dev.Get("/clients/:id/relations", devHandler.ListClientRelations)
|
dev.Get("/clients/:id/relations", devHandler.ListClientRelations)
|
||||||
dev.Post("/clients/:id/relations", devHandler.AddClientRelation)
|
dev.Post("/clients/:id/relations", devHandler.AddClientRelation)
|
||||||
|
|||||||
@@ -1156,6 +1156,14 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
logo:
|
logo:
|
||||||
type: string
|
type: string
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
init_url:
|
||||||
|
type: string
|
||||||
|
auto_login_supported:
|
||||||
|
type: boolean
|
||||||
|
auto_login_url:
|
||||||
|
type: string
|
||||||
lastAuthenticatedAt:
|
lastAuthenticatedAt:
|
||||||
type: string
|
type: string
|
||||||
status:
|
status:
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ func Run(db *gorm.DB) error {
|
|||||||
|
|
||||||
func migrateSchemas(db *gorm.DB) error {
|
func migrateSchemas(db *gorm.DB) error {
|
||||||
slog.Info("[Bootstrap] Migrating database schemas...")
|
slog.Info("[Bootstrap] Migrating database schemas...")
|
||||||
|
if err := dropLegacyTenantDomainUniqueIndex(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Add all domain models here
|
// Add all domain models here
|
||||||
return db.AutoMigrate(
|
return db.AutoMigrate(
|
||||||
&domain.Tenant{},
|
&domain.Tenant{},
|
||||||
@@ -43,6 +47,20 @@ func migrateSchemas(db *gorm.DB) error {
|
|||||||
&domain.KetoOutbox{},
|
&domain.KetoOutbox{},
|
||||||
&domain.SharedLink{},
|
&domain.SharedLink{},
|
||||||
&domain.DeveloperRequest{},
|
&domain.DeveloperRequest{},
|
||||||
|
&domain.RPUserMetadata{},
|
||||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dropLegacyTenantDomainUniqueIndex(db *gorm.DB) error {
|
||||||
|
if !db.Migrator().HasTable(&domain.TenantDomain{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasIndex(&domain.TenantDomain{}, "idx_tenant_domains_domain") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := db.Migrator().DropIndex(&domain.TenantDomain{}, "idx_tenant_domains_domain"); err != nil {
|
||||||
|
return fmt.Errorf("failed to drop legacy tenant domain unique index: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,15 +4,33 @@ import (
|
|||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
|
"baron-sso-backend/internal/utils"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const seedTenantCSVPathEnv = "SEED_TENANT_CSV_PATH"
|
||||||
|
|
||||||
|
var seedTenantCSVPathCandidates = []string{
|
||||||
|
"adminfront/seed-tenant.csv",
|
||||||
|
"../adminfront/seed-tenant.csv",
|
||||||
|
"../../adminfront/seed-tenant.csv",
|
||||||
|
"../../../adminfront/seed-tenant.csv",
|
||||||
|
"/app/adminfront/seed-tenant.csv",
|
||||||
|
}
|
||||||
|
|
||||||
type InitialTenantConfig struct {
|
type InitialTenantConfig struct {
|
||||||
|
TenantID string
|
||||||
Name string
|
Name string
|
||||||
Slug string
|
Slug string
|
||||||
Type string
|
Type string
|
||||||
@@ -21,32 +39,31 @@ type InitialTenantConfig struct {
|
|||||||
Domains []string
|
Domains []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hardcoded for now, can be moved to config file or env later
|
func SeedTenants(db *gorm.DB) error {
|
||||||
var defaultTenants = []InitialTenantConfig{
|
slog.Info("[Bootstrap] Checking initial tenant seed...")
|
||||||
{
|
|
||||||
Name: "한맥가족",
|
var tenantCount int64
|
||||||
Slug: "hanmac-family",
|
if err := db.Model(&domain.Tenant{}).Count(&tenantCount).Error; err != nil {
|
||||||
Type: domain.TenantTypeCompanyGroup,
|
return fmt.Errorf("count tenants before seed: %w", err)
|
||||||
},
|
}
|
||||||
{
|
if tenantCount > 0 {
|
||||||
Name: "한맥기술",
|
slog.Info("[Bootstrap] Tenant seed skipped because tenants already exist", "count", tenantCount)
|
||||||
Slug: "hanmac",
|
return nil
|
||||||
Type: domain.TenantTypeCompany,
|
}
|
||||||
ParentSlug: "hanmac-family",
|
|
||||||
Description: "Primary Family Company",
|
configs, err := loadSeedTenantConfigs()
|
||||||
Domains: []string{"hanmaceng.co.kr", "hmac.kr"},
|
if err != nil {
|
||||||
},
|
return err
|
||||||
{
|
}
|
||||||
Name: "삼안",
|
if len(configs) == 0 {
|
||||||
Slug: "saman",
|
return errors.New("seed tenant csv has no tenant rows")
|
||||||
Type: domain.TenantTypeCompany,
|
}
|
||||||
ParentSlug: "hanmac-family",
|
|
||||||
Domains: []string{"samaneng.com"},
|
return seedTenantConfigs(db, configs)
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func SeedTenants(db *gorm.DB) error {
|
func seedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error {
|
||||||
slog.Info("[Bootstrap] Seeding initial tenants...")
|
slog.Info("[Bootstrap] Seeding initial tenants from CSV...", "count", len(configs))
|
||||||
repo := repository.NewTenantRepository(db)
|
repo := repository.NewTenantRepository(db)
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||||
@@ -54,7 +71,7 @@ func SeedTenants(db *gorm.DB) error {
|
|||||||
svc := service.NewTenantService(repo, userRepo, userGroupRepo, outboxRepo)
|
svc := service.NewTenantService(repo, userRepo, userGroupRepo, outboxRepo)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
for _, config := range defaultTenants {
|
for _, config := range orderSeedTenantConfigsByParentSlug(configs) {
|
||||||
tenantType := config.Type
|
tenantType := config.Type
|
||||||
if tenantType == "" {
|
if tenantType == "" {
|
||||||
tenantType = domain.TenantTypeCompany
|
tenantType = domain.TenantTypeCompany
|
||||||
@@ -73,75 +90,273 @@ func SeedTenants(db *gorm.DB) error {
|
|||||||
parentID = &parent.ID
|
parentID = &parent.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := repo.FindBySlug(ctx, config.Slug)
|
slog.Info("[Bootstrap] Creating seed tenant", "name", config.Name, "slug", config.Slug)
|
||||||
if err == nil && existing != nil {
|
var tenant *domain.Tenant
|
||||||
slog.Info("[Bootstrap] Tenant already exists, checking domains...", "slug", config.Slug)
|
var err error
|
||||||
changed := false
|
if config.TenantID != "" {
|
||||||
if existing.Name != config.Name {
|
tenant, err = createSeedTenant(ctx, repo, outboxRepo, config, tenantType, parentID)
|
||||||
existing.Name = config.Name
|
} else {
|
||||||
changed = true
|
tenant, err = svc.RegisterTenant(ctx, config.Name, config.Slug, tenantType, config.Description, config.Domains, parentID, "")
|
||||||
}
|
|
||||||
if existing.Type != tenantType {
|
|
||||||
existing.Type = tenantType
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if existing.Status != domain.TenantStatusActive {
|
|
||||||
existing.Status = domain.TenantStatusActive
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if config.ParentSlug != "" {
|
|
||||||
if existing.ParentID == nil || *existing.ParentID != *parentID {
|
|
||||||
existing.ParentID = parentID
|
|
||||||
changed = true
|
|
||||||
if err := outboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
||||||
Namespace: "Tenant",
|
|
||||||
Object: existing.ID,
|
|
||||||
Relation: "parents",
|
|
||||||
Subject: "Tenant:" + *parentID,
|
|
||||||
Action: domain.KetoOutboxActionCreate,
|
|
||||||
}); err != nil {
|
|
||||||
slog.Error("Failed to create outbox entry for seeded tenant hierarchy", "tenant", existing.ID, "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if existing.ParentID != nil {
|
|
||||||
existing.ParentID = nil
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if changed {
|
|
||||||
if err := repo.Update(ctx, existing); err != nil {
|
|
||||||
slog.Error("Failed to update seeded tenant", "slug", config.Slug, "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Optional: Check and add missing domains
|
|
||||||
for _, d := range config.Domains {
|
|
||||||
found := false
|
|
||||||
for _, ed := range existing.Domains {
|
|
||||||
if ed.Domain == d {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
slog.Info("[Bootstrap] Adding missing domain to tenant", "slug", config.Slug, "domain", d)
|
|
||||||
if err := repo.AddDomain(ctx, existing.ID, d, true); err != nil {
|
|
||||||
slog.Error("Failed to add domain", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug)
|
|
||||||
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, tenantType, config.Description, config.Domains, parentID, "")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Explicitly set to active during seed
|
|
||||||
tenant.Status = domain.TenantStatusActive
|
tenant.Status = domain.TenantStatusActive
|
||||||
db.Save(tenant)
|
if err := db.Save(tenant).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadSeedTenantConfigs() ([]InitialTenantConfig, error) {
|
||||||
|
path, err := findSeedTenantCSVPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open seed tenant csv %q: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
configs, err := parseSeedTenantCSV(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse seed tenant csv %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSeedTenantCSVPath() (string, error) {
|
||||||
|
if configured := strings.TrimSpace(os.Getenv(seedTenantCSVPathEnv)); configured != "" {
|
||||||
|
return configured, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range seedTenantCSVPathCandidates {
|
||||||
|
cleaned := filepath.Clean(candidate)
|
||||||
|
if _, err := os.Stat(cleaned); err == nil {
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("seed tenant csv not found; set %s or add adminfront/seed-tenant.csv", seedTenantCSVPathEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSeedTenantCSV(r io.Reader) ([]InitialTenantConfig, error) {
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("failed to read csv")
|
||||||
|
}
|
||||||
|
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||||
|
|
||||||
|
reader := csv.NewReader(bytes.NewReader(data))
|
||||||
|
reader.FieldsPerRecord = -1
|
||||||
|
rows, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid csv: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, errors.New("csv is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
header := seedTenantCSVHeaderIndex(rows[0])
|
||||||
|
for _, key := range []string{"name", "type", "slug"} {
|
||||||
|
if _, ok := header[key]; !ok {
|
||||||
|
return nil, fmt.Errorf("missing required column: %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configs := make([]InitialTenantConfig, 0, len(rows)-1)
|
||||||
|
for i, row := range rows[1:] {
|
||||||
|
if seedTenantCSVRowIsEmpty(row) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := seedTenantCSVValue(row, header, "name")
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("row %d: name is required", i+2)
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantType := normalizeSeedTenantType(seedTenantCSVValue(row, header, "type"))
|
||||||
|
if tenantType == "" {
|
||||||
|
return nil, fmt.Errorf("row %d: invalid tenant type", i+2)
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := utils.GenerateSlug(seedTenantCSVValue(row, header, "slug"))
|
||||||
|
if slug == "" {
|
||||||
|
return nil, fmt.Errorf("row %d: slug is required", i+2)
|
||||||
|
}
|
||||||
|
|
||||||
|
configs = append(configs, InitialTenantConfig{
|
||||||
|
TenantID: seedTenantCSVValue(row, header, "tenant_id"),
|
||||||
|
Name: name,
|
||||||
|
Type: tenantType,
|
||||||
|
ParentSlug: seedTenantCSVValue(row, header, "parent_tenant_slug"),
|
||||||
|
Slug: slug,
|
||||||
|
Description: seedTenantCSVValue(row, header, "memo"),
|
||||||
|
Domains: splitSeedTenantCSVDomains(seedTenantCSVValue(row, header, "email_domain")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedTenantCSVHeaderIndex(header []string) map[string]int {
|
||||||
|
index := make(map[string]int, len(header))
|
||||||
|
aliases := map[string]string{
|
||||||
|
"id": "tenant_id",
|
||||||
|
"tenantid": "tenant_id",
|
||||||
|
"tenant_id": "tenant_id",
|
||||||
|
"name": "name",
|
||||||
|
"type": "type",
|
||||||
|
"parenttenantslug": "parent_tenant_slug",
|
||||||
|
"parent_tenant_slug": "parent_tenant_slug",
|
||||||
|
"parent_slug": "parent_tenant_slug",
|
||||||
|
"slug": "slug",
|
||||||
|
"memo": "memo",
|
||||||
|
"description": "memo",
|
||||||
|
"email-domain": "email_domain",
|
||||||
|
"emaildomain": "email_domain",
|
||||||
|
"email_domain": "email_domain",
|
||||||
|
"domain": "email_domain",
|
||||||
|
"domains": "email_domain",
|
||||||
|
}
|
||||||
|
for i, column := range header {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(column))
|
||||||
|
key = strings.ReplaceAll(key, " ", "_")
|
||||||
|
if canonical, ok := aliases[key]; ok {
|
||||||
|
index[canonical] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedTenantCSVValue(row []string, header map[string]int, key string) string {
|
||||||
|
idx, ok := header[key]
|
||||||
|
if !ok || idx >= len(row) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(row[idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedTenantCSVRowIsEmpty(row []string) bool {
|
||||||
|
for _, value := range row {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSeedTenantType(value string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||||
|
case domain.TenantTypePersonal:
|
||||||
|
return domain.TenantTypePersonal
|
||||||
|
case domain.TenantTypeCompany:
|
||||||
|
return domain.TenantTypeCompany
|
||||||
|
case domain.TenantTypeCompanyGroup:
|
||||||
|
return domain.TenantTypeCompanyGroup
|
||||||
|
case domain.TenantTypeUserGroup:
|
||||||
|
return domain.TenantTypeUserGroup
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSeedTenantCSVDomains(value string) []string {
|
||||||
|
value = strings.ReplaceAll(value, "\n", ";")
|
||||||
|
value = strings.ReplaceAll(value, ",", ";")
|
||||||
|
parts := strings.Split(value, ";")
|
||||||
|
domains := make([]string, 0, len(parts))
|
||||||
|
seen := make(map[string]bool, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
domainName := strings.ToLower(strings.TrimSpace(part))
|
||||||
|
if domainName == "" || seen[domainName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[domainName] = true
|
||||||
|
domains = append(domains, domainName)
|
||||||
|
}
|
||||||
|
return domains
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderSeedTenantConfigsByParentSlug(configs []InitialTenantConfig) []InitialTenantConfig {
|
||||||
|
bySlug := make(map[string]InitialTenantConfig, len(configs))
|
||||||
|
for _, config := range configs {
|
||||||
|
bySlug[strings.ToLower(config.Slug)] = config
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := make([]InitialTenantConfig, 0, len(configs))
|
||||||
|
visited := make(map[string]bool, len(configs))
|
||||||
|
var visit func(config InitialTenantConfig)
|
||||||
|
visit = func(config InitialTenantConfig) {
|
||||||
|
key := strings.ToLower(config.Slug)
|
||||||
|
if visited[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if config.ParentSlug != "" {
|
||||||
|
if parent, ok := bySlug[strings.ToLower(config.ParentSlug)]; ok {
|
||||||
|
visit(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visited[key] = true
|
||||||
|
ordered = append(ordered, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, config := range configs {
|
||||||
|
visit(config)
|
||||||
|
}
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSeedTenant(
|
||||||
|
ctx context.Context,
|
||||||
|
repo repository.TenantRepository,
|
||||||
|
outboxRepo repository.KetoOutboxRepository,
|
||||||
|
config InitialTenantConfig,
|
||||||
|
tenantType string,
|
||||||
|
parentID *string,
|
||||||
|
) (*domain.Tenant, error) {
|
||||||
|
tenant := &domain.Tenant{
|
||||||
|
ID: config.TenantID,
|
||||||
|
Type: tenantType,
|
||||||
|
Name: config.Name,
|
||||||
|
Slug: config.Slug,
|
||||||
|
Description: config.Description,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
ParentID: parentID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.Create(ctx, tenant); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenant.ID,
|
||||||
|
Relation: "admins",
|
||||||
|
Subject: "System:global#super_admins",
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tenant.ParentID != nil {
|
||||||
|
if err := outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenant.ID,
|
||||||
|
Relation: "parents",
|
||||||
|
Subject: "Tenant:" + *tenant.ParentID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, domainName := range config.Domains {
|
||||||
|
if err := repo.AddDomain(ctx, tenant.ID, domainName, true); err != nil {
|
||||||
|
slog.Error("Failed to add domain to seeded tenant", "tenant", config.Slug, "domain", domainName, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo.FindBySlug(ctx, config.Slug)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,17 +2,21 @@ package bootstrap
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"reflect"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultTenantsSeedOrderAndHierarchy(t *testing.T) {
|
func TestSeedTenantCSVDefinesOnlyRequiredRootTenants(t *testing.T) {
|
||||||
|
configs, err := loadSeedTenantConfigs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
expected := []struct {
|
expected := []struct {
|
||||||
name string
|
name string
|
||||||
slug string
|
slug string
|
||||||
tenantType string
|
tenantType string
|
||||||
parentSlug string
|
|
||||||
domains []string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "한맥가족",
|
name: "한맥가족",
|
||||||
@@ -20,55 +24,61 @@ func TestDefaultTenantsSeedOrderAndHierarchy(t *testing.T) {
|
|||||||
tenantType: domain.TenantTypeCompanyGroup,
|
tenantType: domain.TenantTypeCompanyGroup,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "한맥기술",
|
name: "Personal",
|
||||||
slug: "hanmac",
|
slug: "personal",
|
||||||
tenantType: domain.TenantTypeCompany,
|
tenantType: domain.TenantTypePersonal,
|
||||||
parentSlug: "hanmac-family",
|
|
||||||
domains: []string{"hanmaceng.co.kr", "hmac.kr"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "삼안",
|
|
||||||
slug: "saman",
|
|
||||||
tenantType: domain.TenantTypeCompany,
|
|
||||||
parentSlug: "hanmac-family",
|
|
||||||
domains: []string{"samaneng.com"},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(defaultTenants) != len(expected) {
|
if len(configs) != len(expected) {
|
||||||
t.Fatalf("expected %d default tenants, got %d", len(expected), len(defaultTenants))
|
t.Fatalf("expected %d seed tenants, got %d", len(expected), len(configs))
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, want := range expected {
|
for i, want := range expected {
|
||||||
got := defaultTenants[i]
|
got := configs[i]
|
||||||
if got.Name != want.name {
|
if got.Name != want.name {
|
||||||
t.Fatalf("tenant[%d] name = %q, want %q", i, got.Name, want.name)
|
t.Fatalf("tenant[%d] name = %q, want %q", i, got.Name, want.name)
|
||||||
}
|
}
|
||||||
if got.Slug != want.slug {
|
if got.Slug != want.slug {
|
||||||
t.Fatalf("tenant[%d] slug = %q, want %q", i, got.Slug, want.slug)
|
t.Fatalf("tenant[%d] slug = %q, want %q", i, got.Slug, want.slug)
|
||||||
}
|
}
|
||||||
if tenantType := stringField(t, got, "Type"); tenantType != want.tenantType {
|
if got.Type != want.tenantType {
|
||||||
t.Fatalf("tenant[%d] type = %q, want %q", i, tenantType, want.tenantType)
|
t.Fatalf("tenant[%d] type = %q, want %q", i, got.Type, want.tenantType)
|
||||||
}
|
}
|
||||||
if parentSlug := stringField(t, got, "ParentSlug"); parentSlug != want.parentSlug {
|
if got.ParentSlug != "" {
|
||||||
t.Fatalf("tenant[%d] parent slug = %q, want %q", i, parentSlug, want.parentSlug)
|
t.Fatalf("tenant[%d] parent slug = %q, want empty root tenant", i, got.ParentSlug)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got.Domains, want.domains) {
|
}
|
||||||
t.Fatalf("tenant[%d] domains = %#v, want %#v", i, got.Domains, want.domains)
|
|
||||||
|
for _, tenant := range configs {
|
||||||
|
if tenant.Slug == "system" || tenant.Slug == "hanmac" || tenant.Slug == "saman" {
|
||||||
|
t.Fatalf("tenant %q must be configured by import, not seed CSV", tenant.Slug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringField(t *testing.T, target InitialTenantConfig, name string) string {
|
func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
|
||||||
t.Helper()
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "seed-tenant.csv")
|
||||||
|
csv := "name,type,parent_tenant_slug,slug,memo,email_domain\n" +
|
||||||
|
"Root,COMPANY_GROUP,,root,Root memo,\n" +
|
||||||
|
"Child,COMPANY,root,child,Child memo,child.example.com\n"
|
||||||
|
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write seed csv: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv(seedTenantCSVPathEnv, path)
|
||||||
|
|
||||||
value := reflect.ValueOf(target)
|
configs, err := loadSeedTenantConfigs()
|
||||||
field := value.FieldByName(name)
|
if err != nil {
|
||||||
if !field.IsValid() {
|
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
|
||||||
t.Fatalf("InitialTenantConfig.%s is required", name)
|
|
||||||
}
|
}
|
||||||
if field.Kind() != reflect.String {
|
if len(configs) != 2 {
|
||||||
t.Fatalf("InitialTenantConfig.%s must be a string", name)
|
t.Fatalf("expected 2 configs, got %d", len(configs))
|
||||||
|
}
|
||||||
|
if configs[1].ParentSlug != "root" {
|
||||||
|
t.Fatalf("child parent slug = %q, want root", configs[1].ParentSlug)
|
||||||
|
}
|
||||||
|
if len(configs[1].Domains) != 1 || configs[1].Domains[0] != "child.example.com" {
|
||||||
|
t.Fatalf("child domains = %#v, want child.example.com", configs[1].Domains)
|
||||||
}
|
}
|
||||||
return field.String()
|
|
||||||
}
|
}
|
||||||
|
|||||||
196
backend/internal/domain/hanmac_email.go
Normal file
196
backend/internal/domain/hanmac_email.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
var hanmacSurnameRomanization = map[rune]string{
|
||||||
|
'한': "han",
|
||||||
|
'김': "kim",
|
||||||
|
'이': "lee",
|
||||||
|
'박': "park",
|
||||||
|
'최': "choi",
|
||||||
|
'정': "jung",
|
||||||
|
'조': "cho",
|
||||||
|
'강': "kang",
|
||||||
|
'윤': "yoon",
|
||||||
|
'장': "jang",
|
||||||
|
'임': "lim",
|
||||||
|
'림': "lim",
|
||||||
|
'신': "shin",
|
||||||
|
'오': "oh",
|
||||||
|
'서': "seo",
|
||||||
|
'권': "kwon",
|
||||||
|
'황': "hwang",
|
||||||
|
'안': "ahn",
|
||||||
|
'송': "song",
|
||||||
|
'전': "jeon",
|
||||||
|
'홍': "hong",
|
||||||
|
'유': "yoo",
|
||||||
|
'고': "ko",
|
||||||
|
'문': "moon",
|
||||||
|
'양': "yang",
|
||||||
|
'손': "son",
|
||||||
|
'배': "bae",
|
||||||
|
'백': "baek",
|
||||||
|
'허': "heo",
|
||||||
|
'남': "nam",
|
||||||
|
'심': "sim",
|
||||||
|
'노': "noh",
|
||||||
|
'하': "ha",
|
||||||
|
'곽': "kwak",
|
||||||
|
'성': "sung",
|
||||||
|
'차': "cha",
|
||||||
|
'주': "joo",
|
||||||
|
'우': "woo",
|
||||||
|
'구': "koo",
|
||||||
|
'민': "min",
|
||||||
|
'류': "ryu",
|
||||||
|
'나': "na",
|
||||||
|
'진': "jin",
|
||||||
|
'지': "ji",
|
||||||
|
'엄': "um",
|
||||||
|
'채': "chae",
|
||||||
|
'원': "won",
|
||||||
|
'천': "cheon",
|
||||||
|
'방': "bang",
|
||||||
|
'공': "gong",
|
||||||
|
'현': "hyun",
|
||||||
|
'함': "ham",
|
||||||
|
'여': "yeo",
|
||||||
|
'추': "choo",
|
||||||
|
'도': "do",
|
||||||
|
'소': "so",
|
||||||
|
'석': "seok",
|
||||||
|
'선': "sun",
|
||||||
|
'설': "seol",
|
||||||
|
'마': "ma",
|
||||||
|
'길': "gil",
|
||||||
|
'연': "yeon",
|
||||||
|
'위': "wi",
|
||||||
|
'표': "pyo",
|
||||||
|
'명': "myung",
|
||||||
|
'기': "ki",
|
||||||
|
'반': "ban",
|
||||||
|
'라': "ra",
|
||||||
|
'왕': "wang",
|
||||||
|
'금': "geum",
|
||||||
|
'옥': "ok",
|
||||||
|
'육': "yook",
|
||||||
|
'인': "in",
|
||||||
|
'맹': "maeng",
|
||||||
|
'제': "je",
|
||||||
|
'모': "mo",
|
||||||
|
'탁': "tak",
|
||||||
|
'국': "guk",
|
||||||
|
'어': "eo",
|
||||||
|
'은': "eun",
|
||||||
|
'편': "pyeon",
|
||||||
|
'용': "yong",
|
||||||
|
}
|
||||||
|
|
||||||
|
var hanmacInitialRomanization = []string{
|
||||||
|
"g", "g", "n", "d", "d", "r", "m", "b", "b", "s",
|
||||||
|
"s", "y", "j", "j", "c", "k", "t", "p", "h",
|
||||||
|
}
|
||||||
|
|
||||||
|
func SplitEmailDomain(email string) (string, string, error) {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||||
|
at := strings.Index(normalized, "@")
|
||||||
|
if at < 0 {
|
||||||
|
return "", "", errors.New("email must contain @")
|
||||||
|
}
|
||||||
|
if strings.Count(normalized, "@") != 1 {
|
||||||
|
return "", "", errors.New("email must contain one @")
|
||||||
|
}
|
||||||
|
localPart := strings.TrimSpace(normalized[:at])
|
||||||
|
domainPart := strings.TrimSpace(normalized[at+1:])
|
||||||
|
if domainPart == "" || !strings.Contains(domainPart, ".") {
|
||||||
|
return "", "", errors.New("email domain is invalid")
|
||||||
|
}
|
||||||
|
return localPart, domainPart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractNormalizedEmailLocalPart(email string) (string, error) {
|
||||||
|
localPart, _, err := SplitEmailDomain(email)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if localPart == "" {
|
||||||
|
return "", errors.New("email local-part is empty")
|
||||||
|
}
|
||||||
|
return localPart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildKoreanNameEmailBase(name string) (string, bool, error) {
|
||||||
|
runes := compactNameRunes(name)
|
||||||
|
if len(runes) < 2 {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
surname, ok := hanmacSurnameRomanization[runes[0]]
|
||||||
|
if !ok {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, r := range runes[1:] {
|
||||||
|
initial, ok := romanizedHangulInitial(r)
|
||||||
|
if !ok {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
builder.WriteString(initial)
|
||||||
|
}
|
||||||
|
builder.WriteString(surname)
|
||||||
|
return builder.String(), false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchesSuggestedNameRule(localPart string, base string) bool {
|
||||||
|
localPart = strings.ToLower(strings.TrimSpace(localPart))
|
||||||
|
base = strings.ToLower(strings.TrimSpace(base))
|
||||||
|
if localPart == "" || base == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if localPart == base {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(localPart, base) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
suffix := localPart[len(base):]
|
||||||
|
if suffix == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range suffix {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactNameRunes(name string) []rune {
|
||||||
|
var runes []rune
|
||||||
|
for _, r := range strings.TrimSpace(name) {
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runes = append(runes, r)
|
||||||
|
}
|
||||||
|
return runes
|
||||||
|
}
|
||||||
|
|
||||||
|
func romanizedHangulInitial(r rune) (string, bool) {
|
||||||
|
const hangulBase = 0xAC00
|
||||||
|
const hangulEnd = 0xD7A3
|
||||||
|
if r < hangulBase || r > hangulEnd {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
index := int(r-hangulBase) / 588
|
||||||
|
if index < 0 || index >= len(hanmacInitialRomanization) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return hanmacInitialRomanization[index], true
|
||||||
|
}
|
||||||
76
backend/internal/domain/hanmac_email_test.go
Normal file
76
backend/internal/domain/hanmac_email_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSplitEmailDomainAllowsDomainOnlyImportInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
email string
|
||||||
|
wantLocal string
|
||||||
|
wantDomain string
|
||||||
|
}{
|
||||||
|
{name: "full address", email: " Han@SamanEng.com ", wantLocal: "han", wantDomain: "samaneng.com"},
|
||||||
|
{name: "domain only", email: "@hanmaceng.co.kr", wantLocal: "", wantDomain: "hanmaceng.co.kr"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
local, domain, err := SplitEmailDomain(tt.email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SplitEmailDomain() error = %v", err)
|
||||||
|
}
|
||||||
|
if local != tt.wantLocal || domain != tt.wantDomain {
|
||||||
|
t.Fatalf("SplitEmailDomain() = (%q, %q), want (%q, %q)", local, domain, tt.wantLocal, tt.wantDomain)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildKoreanNameEmailBase(t *testing.T) {
|
||||||
|
base, needsReview, err := BuildKoreanNameEmailBase("한치영")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildKoreanNameEmailBase() error = %v", err)
|
||||||
|
}
|
||||||
|
if needsReview {
|
||||||
|
t.Fatalf("BuildKoreanNameEmailBase() needsReview = true")
|
||||||
|
}
|
||||||
|
if base != "cyhan" {
|
||||||
|
t.Fatalf("BuildKoreanNameEmailBase() = %q, want %q", base, "cyhan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildKoreanNameEmailBaseNeedsReviewForUnknownName(t *testing.T) {
|
||||||
|
base, needsReview, err := BuildKoreanNameEmailBase("A치영")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildKoreanNameEmailBase() error = %v", err)
|
||||||
|
}
|
||||||
|
if base != "" {
|
||||||
|
t.Fatalf("BuildKoreanNameEmailBase() base = %q, want empty", base)
|
||||||
|
}
|
||||||
|
if !needsReview {
|
||||||
|
t.Fatalf("BuildKoreanNameEmailBase() needsReview = false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesSuggestedNameRule(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
localPart string
|
||||||
|
base string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{localPart: "cyhan", base: "cyhan", want: true},
|
||||||
|
{localPart: "cyhan1", base: "cyhan", want: true},
|
||||||
|
{localPart: "cyhan20", base: "cyhan", want: true},
|
||||||
|
{localPart: "hcy", base: "cyhan", want: false},
|
||||||
|
{localPart: "han.cy", base: "cyhan", want: false},
|
||||||
|
{localPart: "cyhan-a", base: "cyhan", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.localPart, func(t *testing.T) {
|
||||||
|
if got := MatchesSuggestedNameRule(tt.localPart, tt.base); got != tt.want {
|
||||||
|
t.Fatalf("MatchesSuggestedNameRule(%q, %q) = %v, want %v", tt.localPart, tt.base, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ const (
|
|||||||
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
||||||
MetadataHeadlessJWKS = "headless_jwks"
|
MetadataHeadlessJWKS = "headless_jwks"
|
||||||
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
||||||
|
MetadataAutoLoginSupported = "auto_login_supported"
|
||||||
|
MetadataAutoLoginURL = "auto_login_url"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HydraClient struct {
|
type HydraClient struct {
|
||||||
|
|||||||
16
backend/internal/domain/rp_user_metadata.go
Normal file
16
backend/internal/domain/rp_user_metadata.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type RPUserMetadata struct {
|
||||||
|
ClientID string `gorm:"column:client_id;primaryKey" json:"clientId"`
|
||||||
|
UserID string `gorm:"column:user_id;type:uuid;primaryKey" json:"userId"`
|
||||||
|
User *User `gorm:"foreignKey:UserID" json:"-"`
|
||||||
|
Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RPUserMetadata) TableName() string {
|
||||||
|
return "rp_user_metadata"
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
// TenantDomain represents a domain associated with a tenant for auto-assignment.
|
// TenantDomain represents a domain associated with a tenant for auto-assignment.
|
||||||
type TenantDomain struct {
|
type TenantDomain struct {
|
||||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
TenantID string `gorm:"type:uuid;not null;index" json:"tenantId"`
|
TenantID string `gorm:"type:uuid;not null;uniqueIndex:idx_tenant_domains_tenant_domain" json:"tenantId"`
|
||||||
Domain string `gorm:"uniqueIndex;not null" json:"domain"` // e.g. "example.com"
|
Domain string `gorm:"not null;uniqueIndex:idx_tenant_domains_tenant_domain" json:"domain"` // e.g. "example.com"
|
||||||
Verified bool `gorm:"default:false" json:"verified"`
|
Verified bool `gorm:"default:false" json:"verified"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
|||||||
@@ -85,20 +85,21 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
SmsService domain.SmsService
|
SmsService domain.SmsService
|
||||||
EmailService domain.EmailService
|
EmailService domain.EmailService
|
||||||
RedisService domain.RedisRepository
|
RedisService domain.RedisRepository
|
||||||
HeadlessJWKS *service.HeadlessJWKSCacheService
|
HeadlessJWKS *service.HeadlessJWKSCacheService
|
||||||
KratosAdmin service.KratosAdminService
|
KratosAdmin service.KratosAdminService
|
||||||
IdpProvider domain.IdentityProvider
|
IdpProvider domain.IdentityProvider
|
||||||
AuditRepo domain.AuditRepository
|
AuditRepo domain.AuditRepository
|
||||||
OathkeeperRepo domain.OathkeeperLogRepository
|
OathkeeperRepo domain.OathkeeperLogRepository
|
||||||
Hydra *service.HydraAdminService
|
Hydra *service.HydraAdminService
|
||||||
TenantService service.TenantService
|
TenantService service.TenantService
|
||||||
KetoService service.KetoService
|
KetoService service.KetoService
|
||||||
KetoOutboxRepo repository.KetoOutboxRepository
|
KetoOutboxRepo repository.KetoOutboxRepository
|
||||||
UserRepo repository.UserRepository
|
UserRepo repository.UserRepository
|
||||||
ConsentRepo repository.ClientConsentRepository
|
ConsentRepo repository.ClientConsentRepository
|
||||||
|
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
type signupState struct {
|
type signupState struct {
|
||||||
@@ -1157,6 +1158,120 @@ func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string
|
|||||||
return claims
|
return claims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
|
||||||
|
if claims == nil {
|
||||||
|
claims = map[string]any{}
|
||||||
|
}
|
||||||
|
if h == nil || h.RPUserMetadataRepo == nil {
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID := strings.TrimSpace(client.ClientID)
|
||||||
|
subject = strings.TrimSpace(subject)
|
||||||
|
if clientID == "" || subject == "" {
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
claimKeys := extractClaimEnabledCustomUserSchemaKeys(client.Metadata)
|
||||||
|
if len(claimKeys) == 0 {
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := h.RPUserMetadataRepo.Get(ctx, clientID, subject)
|
||||||
|
if err != nil || row == nil || len(row.Metadata) == 0 {
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make(map[string]any)
|
||||||
|
for _, key := range claimKeys {
|
||||||
|
raw, ok := row.Metadata[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if value, ok := raw.(string); ok {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields[key] = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields[key] = raw
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := map[string]any{
|
||||||
|
"client_id": clientID,
|
||||||
|
"fields": fields,
|
||||||
|
}
|
||||||
|
if existing, ok := claims["rp_profiles"].([]any); ok {
|
||||||
|
claims["rp_profiles"] = append(existing, profile)
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
if existing, ok := claims["rp_profiles"].([]interface{}); ok {
|
||||||
|
claims["rp_profiles"] = append(existing, profile)
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
claims["rp_profiles"] = []any{profile}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]interface{}) []string {
|
||||||
|
if metadata == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rawSchema, ok := metadata["customUserSchema"]
|
||||||
|
if !ok || rawSchema == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []interface{}
|
||||||
|
switch schema := rawSchema.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
items = schema
|
||||||
|
case []map[string]interface{}:
|
||||||
|
items = make([]interface{}, 0, len(schema))
|
||||||
|
for _, item := range schema {
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(items))
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, item := range items {
|
||||||
|
field, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
if typed, typedOK := item.(map[string]any); typedOK {
|
||||||
|
field = typed
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enabled, _ := field["claimEnabled"].(bool)
|
||||||
|
if !enabled {
|
||||||
|
enabled, _ = field["claim_enabled"].(bool)
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, _ := field["key"].(string)
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
func collectEmailList(traits map[string]any, primaryEmail string) []string {
|
func collectEmailList(traits map[string]any, primaryEmail string) []string {
|
||||||
emails := make([]string, 0)
|
emails := make([]string, 0)
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
@@ -4792,6 +4907,8 @@ type linkedRpSummary struct {
|
|||||||
Logo string `json:"logo,omitempty"`
|
Logo string `json:"logo,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
InitURL string `json:"init_url,omitempty"`
|
InitURL string `json:"init_url,omitempty"`
|
||||||
|
AutoLoginSupported bool `json:"auto_login_supported"`
|
||||||
|
AutoLoginURL string `json:"auto_login_url,omitempty"`
|
||||||
LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
|
LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Scopes []string `json:"scopes,omitempty"`
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
@@ -4872,19 +4989,23 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" {
|
if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" {
|
||||||
scopes = strings.Fields(client.Scope)
|
scopes = strings.Fields(client.Scope)
|
||||||
}
|
}
|
||||||
initURL := resolveLinkedRPInitURL(client.ClientID, scopes, client.RedirectURIs)
|
autoLoginSupported := resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
|
||||||
|
autoLoginURL := resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
|
||||||
|
initURL := resolveLinkedRPInitURL(client.ClientID, client.Metadata)
|
||||||
|
|
||||||
existing := records[clientID]
|
existing := records[clientID]
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
records[clientID] = &linkedRpRecord{
|
records[clientID] = &linkedRpRecord{
|
||||||
linkedRpSummary: linkedRpSummary{
|
linkedRpSummary: linkedRpSummary{
|
||||||
ID: clientID,
|
ID: clientID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Logo: extractHydraClientLogo(client.Metadata),
|
Logo: extractHydraClientLogo(client.Metadata),
|
||||||
URL: clientURL,
|
URL: clientURL,
|
||||||
InitURL: initURL,
|
InitURL: initURL,
|
||||||
Status: "active", // Hydra 세션이 있으면 활성
|
AutoLoginSupported: autoLoginSupported,
|
||||||
Scopes: scopes,
|
AutoLoginURL: autoLoginURL,
|
||||||
|
Status: "active", // Hydra 세션이 있으면 활성
|
||||||
|
Scopes: scopes,
|
||||||
},
|
},
|
||||||
lastAuth: lastAuth,
|
lastAuth: lastAuth,
|
||||||
}
|
}
|
||||||
@@ -4903,6 +5024,12 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
if existing.InitURL == "" {
|
if existing.InitURL == "" {
|
||||||
existing.InitURL = initURL
|
existing.InitURL = initURL
|
||||||
}
|
}
|
||||||
|
if !existing.AutoLoginSupported {
|
||||||
|
existing.AutoLoginSupported = autoLoginSupported
|
||||||
|
}
|
||||||
|
if existing.AutoLoginURL == "" {
|
||||||
|
existing.AutoLoginURL = autoLoginURL
|
||||||
|
}
|
||||||
existing.Scopes = mergeScopes(existing.Scopes, scopes)
|
existing.Scopes = mergeScopes(existing.Scopes, scopes)
|
||||||
if lastAuth.After(existing.lastAuth) {
|
if lastAuth.After(existing.lastAuth) {
|
||||||
existing.lastAuth = lastAuth
|
existing.lastAuth = lastAuth
|
||||||
@@ -4943,11 +5070,13 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if record.InitURL == "" {
|
if record.InitURL == "" {
|
||||||
record.InitURL = resolveLinkedRPInitURL(
|
record.InitURL = resolveLinkedRPInitURL(client.ClientID, client.Metadata)
|
||||||
client.ClientID,
|
}
|
||||||
record.Scopes,
|
if !record.AutoLoginSupported {
|
||||||
client.RedirectURIs,
|
record.AutoLoginSupported = resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
|
||||||
)
|
}
|
||||||
|
if record.AutoLoginURL == "" {
|
||||||
|
record.AutoLoginURL = resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4999,21 +5128,21 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
client.ClientURI,
|
client.ClientURI,
|
||||||
client.RedirectURIs,
|
client.RedirectURIs,
|
||||||
)
|
)
|
||||||
initURL := resolveLinkedRPInitURL(
|
autoLoginSupported := resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
|
||||||
client.ClientID,
|
autoLoginURL := resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
|
||||||
dc.GrantedScopes,
|
initURL := resolveLinkedRPInitURL(client.ClientID, client.Metadata)
|
||||||
client.RedirectURIs,
|
|
||||||
)
|
|
||||||
|
|
||||||
records[dc.ClientID] = &linkedRpRecord{
|
records[dc.ClientID] = &linkedRpRecord{
|
||||||
linkedRpSummary: linkedRpSummary{
|
linkedRpSummary: linkedRpSummary{
|
||||||
ID: dc.ClientID,
|
ID: dc.ClientID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Logo: extractHydraClientLogo(client.Metadata),
|
Logo: extractHydraClientLogo(client.Metadata),
|
||||||
URL: clientURL,
|
URL: clientURL,
|
||||||
InitURL: initURL,
|
InitURL: initURL,
|
||||||
Status: status,
|
AutoLoginSupported: autoLoginSupported,
|
||||||
Scopes: dc.GrantedScopes,
|
AutoLoginURL: autoLoginURL,
|
||||||
|
Status: status,
|
||||||
|
Scopes: dc.GrantedScopes,
|
||||||
},
|
},
|
||||||
lastAuth: dc.UpdatedAt,
|
lastAuth: dc.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -5087,11 +5216,9 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
record.URL = clientURL
|
record.URL = clientURL
|
||||||
record.InitURL = resolveLinkedRPInitURL(
|
record.InitURL = resolveLinkedRPInitURL(client.ClientID, client.Metadata)
|
||||||
client.ClientID,
|
record.AutoLoginSupported = resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
|
||||||
scopes,
|
record.AutoLoginURL = resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
|
||||||
client.RedirectURIs,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
|
// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
|
||||||
if record.Name == "" {
|
if record.Name == "" {
|
||||||
@@ -5239,6 +5366,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|||||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
||||||
currentSessionID,
|
currentSessionID,
|
||||||
)
|
)
|
||||||
|
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||||
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
|
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return c.JSON(acceptResp)
|
return c.JSON(acceptResp)
|
||||||
@@ -5268,6 +5396,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|||||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
||||||
currentSessionID,
|
currentSessionID,
|
||||||
)
|
)
|
||||||
|
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||||
|
|
||||||
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
|
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
|
||||||
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
||||||
@@ -5450,6 +5579,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
|||||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
||||||
currentSessionID,
|
currentSessionID,
|
||||||
)
|
)
|
||||||
|
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||||
|
|
||||||
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)
|
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)
|
||||||
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
||||||
@@ -7255,6 +7385,10 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string
|
|||||||
if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" {
|
if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
case "orgfront":
|
||||||
|
if value := strings.TrimSpace(os.Getenv("ORGFRONT_URL")); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clientURL := strings.TrimSpace(clientURI)
|
clientURL := strings.TrimSpace(clientURI)
|
||||||
@@ -7271,10 +7405,22 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveLinkedRPInitURL(clientID string, scopes []string, redirectURIs []string) string {
|
func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]interface{}) bool {
|
||||||
|
if readMetadataBoolValue(metadata, domain.MetadataAutoLoginSupported) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(clientID) {
|
||||||
|
case "adminfront", "devfront", "orgfront":
|
||||||
|
return resolveLinkedRPAutoLoginURL(clientID, nil) != ""
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]interface{}) string {
|
||||||
clientID = strings.TrimSpace(clientID)
|
clientID = strings.TrimSpace(clientID)
|
||||||
if clientID == "" {
|
if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" {
|
||||||
return ""
|
return metadataURL
|
||||||
}
|
}
|
||||||
|
|
||||||
switch clientID {
|
switch clientID {
|
||||||
@@ -7286,8 +7432,23 @@ func resolveLinkedRPInitURL(clientID string, scopes []string, redirectURIs []str
|
|||||||
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("DEVFRONT_URL")), "/"); value != "" {
|
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("DEVFRONT_URL")), "/"); value != "" {
|
||||||
return value + "/login?auto=1&returnTo=%2Fclients"
|
return value + "/login?auto=1&returnTo=%2Fclients"
|
||||||
}
|
}
|
||||||
|
case "orgfront":
|
||||||
|
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ORGFRONT_URL")), "/"); value != "" {
|
||||||
|
return value + "/login?auto=1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLinkedRPInitURL(clientID string, metadata map[string]interface{}) string {
|
||||||
|
if !resolveLinkedRPAutoLoginSupported(clientID, metadata) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return resolveLinkedRPAutoLoginURL(clientID, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs []string) string {
|
||||||
hydraPublicURL := strings.TrimRight(os.Getenv("HYDRA_PUBLIC_URL"), "/")
|
hydraPublicURL := strings.TrimRight(os.Getenv("HYDRA_PUBLIC_URL"), "/")
|
||||||
if hydraPublicURL == "" {
|
if hydraPublicURL == "" {
|
||||||
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
|
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -178,6 +179,103 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
|||||||
assert.Equal(t, "Architect", capturedClaims["position"])
|
assert.Equal(t, "Architect", capturedClaims["position"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
|
||||||
|
var capturedClaims map[string]interface{}
|
||||||
|
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||||
|
"challenge": "challenge-rp-profile",
|
||||||
|
"requested_scope": []string{"openid", "profile"},
|
||||||
|
"subject": "user-123",
|
||||||
|
"client": map[string]interface{}{
|
||||||
|
"client_id": "client-app",
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"customUserSchema": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"key": "approvalLevel",
|
||||||
|
"label": "승인 등급",
|
||||||
|
"type": "text",
|
||||||
|
"claimEnabled": true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "internalMemo",
|
||||||
|
"label": "내부 메모",
|
||||||
|
"type": "text",
|
||||||
|
"claimEnabled": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var acceptReq map[string]interface{}
|
||||||
|
json.Unmarshal(body, &acceptReq)
|
||||||
|
if session, ok := acceptReq["session"].(map[string]interface{}); ok {
|
||||||
|
capturedClaims = session["id_token"].(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||||
|
"redirect_to": "http://rp/cb",
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
h := &AuthHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: client,
|
||||||
|
},
|
||||||
|
KratosAdmin: new(MockKratosAdminService),
|
||||||
|
}
|
||||||
|
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||||
|
ID: "user-123",
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"email": "user@test.com",
|
||||||
|
"name": "Test User",
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
repo := new(devMockRPUserMetadataRepo)
|
||||||
|
repo.On("Get", mock.Anything, "client-app", "user-123").Return(&domain.RPUserMetadata{
|
||||||
|
ClientID: "client-app",
|
||||||
|
UserID: "user-123",
|
||||||
|
Metadata: domain.JSONMap{
|
||||||
|
"approvalLevel": "A",
|
||||||
|
"internalMemo": "관리자 전용",
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
h.RPUserMetadataRepo = repo
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"consent_challenge": "challenge-rp-profile",
|
||||||
|
"grant_scope": []string{"openid", "profile"},
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
assert.NotNil(t, capturedClaims)
|
||||||
|
rpProfiles, ok := capturedClaims["rp_profiles"].([]interface{})
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Len(t, rpProfiles, 1)
|
||||||
|
profile := rpProfiles[0].(map[string]interface{})
|
||||||
|
assert.Equal(t, "client-app", profile["client_id"])
|
||||||
|
fields := profile["fields"].(map[string]interface{})
|
||||||
|
assert.Equal(t, "A", fields["approvalLevel"])
|
||||||
|
assert.NotContains(t, fields, "internalMemo")
|
||||||
|
repo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
||||||
var capturedClaims map[string]interface{}
|
var capturedClaims map[string]interface{}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
"grant_scope": []string{"openid", "profile"},
|
"grant_scope": []string{"openid", "profile"},
|
||||||
"handled_at": time.Now().Format(time.RFC3339),
|
"handled_at": time.Now().Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"client": map[string]interface{}{
|
||||||
|
"client_id": "orgfront",
|
||||||
|
"client_name": "OrgFront",
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"auto_login_supported": true,
|
||||||
|
"auto_login_url": "http://localhost:5175/login?auto=1",
|
||||||
|
},
|
||||||
|
"redirect_uris": []string{
|
||||||
|
"http://localhost:5175/auth/callback",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"grant_scope": []string{"openid", "profile"},
|
||||||
|
"handled_at": time.Now().Format(time.RFC3339),
|
||||||
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/admin/clients/client-audit" {
|
if r.URL.Path == "/admin/clients/client-audit" {
|
||||||
@@ -129,16 +144,18 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
|
|
||||||
var res struct {
|
var res struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
InitURL string `json:"init_url"`
|
InitURL string `json:"init_url"`
|
||||||
|
AutoLoginSupported bool `json:"auto_login_supported"`
|
||||||
|
AutoLoginURL string `json:"auto_login_url"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(resp.Body).Decode(&res)
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
|
|
||||||
assert.Equal(t, 3, len(res.Items))
|
assert.Equal(t, 4, len(res.Items))
|
||||||
|
|
||||||
statusMap := make(map[string]string)
|
statusMap := make(map[string]string)
|
||||||
for _, item := range res.Items {
|
for _, item := range res.Items {
|
||||||
@@ -146,6 +163,7 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, "active", statusMap["devfront"])
|
assert.Equal(t, "active", statusMap["devfront"])
|
||||||
|
assert.Equal(t, "active", statusMap["orgfront"])
|
||||||
assert.Equal(t, "inactive", statusMap["client-consent"])
|
assert.Equal(t, "inactive", statusMap["client-consent"])
|
||||||
assert.Equal(t, "inactive", statusMap["client-audit"])
|
assert.Equal(t, "inactive", statusMap["client-audit"])
|
||||||
|
|
||||||
@@ -164,6 +182,23 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
assert.Equal(t, "/login", parsedInitURL.Path)
|
assert.Equal(t, "/login", parsedInitURL.Path)
|
||||||
assert.Equal(t, "1", parsedInitURL.Query().Get("auto"))
|
assert.Equal(t, "1", parsedInitURL.Query().Get("auto"))
|
||||||
assert.Equal(t, "/clients", parsedInitURL.Query().Get("returnTo"))
|
assert.Equal(t, "/clients", parsedInitURL.Query().Get("returnTo"))
|
||||||
|
|
||||||
|
var orgfrontItem struct {
|
||||||
|
InitURL string
|
||||||
|
AutoLoginSupported bool
|
||||||
|
AutoLoginURL string
|
||||||
|
}
|
||||||
|
for _, item := range res.Items {
|
||||||
|
if item.ID == "orgfront" {
|
||||||
|
orgfrontItem.InitURL = item.InitURL
|
||||||
|
orgfrontItem.AutoLoginSupported = item.AutoLoginSupported
|
||||||
|
orgfrontItem.AutoLoginURL = item.AutoLoginURL
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, orgfrontItem.AutoLoginSupported)
|
||||||
|
assert.Equal(t, "http://localhost:5175/login?auto=1", orgfrontItem.AutoLoginURL)
|
||||||
|
assert.Equal(t, orgfrontItem.AutoLoginURL, orgfrontItem.InitURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadata(t *testing.T) {
|
func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadata(t *testing.T) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -24,19 +25,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type DevHandler struct {
|
type DevHandler struct {
|
||||||
Hydra *service.HydraAdminService
|
Hydra *service.HydraAdminService
|
||||||
Redis domain.RedisRepository
|
Redis domain.RedisRepository
|
||||||
HeadlessJWKS *service.HeadlessJWKSCacheService
|
HeadlessJWKS *service.HeadlessJWKSCacheService
|
||||||
SecretRepo domain.ClientSecretRepository
|
SecretRepo domain.ClientSecretRepository
|
||||||
AuditRepo domain.AuditRepository
|
AuditRepo domain.AuditRepository
|
||||||
KratosAdmin service.KratosAdminService
|
KratosAdmin service.KratosAdminService
|
||||||
ConsentRepo repository.ClientConsentRepository
|
ConsentRepo repository.ClientConsentRepository
|
||||||
Keto service.KetoService
|
Keto service.KetoService
|
||||||
KetoOutbox repository.KetoOutboxRepository
|
KetoOutbox repository.KetoOutboxRepository
|
||||||
RPSvc service.RelyingPartyService
|
RPSvc service.RelyingPartyService
|
||||||
TenantSvc service.TenantService
|
TenantSvc service.TenantService
|
||||||
DeveloperSvc *service.DeveloperService
|
DeveloperSvc *service.DeveloperService
|
||||||
Auth interface {
|
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||||
|
Auth interface {
|
||||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1377,6 +1379,86 @@ func (h *DevHandler) publicHeadlessJWKSCacheState(clientID string) (*domain.Head
|
|||||||
return h.HeadlessJWKS.PublicState(clientID)
|
return h.HeadlessJWKS.PublicState(clientID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) GetRPUserMetadata(c *fiber.Ctx) error {
|
||||||
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
|
userID := strings.TrimSpace(c.Params("userId"))
|
||||||
|
if clientID == "" || userID == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "client id and user id are required")
|
||||||
|
}
|
||||||
|
if h.RPUserMetadataRepo == nil {
|
||||||
|
return errorJSON(c, fiber.StatusServiceUnavailable, "rp user metadata repository unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := h.getCurrentProfile(c)
|
||||||
|
if profile == nil {
|
||||||
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.loadClientSummary(c.Context(), clientID)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||||
|
}
|
||||||
|
if !h.canViewClientByPermit(c, profile, summary) {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permission to view client metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := h.RPUserMetadataRepo.Get(c.Context(), clientID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"clientId": clientID,
|
||||||
|
"userId": userID,
|
||||||
|
"metadata": domain.JSONMap{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) UpsertRPUserMetadata(c *fiber.Ctx) error {
|
||||||
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
|
userID := strings.TrimSpace(c.Params("userId"))
|
||||||
|
if clientID == "" || userID == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "client id and user id are required")
|
||||||
|
}
|
||||||
|
if h.RPUserMetadataRepo == nil {
|
||||||
|
return errorJSON(c, fiber.StatusServiceUnavailable, "rp user metadata repository unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := h.getCurrentProfile(c)
|
||||||
|
if profile == nil {
|
||||||
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.loadClientSummary(c.Context(), clientID)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||||
|
}
|
||||||
|
if !h.canManageClientRelations(c, profile, summary) {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permission to update client metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Metadata map[string]any `json:"metadata"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
if req.Metadata == nil {
|
||||||
|
req.Metadata = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
row := &domain.RPUserMetadata{
|
||||||
|
ClientID: clientID,
|
||||||
|
UserID: userID,
|
||||||
|
Metadata: domain.JSONMap(req.Metadata),
|
||||||
|
}
|
||||||
|
if err := h.RPUserMetadataRepo.Upsert(c.Context(), row); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(row)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *DevHandler) syncHeadlessJWKSCache(ctx context.Context, client domain.HydraClient, reason string) {
|
func (h *DevHandler) syncHeadlessJWKSCache(ctx context.Context, client domain.HydraClient, reason string) {
|
||||||
if h.HeadlessJWKS == nil {
|
if h.HeadlessJWKS == nil {
|
||||||
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil)
|
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil)
|
||||||
@@ -1574,6 +1656,10 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
|
metadata, err = normalizeClientAutoLoginMetadata(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
|
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
|
||||||
if tokenAuthMethod == "" {
|
if tokenAuthMethod == "" {
|
||||||
@@ -1766,6 +1852,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
|
metadata, err = normalizeClientAutoLoginMetadata(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
resolvedClientType := currentSummary.Type
|
resolvedClientType := currentSummary.Type
|
||||||
if clientType != "" {
|
if clientType != "" {
|
||||||
resolvedClientType = clientType
|
resolvedClientType = clientType
|
||||||
@@ -2575,6 +2665,30 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
|
||||||
|
if metadata == nil {
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
supported := readMetadataBoolValue(metadata, domain.MetadataAutoLoginSupported)
|
||||||
|
rawURL := strings.TrimSpace(readMetadataStringValue(metadata, domain.MetadataAutoLoginURL))
|
||||||
|
metadata[domain.MetadataAutoLoginSupported] = supported
|
||||||
|
if !supported {
|
||||||
|
delete(metadata, domain.MetadataAutoLoginURL)
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawURL == "" {
|
||||||
|
return nil, errors.New("auto_login_url is required when auto_login_supported is true")
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
|
||||||
|
return nil, errors.New("auto_login_url must be an http or https URL")
|
||||||
|
}
|
||||||
|
metadata[domain.MetadataAutoLoginURL] = rawURL
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeHeadlessClientConfig(
|
func normalizeHeadlessClientConfig(
|
||||||
clientType string,
|
clientType string,
|
||||||
tokenAuthMethod string,
|
tokenAuthMethod string,
|
||||||
|
|||||||
94
backend/internal/handler/dev_handler_rp_metadata_test.go
Normal file
94
backend/internal/handler/dev_handler_rp_metadata_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
type devMockRPUserMetadataRepo struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *devMockRPUserMetadataRepo) Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error) {
|
||||||
|
args := m.Called(ctx, clientID, userID)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*domain.RPUserMetadata), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *devMockRPUserMetadataRepo) Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error {
|
||||||
|
args := m.Called(ctx, metadata)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevHandler_RPUserMetadataRoundTrip(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]interface{}{
|
||||||
|
"client_id": "client-1",
|
||||||
|
"client_name": "Client One",
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
repo := new(devMockRPUserMetadataRepo)
|
||||||
|
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"
|
||||||
|
})).Return(nil).Once()
|
||||||
|
repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{
|
||||||
|
ClientID: "client-1",
|
||||||
|
UserID: "user-1",
|
||||||
|
Metadata: domain.JSONMap{"approvalLevel": "A"},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
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)
|
||||||
|
app.Get("/api/v1/dev/clients/:id/users/:userId/metadata", h.GetRPUserMetadata)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"metadata": map[string]any{"approvalLevel": "A"},
|
||||||
|
})
|
||||||
|
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")
|
||||||
|
putResp, _ := app.Test(putReq, -1)
|
||||||
|
assert.Equal(t, http.StatusOK, putResp.StatusCode)
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/users/user-1/metadata", nil)
|
||||||
|
getResp, _ := app.Test(getReq, -1)
|
||||||
|
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
assert.NoError(t, json.NewDecoder(getResp.Body).Decode(&got))
|
||||||
|
assert.Equal(t, "client-1", got["clientId"])
|
||||||
|
assert.Equal(t, "user-1", got["userId"])
|
||||||
|
assert.Equal(t, "A", got["metadata"].(map[string]any)["approvalLevel"])
|
||||||
|
repo.AssertExpectations(t)
|
||||||
|
}
|
||||||
@@ -1300,6 +1300,36 @@ func TestCreateClient_DefaultsSkipConsentToTrue(t *testing.T) {
|
|||||||
assert.True(t, *captured.SkipConsent)
|
assert.True(t, *captured.SkipConsent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeClientAutoLoginMetadata(t *testing.T) {
|
||||||
|
t.Run("keeps supported flag and URL", func(t *testing.T) {
|
||||||
|
metadata, err := normalizeClientAutoLoginMetadata(map[string]interface{}{
|
||||||
|
"auto_login_supported": true,
|
||||||
|
"auto_login_url": "https://rp.example.com/login?auto=1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, true, metadata["auto_login_supported"])
|
||||||
|
assert.Equal(t, "https://rp.example.com/login?auto=1", metadata["auto_login_url"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("requires URL when supported", func(t *testing.T) {
|
||||||
|
_, err := normalizeClientAutoLoginMetadata(map[string]interface{}{
|
||||||
|
"auto_login_supported": true,
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("removes URL when unsupported", func(t *testing.T) {
|
||||||
|
metadata, err := normalizeClientAutoLoginMetadata(map[string]interface{}{
|
||||||
|
"auto_login_supported": false,
|
||||||
|
"auto_login_url": "https://rp.example.com/login?auto=1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, false, metadata["auto_login_supported"])
|
||||||
|
_, exists := metadata["auto_login_url"]
|
||||||
|
assert.False(t, exists)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateClient_AllowsExplicitSkipConsentFalse(t *testing.T) {
|
func TestCreateClient_AllowsExplicitSkipConsentFalse(t *testing.T) {
|
||||||
var captured domain.HydraClient
|
var captured domain.HydraClient
|
||||||
|
|
||||||
|
|||||||
244
backend/internal/handler/hanmac_email_policy.go
Normal file
244
backend/internal/handler/hanmac_email_policy.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hanmacFamilyTenantSlug = "hanmac-family"
|
||||||
|
|
||||||
|
type hanmacEmailScope struct {
|
||||||
|
TenantIDs map[string]bool
|
||||||
|
Slugs map[string]bool
|
||||||
|
IDList []string
|
||||||
|
SlugList []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type hanmacEmailEvaluation struct {
|
||||||
|
Email string
|
||||||
|
OriginalEmail string
|
||||||
|
SuggestedEmail string
|
||||||
|
Status string
|
||||||
|
Warnings []string
|
||||||
|
Message string
|
||||||
|
Blocking bool
|
||||||
|
LocalPart string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUserItem, scope *hanmacEmailScope, usedLocalParts map[string]bool) hanmacEmailEvaluation {
|
||||||
|
originalEmail := strings.TrimSpace(item.Email)
|
||||||
|
name := strings.TrimSpace(item.Name)
|
||||||
|
evaluation := hanmacEmailEvaluation{
|
||||||
|
Email: originalEmail,
|
||||||
|
OriginalEmail: originalEmail,
|
||||||
|
Status: "valid",
|
||||||
|
}
|
||||||
|
|
||||||
|
localPart, domainPart, err := domain.SplitEmailDomain(originalEmail)
|
||||||
|
if err != nil {
|
||||||
|
evaluation.Status = "blockingError"
|
||||||
|
evaluation.Message = "invalid email format"
|
||||||
|
evaluation.Blocking = true
|
||||||
|
return evaluation
|
||||||
|
}
|
||||||
|
|
||||||
|
base, needsReview, _ := domain.BuildKoreanNameEmailBase(name)
|
||||||
|
if needsReview {
|
||||||
|
evaluation.Warnings = append(evaluation.Warnings, "needsReview")
|
||||||
|
evaluation.Status = "needsReview"
|
||||||
|
}
|
||||||
|
|
||||||
|
if localPart == "" {
|
||||||
|
if base == "" {
|
||||||
|
evaluation.Status = "blockingError"
|
||||||
|
evaluation.Message = "이름으로 이메일 ID를 제안할 수 없습니다."
|
||||||
|
evaluation.Blocking = true
|
||||||
|
return evaluation
|
||||||
|
}
|
||||||
|
nextLocalPart := nextAvailableHanmacLocalPart(base, usedLocalParts)
|
||||||
|
evaluation.Email = nextLocalPart + "@" + domainPart
|
||||||
|
evaluation.SuggestedEmail = evaluation.Email
|
||||||
|
evaluation.LocalPart = nextLocalPart
|
||||||
|
evaluation.Status = "suggested"
|
||||||
|
evaluation.Warnings = appendUniqueString(evaluation.Warnings, "suggested")
|
||||||
|
return evaluation
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluation.LocalPart = localPart
|
||||||
|
if usedLocalParts[localPart] {
|
||||||
|
evaluation.Status = "blockingError"
|
||||||
|
evaluation.Message = "한맥가족 내에서 이미 사용 중인 이메일 ID입니다."
|
||||||
|
evaluation.Blocking = true
|
||||||
|
return evaluation
|
||||||
|
}
|
||||||
|
|
||||||
|
if base != "" && !domain.MatchesSuggestedNameRule(localPart, base) {
|
||||||
|
evaluation.Status = "ruleMismatch"
|
||||||
|
evaluation.Warnings = appendUniqueString(evaluation.Warnings, "ruleMismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if evaluation.Status == "needsReview" && len(evaluation.Warnings) == 0 {
|
||||||
|
evaluation.Warnings = append(evaluation.Warnings, "needsReview")
|
||||||
|
}
|
||||||
|
_ = scope
|
||||||
|
return evaluation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) ensureHanmacCreateEmailAllowed(ctx context.Context, email string, tenantSlug string, tenantID string) error {
|
||||||
|
scope, err := h.resolveHanmacEmailScope(ctx)
|
||||||
|
if err != nil || scope == nil || !scope.ContainsTenant(tenantID, tenantSlug) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
localPart, err := domain.ExtractNormalizedEmailLocalPart(email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
usedLocalParts, err := h.loadHanmacLocalParts(ctx, scope)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if usedLocalParts[localPart] {
|
||||||
|
return fmt.Errorf("한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) resolveHanmacEmailScope(ctx context.Context) (*hanmacEmailScope, error) {
|
||||||
|
if h.TenantService == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tenants, _, err := h.TenantService.ListTenants(ctx, 10000, 0, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootID string
|
||||||
|
for _, tenant := range tenants {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(tenant.Slug), hanmacFamilyTenantSlug) {
|
||||||
|
rootID = tenant.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rootID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantByID := make(map[string]domain.Tenant, len(tenants))
|
||||||
|
for _, tenant := range tenants {
|
||||||
|
tenantByID[tenant.ID] = tenant
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := &hanmacEmailScope{
|
||||||
|
TenantIDs: make(map[string]bool),
|
||||||
|
Slugs: make(map[string]bool),
|
||||||
|
}
|
||||||
|
for _, tenant := range tenants {
|
||||||
|
if isTenantDescendantOf(tenant, rootID, tenantByID) {
|
||||||
|
scope.TenantIDs[tenant.ID] = true
|
||||||
|
scope.Slugs[strings.ToLower(strings.TrimSpace(tenant.Slug))] = true
|
||||||
|
scope.IDList = append(scope.IDList, tenant.ID)
|
||||||
|
scope.SlugList = append(scope.SlugList, tenant.Slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scope, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEmailScope) (map[string]bool, error) {
|
||||||
|
used := make(map[string]bool)
|
||||||
|
if h.UserRepo == nil || scope == nil {
|
||||||
|
return used, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(scope.IDList) > 0 {
|
||||||
|
users, err := h.UserRepo.FindByTenantIDs(ctx, scope.IDList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
addUserEmailLocalParts(used, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(scope.SlugList) > 0 {
|
||||||
|
users, err := h.UserRepo.FindByCompanyCodes(ctx, scope.SlugList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
addUserEmailLocalParts(used, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
return used, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *hanmacEmailScope) ContainsTenant(tenantID string, slug string) bool {
|
||||||
|
if s == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if tenantID != "" && s.TenantIDs[tenantID] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return s.Slugs[strings.ToLower(strings.TrimSpace(slug))]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTenantDescendantOf(tenant domain.Tenant, rootID string, tenantByID map[string]domain.Tenant) bool {
|
||||||
|
if tenant.ID == rootID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
visited := make(map[string]bool)
|
||||||
|
parentID := ""
|
||||||
|
if tenant.ParentID != nil {
|
||||||
|
parentID = *tenant.ParentID
|
||||||
|
}
|
||||||
|
for parentID != "" {
|
||||||
|
if parentID == rootID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if visited[parentID] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
visited[parentID] = true
|
||||||
|
parent, ok := tenantByID[parentID]
|
||||||
|
if !ok || parent.ParentID == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parentID = *parent.ParentID
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func addUserEmailLocalParts(target map[string]bool, users []domain.User) {
|
||||||
|
for _, user := range users {
|
||||||
|
localPart, err := domain.ExtractNormalizedEmailLocalPart(user.Email)
|
||||||
|
if err == nil && localPart != "" {
|
||||||
|
target[localPart] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextAvailableHanmacLocalPart(base string, usedLocalParts map[string]bool) string {
|
||||||
|
base = strings.ToLower(strings.TrimSpace(base))
|
||||||
|
if base == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !usedLocalParts[base] {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
for index := 1; ; index++ {
|
||||||
|
candidate := fmt.Sprintf("%s%d", base, index)
|
||||||
|
if !usedLocalParts[candidate] {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniqueString(values []string, value string) []string {
|
||||||
|
for _, existing := range values {
|
||||||
|
if existing == value {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(values, value)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/utils"
|
"baron-sso-backend/internal/utils"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -68,14 +69,22 @@ type tenantImportResult struct {
|
|||||||
Errors []string `json:"errors"`
|
Errors []string `json:"errors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tenantDomainConflict struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
TenantID string `json:"tenantId"`
|
||||||
|
TenantName string `json:"tenantName"`
|
||||||
|
TenantSlug string `json:"tenantSlug"`
|
||||||
|
}
|
||||||
|
|
||||||
type tenantCSVRecord struct {
|
type tenantCSVRecord struct {
|
||||||
TenantID string
|
TenantID string
|
||||||
Name string
|
Name string
|
||||||
Type string
|
Type string
|
||||||
ParentTenantID *string
|
ParentTenantID *string
|
||||||
Slug string
|
ParentTenantSlug string
|
||||||
Memo string
|
Slug string
|
||||||
Domains []string
|
Memo string
|
||||||
|
Domains []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
|
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
|
||||||
@@ -258,13 +267,24 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
writer := csv.NewWriter(&buf)
|
writer := csv.NewWriter(&buf)
|
||||||
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "slug", "memo", "email_domain"}); err != nil {
|
includeIDs := includeCSVIds(c)
|
||||||
|
if includeIDs {
|
||||||
|
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "parent_tenant_slug", "slug", "memo", "email_domain"}); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
} else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain"}); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
slugByID := make(map[string]string, len(tenants))
|
||||||
|
for _, tenant := range tenants {
|
||||||
|
slugByID[tenant.ID] = tenant.Slug
|
||||||
|
}
|
||||||
for _, tenant := range tenants {
|
for _, tenant := range tenants {
|
||||||
parentID := ""
|
parentID := ""
|
||||||
|
parentSlug := ""
|
||||||
if tenant.ParentID != nil {
|
if tenant.ParentID != nil {
|
||||||
parentID = *tenant.ParentID
|
parentID = *tenant.ParentID
|
||||||
|
parentSlug = slugByID[parentID]
|
||||||
}
|
}
|
||||||
domains := make([]string, 0, len(tenant.Domains))
|
domains := make([]string, 0, len(tenant.Domains))
|
||||||
for _, domainName := range tenant.Domains {
|
for _, domainName := range tenant.Domains {
|
||||||
@@ -273,15 +293,27 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
|||||||
domains = append(domains, domainName)
|
domains = append(domains, domainName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := writer.Write([]string{
|
row := []string{
|
||||||
tenant.ID,
|
|
||||||
tenant.Name,
|
tenant.Name,
|
||||||
tenant.Type,
|
tenant.Type,
|
||||||
parentID,
|
parentSlug,
|
||||||
tenant.Slug,
|
tenant.Slug,
|
||||||
tenant.Description,
|
tenant.Description,
|
||||||
strings.Join(domains, ";"),
|
strings.Join(domains, ";"),
|
||||||
}); err != nil {
|
}
|
||||||
|
if includeIDs {
|
||||||
|
row = []string{
|
||||||
|
tenant.ID,
|
||||||
|
tenant.Name,
|
||||||
|
tenant.Type,
|
||||||
|
parentID,
|
||||||
|
parentSlug,
|
||||||
|
tenant.Slug,
|
||||||
|
tenant.Description,
|
||||||
|
strings.Join(domains, ";"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Write(row); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,33 +337,60 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
|
records = orderTenantCSVRecordsByParentSlug(records)
|
||||||
|
|
||||||
creatorID := ""
|
creatorID := ""
|
||||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||||
creatorID = profile.ID
|
creatorID = profile.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tenantIDBySlug := make(map[string]string)
|
||||||
|
if h.Service != nil {
|
||||||
|
if tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, ""); err == nil {
|
||||||
|
for _, tenant := range tenants {
|
||||||
|
tenantIDBySlug[strings.ToLower(tenant.Slug)] = tenant.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result := tenantImportResult{Errors: make([]string, 0)}
|
result := tenantImportResult{Errors: make([]string, 0)}
|
||||||
for i, record := range records {
|
for i, record := range records {
|
||||||
rowNumber := i + 2
|
rowNumber := i + 2
|
||||||
|
if record.ParentTenantID == nil && record.ParentTenantSlug != "" {
|
||||||
|
parentID := tenantIDBySlug[strings.ToLower(record.ParentTenantSlug)]
|
||||||
|
if parentID == "" {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("row %d: parent tenant slug not found: %s", rowNumber, record.ParentTenantSlug))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
record.ParentTenantID = &parentID
|
||||||
|
}
|
||||||
if record.TenantID != "" || (h.DB != nil && record.Slug != "") {
|
if record.TenantID != "" || (h.DB != nil && record.Slug != "") {
|
||||||
updated, err := h.upsertTenantCSVRecord(c, record)
|
tenant, updated, err := h.upsertTenantCSVRecord(c, record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Failed++
|
result.Failed++
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
|
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if updated {
|
if updated {
|
||||||
|
tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID
|
||||||
result.Updated++
|
result.Updated++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.createTenantCSVRecord(c, record, creatorID); err != nil {
|
tenant, err := h.createTenantCSVRecord(c, record, creatorID)
|
||||||
|
if err != nil {
|
||||||
result.Failed++
|
result.Failed++
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
|
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if tenant == nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("row %d: tenant creation returned empty result", rowNumber))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID
|
||||||
result.Created++
|
result.Created++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,13 +473,14 @@ func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
records = append(records, tenantCSVRecord{
|
records = append(records, tenantCSVRecord{
|
||||||
TenantID: tenantCSVValue(row, header, "tenant_id"),
|
TenantID: tenantCSVValue(row, header, "tenant_id"),
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: tenantType,
|
Type: tenantType,
|
||||||
ParentTenantID: parentID,
|
ParentTenantID: parentID,
|
||||||
Slug: slug,
|
ParentTenantSlug: tenantCSVValue(row, header, "parent_tenant_slug"),
|
||||||
Memo: tenantCSVValue(row, header, "memo"),
|
Slug: slug,
|
||||||
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
|
Memo: tenantCSVValue(row, header, "memo"),
|
||||||
|
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,23 +490,25 @@ func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
|
|||||||
func tenantCSVHeaderIndex(header []string) map[string]int {
|
func tenantCSVHeaderIndex(header []string) map[string]int {
|
||||||
index := make(map[string]int, len(header))
|
index := make(map[string]int, len(header))
|
||||||
aliases := map[string]string{
|
aliases := map[string]string{
|
||||||
"id": "tenant_id",
|
"id": "tenant_id",
|
||||||
"tenantid": "tenant_id",
|
"tenantid": "tenant_id",
|
||||||
"tenant_id": "tenant_id",
|
"tenant_id": "tenant_id",
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type": "type",
|
"type": "type",
|
||||||
"parentid": "parent_tenant_id",
|
"parentid": "parent_tenant_id",
|
||||||
"parent_id": "parent_tenant_id",
|
"parent_id": "parent_tenant_id",
|
||||||
"parenttenantid": "parent_tenant_id",
|
"parenttenantid": "parent_tenant_id",
|
||||||
"parent_tenant_id": "parent_tenant_id",
|
"parent_tenant_id": "parent_tenant_id",
|
||||||
"slug": "slug",
|
"parenttenantslug": "parent_tenant_slug",
|
||||||
"memo": "memo",
|
"parent_tenant_slug": "parent_tenant_slug",
|
||||||
"description": "memo",
|
"slug": "slug",
|
||||||
"email-domain": "email_domain",
|
"memo": "memo",
|
||||||
"emaildomain": "email_domain",
|
"description": "memo",
|
||||||
"email_domain": "email_domain",
|
"email-domain": "email_domain",
|
||||||
"domain": "email_domain",
|
"emaildomain": "email_domain",
|
||||||
"domains": "email_domain",
|
"email_domain": "email_domain",
|
||||||
|
"domain": "email_domain",
|
||||||
|
"domains": "email_domain",
|
||||||
}
|
}
|
||||||
for i, column := range header {
|
for i, column := range header {
|
||||||
key := strings.ToLower(strings.TrimSpace(column))
|
key := strings.ToLower(strings.TrimSpace(column))
|
||||||
@@ -475,6 +537,40 @@ func tenantCSVRowIsEmpty(row []string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func includeCSVIds(c *fiber.Ctx) bool {
|
||||||
|
value := strings.ToLower(strings.TrimSpace(c.Query("includeIds")))
|
||||||
|
return value == "true" || value == "1" || value == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderTenantCSVRecordsByParentSlug(records []tenantCSVRecord) []tenantCSVRecord {
|
||||||
|
bySlug := make(map[string]tenantCSVRecord, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
bySlug[strings.ToLower(record.Slug)] = record
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := make([]tenantCSVRecord, 0, len(records))
|
||||||
|
visited := make(map[string]bool, len(records))
|
||||||
|
var visit func(record tenantCSVRecord)
|
||||||
|
visit = func(record tenantCSVRecord) {
|
||||||
|
key := strings.ToLower(record.Slug)
|
||||||
|
if visited[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if record.ParentTenantSlug != "" {
|
||||||
|
if parent, ok := bySlug[strings.ToLower(record.ParentTenantSlug)]; ok {
|
||||||
|
visit(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visited[key] = true
|
||||||
|
ordered = append(ordered, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
visit(record)
|
||||||
|
}
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
func splitTenantCSVDomains(value string) []string {
|
func splitTenantCSVDomains(value string) []string {
|
||||||
value = strings.ReplaceAll(value, "\n", ";")
|
value = strings.ReplaceAll(value, "\n", ";")
|
||||||
value = strings.ReplaceAll(value, ",", ";")
|
value = strings.ReplaceAll(value, ",", ";")
|
||||||
@@ -492,12 +588,203 @@ func splitTenantCSVDomains(value string) []string {
|
|||||||
return domains
|
return domains
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (bool, error) {
|
func normalizeTenantDomainInputs(values []string) []string {
|
||||||
|
seen := make(map[string]bool, len(values))
|
||||||
|
domains := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
|
||||||
|
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
|
||||||
|
}) {
|
||||||
|
domainName := strings.ToLower(strings.TrimSpace(part))
|
||||||
|
if domainName == "" || seen[domainName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[domainName] = true
|
||||||
|
domains = append(domains, domainName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domains
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
|
||||||
|
normalized := make(domain.JSONMap, len(config))
|
||||||
|
for key, value := range config {
|
||||||
|
if key == "userSchema" {
|
||||||
|
fields, err := normalizeTenantUserSchema(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
normalized[key] = fields
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized[key] = value
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTenantUserSchema(value any) ([]any, error) {
|
||||||
|
if value == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawFields, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("userSchema must be an array")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make([]any, 0, len(rawFields))
|
||||||
|
for _, raw := range rawFields {
|
||||||
|
field, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("userSchema fields must be objects")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := make(map[string]any, len(field))
|
||||||
|
for key, value := range field {
|
||||||
|
if key == "maxLength" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoginID, _ := normalized["isLoginId"].(bool)
|
||||||
|
if isLoginID {
|
||||||
|
fieldType, _ := normalized["type"].(string)
|
||||||
|
if fieldType != "" && fieldType != "text" {
|
||||||
|
return nil, fmt.Errorf("login ID fields must be text")
|
||||||
|
}
|
||||||
|
normalized["type"] = "text"
|
||||||
|
normalized["indexed"] = true
|
||||||
|
} else if indexed, ok := normalized["indexed"].(bool); !ok || !indexed {
|
||||||
|
normalized["indexed"] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = append(fields, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTenantDomainForceSet(values []string) map[string]bool {
|
||||||
|
domains := normalizeTenantDomainInputs(values)
|
||||||
|
force := make(map[string]bool, len(domains))
|
||||||
|
for _, domainName := range domains {
|
||||||
|
force[domainName] = true
|
||||||
|
}
|
||||||
|
return force
|
||||||
|
}
|
||||||
|
|
||||||
|
func tenantDomainConflictJSON(c *fiber.Ctx, conflicts []tenantDomainConflict) error {
|
||||||
|
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": "tenant_domain_conflict",
|
||||||
|
"error": "domain is already assigned to another tenant",
|
||||||
|
"conflicts": conflicts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) findTenantDomainConflicts(ctx context.Context, tenantID string, domains []string, forceDomains []string) ([]tenantDomainConflict, error) {
|
||||||
|
if h.DB == nil || h.DB.Config == nil || len(domains) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
force := normalizeTenantDomainForceSet(forceDomains)
|
||||||
|
var rows []domain.TenantDomain
|
||||||
|
query := h.DB.WithContext(ctx).Where("domain IN ?", domains)
|
||||||
|
if tenantID != "" {
|
||||||
|
query = query.Where("tenant_id <> ?", tenantID)
|
||||||
|
}
|
||||||
|
if err := query.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts := make([]tenantDomainConflict, 0, len(rows))
|
||||||
|
tenantIDs := make([]string, 0, len(rows))
|
||||||
|
seenTenantIDs := make(map[string]bool, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if force[row.Domain] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !seenTenantIDs[row.TenantID] {
|
||||||
|
seenTenantIDs[row.TenantID] = true
|
||||||
|
tenantIDs = append(tenantIDs, row.TenantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantsByID := make(map[string]domain.Tenant, len(tenantIDs))
|
||||||
|
if len(tenantIDs) > 0 {
|
||||||
|
var tenants []domain.Tenant
|
||||||
|
if err := h.DB.WithContext(ctx).Where("id IN ?", tenantIDs).Find(&tenants).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, tenant := range tenants {
|
||||||
|
tenantsByID[tenant.ID] = tenant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
if force[row.Domain] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
conflict := tenantDomainConflict{
|
||||||
|
Domain: row.Domain,
|
||||||
|
TenantID: row.TenantID,
|
||||||
|
}
|
||||||
|
if tenant, ok := tenantsByID[row.TenantID]; ok {
|
||||||
|
conflict.TenantName = tenant.Name
|
||||||
|
conflict.TenantSlug = tenant.Slug
|
||||||
|
}
|
||||||
|
conflicts = append(conflicts, conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) replaceTenantDomains(ctx context.Context, tenantID string, domains []string, forceDomains []string) error {
|
||||||
|
if h.DB == nil {
|
||||||
|
return errors.New("database not available")
|
||||||
|
}
|
||||||
|
if h.DB.Config == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteQuery := h.DB.WithContext(ctx).Where("tenant_id = ?", tenantID)
|
||||||
|
if len(domains) > 0 {
|
||||||
|
deleteQuery = deleteQuery.Where("domain NOT IN ?", domains)
|
||||||
|
}
|
||||||
|
if err := deleteQuery.Delete(&domain.TenantDomain{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to clear old domains: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, domainName := range domains {
|
||||||
|
var existing domain.TenantDomain
|
||||||
|
err := h.DB.WithContext(ctx).Unscoped().
|
||||||
|
Where("tenant_id = ? AND domain = ?", tenantID, domainName).
|
||||||
|
First(&existing).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
if err := repository.NewTenantRepository(h.DB).AddDomain(ctx, tenantID, domainName, true); err != nil {
|
||||||
|
return fmt.Errorf("failed to add domain: %s", domainName)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := h.DB.WithContext(ctx).Unscoped().Model(&existing).Updates(map[string]any{
|
||||||
|
"verified": true,
|
||||||
|
"deleted_at": nil,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to add domain: %s", domainName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (*domain.Tenant, bool, error) {
|
||||||
if h.DB == nil {
|
if h.DB == nil {
|
||||||
if record.TenantID != "" {
|
if record.TenantID != "" {
|
||||||
return false, errors.New("database not available for tenant update")
|
return nil, false, errors.New("database not available for tenant update")
|
||||||
}
|
}
|
||||||
return false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenant domain.Tenant
|
var tenant domain.Tenant
|
||||||
@@ -510,10 +797,10 @@ func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVReco
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tenant.Name = record.Name
|
tenant.Name = record.Name
|
||||||
@@ -526,29 +813,29 @@ func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVReco
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.DB.Save(&tenant).Error; err != nil {
|
if err := h.DB.Save(&tenant).Error; err != nil {
|
||||||
return false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
|
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
|
||||||
return false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
repo := repository.NewTenantRepository(h.DB)
|
repo := repository.NewTenantRepository(h.DB)
|
||||||
for _, domainName := range record.Domains {
|
for _, domainName := range record.Domains {
|
||||||
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
|
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
|
||||||
return false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return &tenant, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord, creatorID string) error {
|
func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord, creatorID string) (*domain.Tenant, error) {
|
||||||
if h.DB != nil && record.TenantID != "" {
|
if h.DB != nil && record.TenantID != "" {
|
||||||
var exists int64
|
var exists int64
|
||||||
if err := h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", record.Slug).Count(&exists).Error; err != nil {
|
if err := h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", record.Slug).Count(&exists).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if exists > 0 {
|
if exists > 0 {
|
||||||
return errors.New("tenant slug already exists")
|
return nil, errors.New("tenant slug already exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
tenant := domain.Tenant{
|
tenant := domain.Tenant{
|
||||||
@@ -561,7 +848,7 @@ func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVReco
|
|||||||
Status: domain.TenantStatusActive,
|
Status: domain.TenantStatusActive,
|
||||||
}
|
}
|
||||||
if err := h.DB.Create(&tenant).Error; err != nil {
|
if err := h.DB.Create(&tenant).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if h.KetoOutbox != nil {
|
if h.KetoOutbox != nil {
|
||||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
@@ -595,14 +882,14 @@ func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVReco
|
|||||||
repo := repository.NewTenantRepository(h.DB)
|
repo := repository.NewTenantRepository(h.DB)
|
||||||
for _, domainName := range record.Domains {
|
for _, domainName := range record.Domains {
|
||||||
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
|
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return &tenant, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.Service.RegisterTenant(c.Context(), record.Name, record.Slug, record.Type, record.Memo, record.Domains, record.ParentTenantID, creatorID)
|
tenant, err := h.Service.RegisterTenant(c.Context(), record.Name, record.Slug, record.Type, record.Memo, record.Domains, record.ParentTenantID, creatorID)
|
||||||
return err
|
return tenant, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||||
@@ -646,14 +933,15 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Domains []string `json:"domains"`
|
Domains []string `json:"domains"`
|
||||||
ParentID *string `json:"parentId"`
|
ForceDomains []string `json:"forceDomainConflicts"`
|
||||||
Config map[string]any `json:"config"`
|
ParentID *string `json:"parentId"`
|
||||||
|
Config map[string]any `json:"config"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
@@ -701,7 +989,16 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
creatorID = profile.ID
|
creatorID = profile.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID, creatorID)
|
normalizedDomains := normalizeTenantDomainInputs(req.Domains)
|
||||||
|
conflicts, err := h.findTenantDomainConflicts(c.Context(), "", normalizedDomains, req.ForceDomains)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
if len(conflicts) > 0 {
|
||||||
|
return tenantDomainConflictJSON(c, conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, nil, parentID, creatorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "already exists") {
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
return errorJSON(c, fiber.StatusConflict, err.Error())
|
return errorJSON(c, fiber.StatusConflict, err.Error())
|
||||||
@@ -713,10 +1010,20 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
summary.MemberCount = 0
|
summary.MemberCount = 0
|
||||||
|
|
||||||
if req.Config != nil {
|
if req.Config != nil {
|
||||||
tenant.Config = req.Config
|
config, err := normalizeTenantConfig(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
tenant.Config = config
|
||||||
h.DB.Save(tenant)
|
h.DB.Save(tenant)
|
||||||
summary.Config = tenant.Config
|
summary.Config = tenant.Config
|
||||||
}
|
}
|
||||||
|
if err := h.replaceTenantDomains(c.Context(), tenant.ID, normalizedDomains, req.ForceDomains); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
if len(normalizedDomains) > 0 {
|
||||||
|
summary.Domains = normalizedDomains
|
||||||
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(summary)
|
return c.Status(fiber.StatusCreated).JSON(summary)
|
||||||
}
|
}
|
||||||
@@ -740,14 +1047,15 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Type *string `json:"type"`
|
Type *string `json:"type"`
|
||||||
Slug *string `json:"slug"`
|
Slug *string `json:"slug"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
ParentID *string `json:"parentId"`
|
ParentID *string `json:"parentId"`
|
||||||
Domains []string `json:"domains"`
|
Domains []string `json:"domains"`
|
||||||
Config map[string]any `json:"config"`
|
ForceDomains []string `json:"forceDomainConflicts"`
|
||||||
|
Config map[string]any `json:"config"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
@@ -835,7 +1143,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.Config != nil {
|
if req.Config != nil {
|
||||||
tenant.Config = req.Config
|
config, err := normalizeTenantConfig(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
tenant.Config = config
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.DB.Save(&tenant).Error; err != nil {
|
if err := h.DB.Save(&tenant).Error; err != nil {
|
||||||
@@ -844,18 +1156,16 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// Update domains if provided
|
// Update domains if provided
|
||||||
if req.Domains != nil {
|
if req.Domains != nil {
|
||||||
// Simple approach: Delete existing and recreate
|
normalizedDomains := normalizeTenantDomainInputs(req.Domains)
|
||||||
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
|
conflicts, err := h.findTenantDomainConflicts(c.Context(), tenant.ID, normalizedDomains, req.ForceDomains)
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to clear old domains")
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
for _, d := range req.Domains {
|
if len(conflicts) > 0 {
|
||||||
if strings.TrimSpace(d) == "" {
|
return tenantDomainConflictJSON(c, conflicts)
|
||||||
continue
|
}
|
||||||
}
|
if err := h.replaceTenantDomains(c.Context(), tenant.ID, normalizedDomains, req.ForceDomains); err != nil {
|
||||||
// Use repository for consistency
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
if err := repository.NewTenantRepository(h.DB).AddDomain(c.Context(), tenant.ID, strings.TrimSpace(d), true); err != nil {
|
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to add domain: "+d)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body, _ := json.Marshal(input)
|
body, _ := json.Marshal(input)
|
||||||
|
|
||||||
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil), "").
|
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string(nil), (*string)(nil), "").
|
||||||
Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)
|
Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
|
||||||
@@ -278,15 +278,55 @@ func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
|
|||||||
|
|
||||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(1), nil)
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(1), nil)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/tenants/export", nil)
|
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
|
||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv")
|
assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv")
|
||||||
assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
||||||
assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain")
|
assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain")
|
||||||
assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com")
|
assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockSvc := new(MockTenantService)
|
||||||
|
h := &TenantHandler{Service: mockSvc}
|
||||||
|
|
||||||
|
app.Get("/tenants/export", h.ExportTenantsCSV)
|
||||||
|
|
||||||
|
parentID := "parent-1"
|
||||||
|
tenants := []domain.Tenant{
|
||||||
|
{
|
||||||
|
ID: parentID,
|
||||||
|
Name: "Parent Tenant",
|
||||||
|
Type: domain.TenantTypeCompanyGroup,
|
||||||
|
Slug: "parent-tenant",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "child-1",
|
||||||
|
Name: "Child Tenant",
|
||||||
|
Type: domain.TenantTypeUserGroup,
|
||||||
|
ParentID: &parentID,
|
||||||
|
Slug: "child-tenant",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(2), nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/tenants/export?includeIds=false", nil)
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
text := string(body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Contains(t, text, "name,type,parent_tenant_slug,slug,memo,email_domain")
|
||||||
|
assert.Contains(t, text, "Child Tenant,USER_GROUP,parent-tenant,child-tenant,,")
|
||||||
|
assert.NotContains(t, text, "tenant_id")
|
||||||
|
assert.NotContains(t, text, "parent_tenant_id")
|
||||||
|
assert.NotContains(t, text, "child-1")
|
||||||
|
mockSvc.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
|
func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
|
||||||
@@ -304,6 +344,7 @@ func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NoError(t, writer.Close())
|
assert.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once()
|
||||||
mockSvc.On(
|
mockSvc.On(
|
||||||
"RegisterTenant",
|
"RegisterTenant",
|
||||||
mock.Anything,
|
mock.Anything,
|
||||||
@@ -331,6 +372,127 @@ func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
|
|||||||
mockSvc.AssertExpectations(t)
|
mockSvc.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockSvc := new(MockTenantService)
|
||||||
|
h := &TenantHandler{Service: mockSvc}
|
||||||
|
|
||||||
|
app.Post("/tenants/import", h.ImportTenantsCSV)
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
part, err := writer.CreateFormFile("file", "tenants.csv")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = part.Write([]byte("name,type,parent_tenant_slug,slug,memo,email_domain\nParent Tenant,COMPANY,,parent-slug,,\nChild Tenant,USER_GROUP,parent-slug,child-slug,,\n"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
parentID := "parent-id"
|
||||||
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once()
|
||||||
|
mockSvc.On(
|
||||||
|
"RegisterTenant",
|
||||||
|
mock.Anything,
|
||||||
|
"Parent Tenant",
|
||||||
|
"parent-slug",
|
||||||
|
domain.TenantTypeCompany,
|
||||||
|
"",
|
||||||
|
[]string{},
|
||||||
|
(*string)(nil),
|
||||||
|
"",
|
||||||
|
).Return(&domain.Tenant{ID: parentID, Name: "Parent Tenant", Slug: "parent-slug"}, nil).Once()
|
||||||
|
mockSvc.On(
|
||||||
|
"RegisterTenant",
|
||||||
|
mock.Anything,
|
||||||
|
"Child Tenant",
|
||||||
|
"child-slug",
|
||||||
|
domain.TenantTypeUserGroup,
|
||||||
|
"",
|
||||||
|
[]string{},
|
||||||
|
mock.MatchedBy(func(got *string) bool {
|
||||||
|
return got != nil && *got == parentID
|
||||||
|
}),
|
||||||
|
"",
|
||||||
|
).Return(&domain.Tenant{ID: "child-id", Name: "Child Tenant", Slug: "child-slug"}, nil).Once()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/tenants/import", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var got map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&got)
|
||||||
|
assert.Equal(t, float64(2), got["created"])
|
||||||
|
assert.Equal(t, float64(0), got["failed"])
|
||||||
|
mockSvc.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) {
|
||||||
|
records, err := parseTenantCSVRecords(strings.NewReader(
|
||||||
|
"name,type,parent_tenant_slug,slug,memo,email_domain\n" +
|
||||||
|
"Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\"\n",
|
||||||
|
))
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, records, 1)
|
||||||
|
assert.Equal(t, []string{"samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"}, records[0].Domains)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeTenantDomainInputsSplitsCommaAndWhitespace(t *testing.T) {
|
||||||
|
got := normalizeTenantDomainInputs([]string{
|
||||||
|
"samaneng.com, hanmaceng.co.kr",
|
||||||
|
" LOGIN.HMAC.KR\nportal.hmac.kr ",
|
||||||
|
"samaneng.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"samaneng.com",
|
||||||
|
"hanmaceng.co.kr",
|
||||||
|
"login.hmac.kr",
|
||||||
|
"portal.hmac.kr",
|
||||||
|
}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeTenantConfigForcesIndexedForLoginIDFields(t *testing.T) {
|
||||||
|
config, err := normalizeTenantConfig(map[string]any{
|
||||||
|
"userSchema": []any{
|
||||||
|
map[string]any{
|
||||||
|
"key": "emp_no",
|
||||||
|
"label": "사번",
|
||||||
|
"type": "text",
|
||||||
|
"indexed": false,
|
||||||
|
"isLoginId": true,
|
||||||
|
"maxLength": 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
fields, ok := config["userSchema"].([]any)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Len(t, fields, 1)
|
||||||
|
|
||||||
|
field, ok := fields[0].(map[string]any)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, true, field["indexed"])
|
||||||
|
assert.Equal(t, true, field["isLoginId"])
|
||||||
|
assert.NotContains(t, field, "maxLength")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeTenantConfigRejectsNonTextLoginIDFields(t *testing.T) {
|
||||||
|
_, err := normalizeTenantConfig(map[string]any{
|
||||||
|
"userSchema": []any{
|
||||||
|
map[string]any{
|
||||||
|
"key": "emp_no",
|
||||||
|
"type": "number",
|
||||||
|
"isLoginId": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "login ID fields must be text")
|
||||||
|
}
|
||||||
|
|
||||||
func TestTenantHandler_ApproveTenant(t *testing.T) {
|
func TestTenantHandler_ApproveTenant(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
|
|||||||
@@ -425,6 +425,15 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
attributes["tenant_id"] = tenantID
|
attributes["tenant_id"] = tenantID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.UserRepo != nil {
|
||||||
|
if err := h.ensureHanmacCreateEmailAllowed(c.Context(), email, req.CompanyCode, tenantID); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "한맥가족") {
|
||||||
|
return errorJSON(c, fiber.StatusConflict, err.Error())
|
||||||
|
}
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Merge custom metadata into attributes
|
// Merge custom metadata into attributes
|
||||||
for k, v := range req.Metadata {
|
for k, v := range req.Metadata {
|
||||||
// Don't overwrite core fields
|
// Don't overwrite core fields
|
||||||
@@ -534,10 +543,14 @@ type bulkUserItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type bulkUserResult struct {
|
type bulkUserResult struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Success bool `json:"success"`
|
OriginalEmail string `json:"originalEmail,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
SuggestedEmail string `json:"suggestedEmail,omitempty"`
|
||||||
UserID string `json:"userId,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
UserID string `json:"userId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||||
@@ -565,6 +578,9 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
results := make([]bulkUserResult, 0, len(req.Users))
|
results := make([]bulkUserResult, 0, len(req.Users))
|
||||||
|
var hanmacScope *hanmacEmailScope
|
||||||
|
var hanmacLocalParts map[string]bool
|
||||||
|
hanmacScopeLoaded := false
|
||||||
|
|
||||||
// Pre-fetch tenant data to avoid redundant DB calls
|
// Pre-fetch tenant data to avoid redundant DB calls
|
||||||
type tenantCacheItem struct {
|
type tenantCacheItem struct {
|
||||||
@@ -638,6 +654,53 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.UserRepo != nil && !hanmacScopeLoaded {
|
||||||
|
hanmacScopeLoaded = true
|
||||||
|
var err error
|
||||||
|
hanmacScope, err = h.resolveHanmacEmailScope(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to resolve Hanmac family tenant scope"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hanmacScope != nil {
|
||||||
|
hanmacLocalParts, err = h.loadHanmacLocalParts(c.Context(), hanmacScope)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to validate Hanmac family email policy"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userEmail := email
|
||||||
|
var emailEvaluation hanmacEmailEvaluation
|
||||||
|
if h.UserRepo != nil && hanmacScope != nil && hanmacScope.ContainsTenant(tItem.ID, tenantSlug) {
|
||||||
|
emailEvaluation = h.evaluateHanmacImportEmail(c.Context(), item, hanmacScope, hanmacLocalParts)
|
||||||
|
if emailEvaluation.Blocking {
|
||||||
|
results = append(results, bulkUserResult{
|
||||||
|
Email: emailEvaluation.Email,
|
||||||
|
OriginalEmail: emailEvaluation.OriginalEmail,
|
||||||
|
Status: emailEvaluation.Status,
|
||||||
|
Warnings: emailEvaluation.Warnings,
|
||||||
|
Success: false,
|
||||||
|
Message: emailEvaluation.Message,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userEmail = emailEvaluation.Email
|
||||||
|
if emailEvaluation.LocalPart != "" {
|
||||||
|
hanmacLocalParts[emailEvaluation.LocalPart] = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, _, err := domain.SplitEmailDomain(email); err != nil {
|
||||||
|
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: "invalid email format"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if localPart, err := domain.ExtractNormalizedEmailLocalPart(email); err != nil || localPart == "" {
|
||||||
|
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: "invalid email format"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
password, _ := utils.GeneratePasswordWithPolicy(policy)
|
password, _ := utils.GeneratePasswordWithPolicy(policy)
|
||||||
role := item.Role
|
role := item.Role
|
||||||
if role == "" {
|
if role == "" {
|
||||||
@@ -665,7 +728,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
userEmail := email
|
|
||||||
userPhone := normalizePhoneNumber(item.Phone)
|
userPhone := normalizePhoneNumber(item.Phone)
|
||||||
|
|
||||||
// Validate all collected LoginIDs
|
// Validate all collected LoginIDs
|
||||||
@@ -673,7 +735,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
valid := true
|
valid := true
|
||||||
for _, lid := range collectedIDs {
|
for _, lid := range collectedIDs {
|
||||||
if err := domain.ValidateLoginID(lid, userEmail, userPhone); err != nil {
|
if err := domain.ValidateLoginID(lid, userEmail, userPhone); err != nil {
|
||||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "Invalid LoginID (" + lid + "): " + err.Error()})
|
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: "Invalid LoginID (" + lid + "): " + err.Error()})
|
||||||
valid = false
|
valid = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -692,14 +754,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
|
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
|
||||||
if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") {
|
if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") {
|
||||||
identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email)
|
identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userEmail)
|
||||||
if err != nil || identityID == "" {
|
if err != nil || identityID == "" {
|
||||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."})
|
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: "blockingError", Warnings: emailEvaluation.Warnings, Success: false, Message: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID)
|
slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID)
|
||||||
} else {
|
} else {
|
||||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
|
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: err.Error()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -709,7 +771,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
if h.UserRepo != nil {
|
if h.UserRepo != nil {
|
||||||
localUser := &domain.User{
|
localUser := &domain.User{
|
||||||
ID: identityID,
|
ID: identityID,
|
||||||
Email: email,
|
Email: userEmail,
|
||||||
Name: name,
|
Name: name,
|
||||||
Phone: normalizePhoneNumber(item.Phone),
|
Phone: normalizePhoneNumber(item.Phone),
|
||||||
Role: role,
|
Role: role,
|
||||||
@@ -776,7 +838,15 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results = append(results, bulkUserResult{Email: email, Success: true, UserID: identityID})
|
results = append(results, bulkUserResult{
|
||||||
|
Email: userEmail,
|
||||||
|
OriginalEmail: emailEvaluation.OriginalEmail,
|
||||||
|
SuggestedEmail: emailEvaluation.SuggestedEmail,
|
||||||
|
Status: emailEvaluation.Status,
|
||||||
|
Warnings: emailEvaluation.Warnings,
|
||||||
|
Success: true,
|
||||||
|
UserID: identityID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||||
@@ -870,12 +940,19 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
defer writer.Flush()
|
defer writer.Flush()
|
||||||
|
|
||||||
// Header row
|
// Header row
|
||||||
header := []string{"ID", "Email", "Name", "Phone", "Status", "Tenant", "Position", "JobTitle", "CreatedAt"}
|
includeIDs := includeCSVIds(c)
|
||||||
|
header := []string{"Email", "Name", "Phone", "Status", "tenant_slug", "Position", "JobTitle", "CreatedAt"}
|
||||||
|
if includeIDs {
|
||||||
|
header = []string{"user_id", "Email", "Name", "Phone", "Status", "tenant_id", "tenant_slug", "Position", "JobTitle", "CreatedAt"}
|
||||||
|
}
|
||||||
|
|
||||||
// Collect all possible metadata keys for dynamic columns
|
// Collect all possible metadata keys for dynamic columns
|
||||||
metaKeysMap := make(map[string]bool)
|
metaKeysMap := make(map[string]bool)
|
||||||
for _, u := range filtered {
|
for _, u := range filtered {
|
||||||
for k := range u.Metadata {
|
for k := range u.Metadata {
|
||||||
|
if !includeIDs && csvMetadataKeyIsID(k) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
metaKeysMap[k] = true
|
metaKeysMap[k] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -891,8 +968,11 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// Data rows
|
// Data rows
|
||||||
for _, u := range filtered {
|
for _, u := range filtered {
|
||||||
|
tenantID := ""
|
||||||
|
if u.TenantID != nil {
|
||||||
|
tenantID = *u.TenantID
|
||||||
|
}
|
||||||
row := []string{
|
row := []string{
|
||||||
u.ID,
|
|
||||||
u.Email,
|
u.Email,
|
||||||
u.Name,
|
u.Name,
|
||||||
u.Phone,
|
u.Phone,
|
||||||
@@ -902,6 +982,20 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
u.JobTitle,
|
u.JobTitle,
|
||||||
u.CreatedAt.Format(time.RFC3339),
|
u.CreatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
if includeIDs {
|
||||||
|
row = []string{
|
||||||
|
u.ID,
|
||||||
|
u.Email,
|
||||||
|
u.Name,
|
||||||
|
u.Phone,
|
||||||
|
u.Status,
|
||||||
|
tenantID,
|
||||||
|
u.CompanyCode,
|
||||||
|
u.Position,
|
||||||
|
u.JobTitle,
|
||||||
|
u.CreatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
// Append metadata values in order
|
// Append metadata values in order
|
||||||
for _, k := range metaKeys {
|
for _, k := range metaKeys {
|
||||||
val := ""
|
val := ""
|
||||||
@@ -918,6 +1012,11 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func csvMetadataKeyIsID(key string) bool {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||||
|
return normalized == "id" || normalized == "user_id" || normalized == "tenant_id" || normalized == "tenantid"
|
||||||
|
}
|
||||||
|
|
||||||
func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||||
var req struct {
|
var req struct {
|
||||||
UserIDs []string `json:"userIds"`
|
UserIDs []string `json:"userIds"`
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us
|
|||||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||||
|
args := m.Called(ctx, limit, offset, parentID)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Get(1).(int64), args.Error(2)
|
||||||
|
}
|
||||||
|
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||||
args := m.Called(ctx, domainName)
|
args := m.Called(ctx, domainName)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
@@ -167,20 +175,66 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
|
|||||||
},
|
},
|
||||||
}, int64(1), nil).Once()
|
}, int64(1), nil).Once()
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant", nil)
|
req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant&includeIds=true", nil)
|
||||||
resp, err := app.Test(req)
|
resp, err := app.Test(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
|
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
|
||||||
assert.Contains(t, body, "ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt")
|
assert.Contains(t, body, "user_id,Email,Name,Phone,Status,tenant_id,tenant_slug,Position,JobTitle,CreatedAt")
|
||||||
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,test-tenant")
|
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,,test-tenant")
|
||||||
assert.NotContains(t, body, "Role")
|
assert.NotContains(t, body, "Role")
|
||||||
assert.NotContains(t, body, "Department")
|
assert.NotContains(t, body, "Department")
|
||||||
mockRepo.AssertExpectations(t)
|
mockRepo.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockRepo := new(MockUserRepoForHandler)
|
||||||
|
h := &UserHandler{UserRepo: mockRepo}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
tenantID := "tenant-uuid"
|
||||||
|
mockRepo.On("List", mock.Anything, 0, 10000, "", "").
|
||||||
|
Return([]domain.User{
|
||||||
|
{
|
||||||
|
ID: "user-uuid",
|
||||||
|
Email: "user@test.com",
|
||||||
|
Name: "Test User",
|
||||||
|
Phone: "010-1111-2222",
|
||||||
|
Status: "active",
|
||||||
|
CompanyCode: "test-tenant",
|
||||||
|
TenantID: &tenantID,
|
||||||
|
Position: "책임",
|
||||||
|
JobTitle: "플랫폼 운영",
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
},
|
||||||
|
}, int64(1), nil).Once()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/users/export?includeIds=false", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
|
||||||
|
assert.Contains(t, body, "Email,Name,Phone,Status,tenant_slug,Position,JobTitle,CreatedAt")
|
||||||
|
assert.Contains(t, body, "user@test.com,Test User,010-1111-2222,active,test-tenant")
|
||||||
|
assert.NotContains(t, body, "user-uuid")
|
||||||
|
assert.NotContains(t, body, "tenant-uuid")
|
||||||
|
assert.NotContains(t, body, "ID,")
|
||||||
|
mockRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
@@ -355,6 +409,170 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
mockOry := new(MockOryProvider)
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
mockRepo := new(MockUserRepoForHandler)
|
||||||
|
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
OryProvider: mockOry,
|
||||||
|
TenantService: mockTenant,
|
||||||
|
UserRepo: mockRepo,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||||
|
|
||||||
|
rootID := "hanmac-family-id"
|
||||||
|
companyID := "hanmac-id"
|
||||||
|
tenants := []domain.Tenant{
|
||||||
|
{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
|
||||||
|
{ID: companyID, Slug: "hanmac", Name: "한맥기술", ParentID: &rootID},
|
||||||
|
{ID: "external-id", Slug: "external", Name: "외부사"},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("domain only email receives suggested final email with next suffix", func(t *testing.T) {
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "hanmac").Return(&domain.Tenant{
|
||||||
|
ID: companyID,
|
||||||
|
Slug: "hanmac",
|
||||||
|
}, nil).Once()
|
||||||
|
mockTenant.On("GetTenant", mock.Anything, companyID).Return(&domain.Tenant{
|
||||||
|
ID: companyID,
|
||||||
|
Slug: "hanmac",
|
||||||
|
}, nil).Maybe()
|
||||||
|
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||||
|
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{
|
||||||
|
{Email: "cyhan@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
|
||||||
|
{Email: "cyhan1@samaneng.com", CompanyCode: "hanmac", TenantID: &companyID},
|
||||||
|
}, nil).Once()
|
||||||
|
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once()
|
||||||
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
|
||||||
|
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||||
|
return user.Email == "cyhan2@hanmaceng.co.kr"
|
||||||
|
}), mock.Anything).Return("u-hanmac", nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"users": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"email": "@hanmaceng.co.kr",
|
||||||
|
"name": "한치영",
|
||||||
|
"tenantSlug": "hanmac",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
results := result["results"].([]interface{})
|
||||||
|
row := results[0].(map[string]interface{})
|
||||||
|
assert.True(t, row["success"].(bool))
|
||||||
|
assert.Equal(t, "cyhan2@hanmaceng.co.kr", row["email"])
|
||||||
|
assert.Equal(t, "@hanmaceng.co.kr", row["originalEmail"])
|
||||||
|
assert.Contains(t, row["warnings"].([]interface{}), "suggested")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("full email duplicate local part is blocking error", func(t *testing.T) {
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "hanmac").Return(&domain.Tenant{
|
||||||
|
ID: companyID,
|
||||||
|
Slug: "hanmac",
|
||||||
|
}, nil).Once()
|
||||||
|
mockTenant.On("GetTenant", mock.Anything, companyID).Return(&domain.Tenant{
|
||||||
|
ID: companyID,
|
||||||
|
Slug: "hanmac",
|
||||||
|
}, nil).Maybe()
|
||||||
|
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||||
|
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{
|
||||||
|
{Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
|
||||||
|
}, nil).Once()
|
||||||
|
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once()
|
||||||
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"users": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"email": "han@samaneng.com",
|
||||||
|
"name": "한치영",
|
||||||
|
"tenantSlug": "hanmac",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
results := result["results"].([]interface{})
|
||||||
|
row := results[0].(map[string]interface{})
|
||||||
|
assert.False(t, row["success"].(bool))
|
||||||
|
assert.Equal(t, "blockingError", row["status"])
|
||||||
|
assert.Contains(t, row["message"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
mockOry := new(MockOryProvider)
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
mockRepo := new(MockUserRepoForHandler)
|
||||||
|
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
OryProvider: mockOry,
|
||||||
|
TenantService: mockTenant,
|
||||||
|
UserRepo: mockRepo,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Post("/users", h.CreateUser)
|
||||||
|
|
||||||
|
rootID := "hanmac-family-id"
|
||||||
|
companyID := "hanmac-id"
|
||||||
|
tenants := []domain.Tenant{
|
||||||
|
{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
|
||||||
|
{ID: companyID, Slug: "hanmac", Name: "한맥기술", ParentID: &rootID},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "hanmac").Return(&domain.Tenant{
|
||||||
|
ID: companyID,
|
||||||
|
Slug: "hanmac",
|
||||||
|
}, nil).Once()
|
||||||
|
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||||
|
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{
|
||||||
|
{Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
|
||||||
|
}, nil).Once()
|
||||||
|
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"email": "han@samaneng.com",
|
||||||
|
"name": "한치영",
|
||||||
|
"companyCode": "hanmac",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.Contains(t, result["error"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
||||||
|
mockOry.AssertNotCalled(t, "CreateUser")
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-migrate
|
// Auto-migrate
|
||||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{})
|
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to migrate database: %s", err)
|
log.Fatalf("failed to migrate database: %s", err)
|
||||||
}
|
}
|
||||||
|
|||||||
40
backend/internal/repository/rp_user_metadata_repository.go
Normal file
40
backend/internal/repository/rp_user_metadata_repository.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RPUserMetadataRepository interface {
|
||||||
|
Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error)
|
||||||
|
Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpUserMetadataRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRPUserMetadataRepository(db *gorm.DB) RPUserMetadataRepository {
|
||||||
|
return &rpUserMetadataRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rpUserMetadataRepository) Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error) {
|
||||||
|
var metadata domain.RPUserMetadata
|
||||||
|
if err := r.db.WithContext(ctx).First(&metadata, "client_id = ? AND user_id = ?", clientID, userID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rpUserMetadataRepository) Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error {
|
||||||
|
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{
|
||||||
|
{Name: "client_id"},
|
||||||
|
{Name: "user_id"},
|
||||||
|
},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"metadata", "updated_at"}),
|
||||||
|
}).Create(metadata).Error
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -88,6 +89,20 @@ func (r *tenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domai
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error {
|
func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error {
|
||||||
|
var existing domain.TenantDomain
|
||||||
|
err := r.db.WithContext(ctx).Unscoped().
|
||||||
|
Where("tenant_id = ? AND domain = ?", tenantID, domainName).
|
||||||
|
First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
return r.db.WithContext(ctx).Unscoped().Model(&existing).Updates(map[string]any{
|
||||||
|
"verified": verified,
|
||||||
|
"deleted_at": nil,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
td := domain.TenantDomain{
|
td := domain.TenantDomain{
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
Domain: domainName,
|
Domain: domainName,
|
||||||
|
|||||||
@@ -60,6 +60,49 @@ func TestTenantRepository(t *testing.T) {
|
|||||||
assert.Equal(t, "test-domain.com", found.Domains[0].Domain)
|
assert.Equal(t, "test-domain.com", found.Domains[0].Domain)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("AddDomain allows same domain on multiple tenants", func(t *testing.T) {
|
||||||
|
first := &domain.Tenant{
|
||||||
|
Name: "Saman Existing",
|
||||||
|
Slug: "saman-existing",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
}
|
||||||
|
second := &domain.Tenant{
|
||||||
|
Name: "Saman Current",
|
||||||
|
Slug: "saman-current",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
}
|
||||||
|
assert.NoError(t, repo.Create(ctx, first))
|
||||||
|
assert.NoError(t, repo.Create(ctx, second))
|
||||||
|
|
||||||
|
assert.NoError(t, repo.AddDomain(ctx, first.ID, "samaneng.com", true))
|
||||||
|
assert.NoError(t, repo.AddDomain(ctx, second.ID, "samaneng.com", true))
|
||||||
|
|
||||||
|
var rows []domain.TenantDomain
|
||||||
|
err := testDB.Where("domain = ?", "samaneng.com").Find(&rows).Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, rows, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddDomain restores deleted tenant domain", func(t *testing.T) {
|
||||||
|
tenant := &domain.Tenant{
|
||||||
|
Name: "Domain Restore",
|
||||||
|
Slug: "domain-restore",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
}
|
||||||
|
assert.NoError(t, repo.Create(ctx, tenant))
|
||||||
|
assert.NoError(t, repo.AddDomain(ctx, tenant.ID, "restore.samaneng.com", true))
|
||||||
|
assert.NoError(t, testDB.Where("tenant_id = ? AND domain = ?", tenant.ID, "restore.samaneng.com").Delete(&domain.TenantDomain{}).Error)
|
||||||
|
|
||||||
|
assert.NoError(t, repo.AddDomain(ctx, tenant.ID, "restore.samaneng.com", true))
|
||||||
|
|
||||||
|
var rows []domain.TenantDomain
|
||||||
|
err := testDB.Where("tenant_id = ? AND domain = ?", tenant.ID, "restore.samaneng.com").Find(&rows).Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if assert.Len(t, rows, 1) {
|
||||||
|
assert.True(t, rows[0].Verified)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Update", func(t *testing.T) {
|
t.Run("Update", func(t *testing.T) {
|
||||||
tenant := &domain.Tenant{
|
tenant := &domain.Tenant{
|
||||||
Name: "Before Update",
|
Name: "Before Update",
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { defineConfig } from "vite";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
|
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
|
||||||
|
cacheDir:
|
||||||
|
process.env.ADMINFRONT_VITE_CACHE_DIR ??
|
||||||
|
"/tmp/baron-sso-adminfront-vite-cache",
|
||||||
server: {
|
server: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
// 인스턴스별 도메인을 자동으로 허용
|
// 인스턴스별 도메인을 자동으로 허용
|
||||||
|
|||||||
@@ -81,8 +81,11 @@ services:
|
|||||||
- DB_HOST=postgres
|
- DB_HOST=postgres
|
||||||
- REDIS_ADDR=redis:6379
|
- REDIS_ADDR=redis:6379
|
||||||
- CLICKHOUSE_HOST=clickhouse
|
- CLICKHOUSE_HOST=clickhouse
|
||||||
|
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT}:${BACKEND_PORT}"
|
- "${BACKEND_PORT}:${BACKEND_PORT}"
|
||||||
|
volumes:
|
||||||
|
- ../../adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
|
||||||
networks: [app_net]
|
networks: [app_net]
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ function ClientGeneralPage() {
|
|||||||
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
||||||
const [tenantSearch, setTenantSearch] = useState("");
|
const [tenantSearch, setTenantSearch] = useState("");
|
||||||
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
|
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
|
||||||
|
const [autoLoginSupported, setAutoLoginSupported] = useState(false);
|
||||||
|
const [autoLoginUrl, setAutoLoginUrl] = useState("");
|
||||||
|
|
||||||
// Public Key Registration States
|
// Public Key Registration States
|
||||||
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
||||||
@@ -203,6 +205,9 @@ function ClientGeneralPage() {
|
|||||||
if (typeof metadata.description === "string")
|
if (typeof metadata.description === "string")
|
||||||
setDescription(metadata.description);
|
setDescription(metadata.description);
|
||||||
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
|
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
|
||||||
|
setAutoLoginSupported(metadata.auto_login_supported === true);
|
||||||
|
if (typeof metadata.auto_login_url === "string")
|
||||||
|
setAutoLoginUrl(metadata.auto_login_url);
|
||||||
|
|
||||||
const headlessEnabled = !!metadata.headless_login_enabled;
|
const headlessEnabled = !!metadata.headless_login_enabled;
|
||||||
setHeadlessLoginEnabled(headlessEnabled);
|
setHeadlessLoginEnabled(headlessEnabled);
|
||||||
@@ -287,8 +292,12 @@ function ClientGeneralPage() {
|
|||||||
const securityProfile: SecurityProfile =
|
const securityProfile: SecurityProfile =
|
||||||
clientType === "pkce" ? "pkce" : "private";
|
clientType === "pkce" ? "pkce" : "private";
|
||||||
const trimmedLogoUrl = logoUrl.trim();
|
const trimmedLogoUrl = logoUrl.trim();
|
||||||
|
const trimmedAutoLoginUrl = autoLoginUrl.trim();
|
||||||
const hasLogoUrl = trimmedLogoUrl.length > 0;
|
const hasLogoUrl = trimmedLogoUrl.length > 0;
|
||||||
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
|
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
|
||||||
|
const hasValidAutoLoginUrl =
|
||||||
|
!autoLoginSupported ||
|
||||||
|
(trimmedAutoLoginUrl.length > 0 && isValidUrl(trimmedAutoLoginUrl));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasLogoUrl) {
|
if (!hasLogoUrl) {
|
||||||
@@ -523,6 +532,14 @@ function ClientGeneralPage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (autoLoginSupported && !hasValidAutoLoginUrl) {
|
||||||
|
validationErrors.push(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.general.auto_login.invalid_url",
|
||||||
|
"자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const hasValidationErrors = validationErrors.length > 0;
|
const hasValidationErrors = validationErrors.length > 0;
|
||||||
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
||||||
@@ -618,6 +635,14 @@ function ClientGeneralPage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (autoLoginSupported && !hasValidAutoLoginUrl) {
|
||||||
|
throw new Error(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.general.auto_login.invalid_url",
|
||||||
|
"자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedScopes = normalizeScopesForTenantAccess(
|
const normalizedScopes = normalizeScopesForTenantAccess(
|
||||||
scopes,
|
scopes,
|
||||||
@@ -648,6 +673,8 @@ function ClientGeneralPage() {
|
|||||||
metadata: {
|
metadata: {
|
||||||
description,
|
description,
|
||||||
logo_url: trimmedLogoUrl,
|
logo_url: trimmedLogoUrl,
|
||||||
|
auto_login_supported: autoLoginSupported,
|
||||||
|
auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined,
|
||||||
structured_scopes: normalizedScopes,
|
structured_scopes: normalizedScopes,
|
||||||
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
||||||
headless_login_enabled: headlessLoginEnabled,
|
headless_login_enabled: headlessLoginEnabled,
|
||||||
@@ -1057,6 +1084,84 @@ function ClientGeneralPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-xl font-bold">
|
||||||
|
{t("ui.dev.clients.general.auto_login.title", "자동 로그인")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.general.auto_login.subtitle",
|
||||||
|
"RP가 자체 로그인 시작 URL에서 OIDC 요청을 만들 수 있으면 userfront에서 바로 로그인 진입을 제공합니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||||
|
<div className="space-y-0.5 text-right">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{autoLoginSupported
|
||||||
|
? t("ui.common.enabled", "사용")
|
||||||
|
: t("ui.common.disabled", "사용 안 함")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.auto_login.supported",
|
||||||
|
"자동 로그인 지원",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoLoginSupported}
|
||||||
|
onCheckedChange={setAutoLoginSupported}
|
||||||
|
id="auto-login-supported"
|
||||||
|
aria-label={t(
|
||||||
|
"ui.dev.clients.general.auto_login.supported",
|
||||||
|
"자동 로그인 지원",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="auto-login-url" className="text-sm font-semibold">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.auto_login.url",
|
||||||
|
"자동 로그인 시작 URL",
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="auto-login-url"
|
||||||
|
value={autoLoginUrl}
|
||||||
|
onChange={(event) => setAutoLoginUrl(event.target.value)}
|
||||||
|
disabled={!autoLoginSupported}
|
||||||
|
aria-invalid={!hasValidAutoLoginUrl}
|
||||||
|
className={!hasValidAutoLoginUrl ? "border-destructive" : ""}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.dev.clients.general.auto_login.url_placeholder",
|
||||||
|
"https://app.example.com/login?auto=1",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.general.auto_login.help",
|
||||||
|
"이 URL은 RP가 state, nonce, PKCE 값을 직접 생성한 뒤 Baron OIDC로 리다이렉트해야 합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{!hasValidAutoLoginUrl ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.general.auto_login.invalid_url",
|
||||||
|
"자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 2. Scopes */}
|
{/* 2. Scopes */}
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||||
|
|||||||
@@ -273,6 +273,33 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
).toHaveValue(jwksUri);
|
).toHaveValue(jwksUri);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("auto login settings are stored in client metadata", async ({ page }) => {
|
||||||
|
const autoLoginUrl = "https://rp.example.com/login?auto=1";
|
||||||
|
const state = {
|
||||||
|
clients: [makeClient("client-auto-login", { name: "Auto Login app" })],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.goto("/clients/client-auto-login/settings");
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("switch", { name: /자동 로그인 지원|Auto Login/i })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(/https:\/\/app\.example\.com\/login\?auto=1/i)
|
||||||
|
.fill(autoLoginUrl);
|
||||||
|
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => state.clients[0]?.metadata?.auto_login_supported)
|
||||||
|
.toBe(true);
|
||||||
|
await expect
|
||||||
|
.poll(() => state.clients[0]?.metadata?.auto_login_url)
|
||||||
|
.toBe(autoLoginUrl);
|
||||||
|
});
|
||||||
|
|
||||||
test("pkce headless login blocks save when parsed jwks algorithm is unsupported", async ({
|
test("pkce headless login blocks save when parsed jwks algorithm is unsupported", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ services:
|
|||||||
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
||||||
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
||||||
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
|
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
|
||||||
|
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
|
||||||
depends_on:
|
depends_on:
|
||||||
- infra_check
|
- infra_check
|
||||||
networks:
|
networks:
|
||||||
@@ -35,6 +36,7 @@ services:
|
|||||||
- ory-net
|
- ory-net
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
|
||||||
command: ["go", "run", "./cmd/server"]
|
command: ["go", "run", "./cmd/server"]
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ services:
|
|||||||
- IDP_PROVIDER=ory
|
- IDP_PROVIDER=ory
|
||||||
- OATHKEEPER_API_URL=http://oathkeeper:4456
|
- OATHKEEPER_API_URL=http://oathkeeper:4456
|
||||||
- PROFILE_CACHE_TTL="${PROFILE_CACHE_TTL:-30m}"
|
- PROFILE_CACHE_TTL="${PROFILE_CACHE_TTL:-30m}"
|
||||||
|
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-3000}:3000"
|
- "${BACKEND_PORT:-3000}:3000"
|
||||||
|
volumes:
|
||||||
|
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
oathkeeper:
|
oathkeeper:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ services:
|
|||||||
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
||||||
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
|
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
|
||||||
- USERFRONT_URL=${USERFRONT_URL:-https://sso.hmac.kr}
|
- USERFRONT_URL=${USERFRONT_URL:-https://sso.hmac.kr}
|
||||||
|
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-3010}:3010"
|
- "${BACKEND_PORT:-3010}:3010"
|
||||||
|
volumes:
|
||||||
|
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- infra_check
|
- infra_check
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ services:
|
|||||||
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
||||||
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
||||||
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
|
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
|
||||||
|
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
|
||||||
depends_on:
|
depends_on:
|
||||||
clickhouse:
|
clickhouse:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -370,6 +371,7 @@ services:
|
|||||||
- ory-net
|
- ory-net
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
|
||||||
command: ["go", "run", "./cmd/server"]
|
command: ["go", "run", "./cmd/server"]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||||
|
|||||||
121
docs/custom-field-jsonb-index-policy.md
Normal file
121
docs/custom-field-jsonb-index-policy.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Custom Field JSONB 및 인덱스 정책
|
||||||
|
|
||||||
|
## 현재 구조
|
||||||
|
|
||||||
|
- Tenant custom schema는 `tenants.config.userSchema` JSONB에 저장한다.
|
||||||
|
- Tenant custom value는 backend DB의 `users.metadata` JSONB에 저장한다.
|
||||||
|
- `isLoginId=true`인 Tenant field 값은 로그인 식별자 처리를 위해 `user_login_ids`에도 동기화한다.
|
||||||
|
- Ory Kratos traits에는 인증/식별에 필요한 최소 값만 동기화하는 방향으로 정리한다.
|
||||||
|
- RP custom value는 backend DB의 `rp_user_metadata.metadata` JSONB에 별도 저장한다.
|
||||||
|
|
||||||
|
## Tenant Custom Field
|
||||||
|
|
||||||
|
Tenant schema field는 다음 속성을 기준으로 한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "employeeNo",
|
||||||
|
"label": "사번",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"indexed": true,
|
||||||
|
"isLoginId": true,
|
||||||
|
"adminOnly": false,
|
||||||
|
"validation": "^[A-Z0-9]+$"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `indexed=true`는 검색/필터 최적화 대상이라는 의미다.
|
||||||
|
- `isLoginId=true`이면 backend와 adminfront 모두 `indexed=true`를 강제한다.
|
||||||
|
- `isLoginId=true`는 값 필수를 의미하지 않는다. 값 필수 여부는 `required=true`로 별도 제어한다.
|
||||||
|
- `isLoginId=true`인 field는 `type=text`만 허용한다.
|
||||||
|
- JSONB 통합 정책에서는 `varchar` 크기 지정 의미를 두지 않는다.
|
||||||
|
|
||||||
|
## RP Custom Field
|
||||||
|
|
||||||
|
RP custom schema는 client metadata의 `customUserSchema`에 저장한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customUserSchema": [
|
||||||
|
{
|
||||||
|
"key": "approvalLevel",
|
||||||
|
"label": "승인 등급",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"indexed": true,
|
||||||
|
"claimEnabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
RP custom value는 `rp_user_metadata` 테이블에 저장한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
client_id text
|
||||||
|
user_id uuid
|
||||||
|
metadata jsonb
|
||||||
|
created_at timestamptz
|
||||||
|
updated_at timestamptz
|
||||||
|
primary key (client_id, user_id)
|
||||||
|
foreign key (user_id) references users(id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend API 초안:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/dev/clients/:id/users/:userId/metadata
|
||||||
|
PUT /api/v1/dev/clients/:id/users/:userId/metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
PUT payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"approvalLevel": "A",
|
||||||
|
"preferences": {
|
||||||
|
"theme": "dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 검색 및 인덱스
|
||||||
|
|
||||||
|
- `indexed=true` field만 검색 UI/API 후보로 노출한다.
|
||||||
|
- 기본 검색은 exact match, exists, JSON containment 중심으로 제한한다.
|
||||||
|
- RP custom field의 LIKE/fuzzy 검색은 기본 제공하지 않는다.
|
||||||
|
- GIN 인덱스는 backend index manager가 별도 상태로 관리하는 방향을 원칙으로 한다.
|
||||||
|
- API 요청 처리 중 `CREATE INDEX`를 동기 실행하지 않는다.
|
||||||
|
|
||||||
|
## Claim Projection
|
||||||
|
|
||||||
|
JWT 또는 userinfo 응답에서는 custom field를 top-level에 풀지 않는다.
|
||||||
|
Tenant/RP 단위로 묶어서 전달한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenant_profiles": [
|
||||||
|
{
|
||||||
|
"tenant_id": "tenant-uuid",
|
||||||
|
"tenant_slug": "hanmac-family",
|
||||||
|
"fields": {
|
||||||
|
"employeeNo": "E1001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rp_profiles": [
|
||||||
|
{
|
||||||
|
"client_id": "sample-rp",
|
||||||
|
"fields": {
|
||||||
|
"approvalLevel": "A"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `claimEnabled=true` field만 RP claim 후보로 포함한다.
|
||||||
|
- 긴 JSON 값은 기본적으로 token claim보다 userinfo/profile API 응답에 싣는 방향을 우선한다.
|
||||||
127
docs/rp-auto-login-guide.md
Normal file
127
docs/rp-auto-login-guide.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# RP 자동 로그인 지원 가이드
|
||||||
|
|
||||||
|
이 문서는 Baron SSO의 userfront 연동 앱에서 RP를 클릭했을 때 별도 로그인 버튼 클릭 없이 OIDC 인증을 시작하려는 RP 등록자와 RP 개발자를 위한 기준입니다.
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
자동 로그인은 userfront가 RP의 자체 로그인 시작 URL로 사용자를 보내고, RP가 그 진입점에서 OIDC Authorization Code + PKCE 흐름을 직접 시작하는 방식입니다.
|
||||||
|
|
||||||
|
Baron backend가 `/oauth2/auth?...` URL을 대신 만들어 넘기지 않는 이유는 RP가 직접 `state`, `nonce`, PKCE verifier/challenge, callback 검증 상태를 관리해야 하기 때문입니다. SPA나 모바일 웹앱은 이 상태가 RP 저장소에 있어야 callback을 안전하게 처리할 수 있습니다.
|
||||||
|
|
||||||
|
## 등록 메타데이터
|
||||||
|
|
||||||
|
RP는 Hydra client metadata에 다음 값을 저장합니다.
|
||||||
|
|
||||||
|
| 키 | 타입 | 설명 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `auto_login_supported` | boolean | 자동 로그인 지원 여부입니다. `true`일 때만 userfront가 자동 로그인 URL을 진입 URL로 사용합니다. |
|
||||||
|
| `auto_login_url` | string | RP가 OIDC 로그인을 시작하는 URL입니다. `http` 또는 `https` URL이어야 합니다. |
|
||||||
|
|
||||||
|
devfront의 RP 일반 설정에서 다음 항목을 입력합니다.
|
||||||
|
|
||||||
|
1. `자동 로그인 지원`을 켭니다.
|
||||||
|
2. `자동 로그인 시작 URL`에 RP 로그인 시작 URL을 입력합니다.
|
||||||
|
3. Redirect URI 목록에는 RP callback URL을 등록합니다.
|
||||||
|
4. 저장 후 userfront 연동 앱 카드에서 “연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다.” 안내가 보이는지 확인합니다.
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
```text
|
||||||
|
auto_login_supported: true
|
||||||
|
auto_login_url: https://org.example.com/login?auto=1
|
||||||
|
redirect_uri: https://org.example.com/auth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
## RP 구현 요구사항
|
||||||
|
|
||||||
|
RP는 `auto_login_url`에서 다음 동작을 구현해야 합니다.
|
||||||
|
|
||||||
|
1. `auto=1` 쿼리를 읽습니다.
|
||||||
|
2. 이미 RP 세션이 있으면 기본 화면 또는 `returnTo` 경로로 이동합니다.
|
||||||
|
3. RP 세션이 없고 `auto=1`이면 로그인 버튼을 기다리지 않고 OIDC authorization 요청을 시작합니다.
|
||||||
|
4. OIDC 요청 전에 `state`, `nonce`, PKCE `code_verifier`, `code_challenge`를 생성합니다.
|
||||||
|
5. `state`, `nonce`, `code_verifier`, `returnTo`는 RP origin의 안전한 저장소에 보관합니다.
|
||||||
|
6. callback에서 `state`를 검증하고, token 교환 시 `code_verifier`를 사용합니다.
|
||||||
|
7. 인증 완료 후 저장된 `returnTo`가 있으면 해당 경로로 이동합니다.
|
||||||
|
|
||||||
|
권장 URL 형식:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://rp.example.com/login?auto=1
|
||||||
|
https://rp.example.com/login?auto=1&returnTo=%2Fdashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Baron 내장 RP 기준
|
||||||
|
|
||||||
|
Baron 계열 RP는 다음 fallback을 사용합니다. env URL이 설정되어 있을 때만 자동 로그인 지원으로 간주합니다.
|
||||||
|
|
||||||
|
| Client ID | Env | 자동 로그인 URL |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `adminfront` | `ADMINFRONT_URL` | `${ADMINFRONT_URL}/login?auto=1` |
|
||||||
|
| `devfront` | `DEVFRONT_URL` | `${DEVFRONT_URL}/login?auto=1&returnTo=%2Fclients` |
|
||||||
|
| `orgfront` | `ORGFRONT_URL` | `${ORGFRONT_URL}/login?auto=1` |
|
||||||
|
|
||||||
|
orgfront는 `/login?auto=1` 진입 시 OIDC authorize 요청을 즉시 시작하며, 기본 callback 이후 이동 경로는 `/chart`입니다.
|
||||||
|
|
||||||
|
## userfront 동작
|
||||||
|
|
||||||
|
userfront는 backend의 linked RP 응답을 기준으로 진입 URL을 선택합니다.
|
||||||
|
|
||||||
|
1. `status`가 active가 아니면 진입 URL을 만들지 않습니다.
|
||||||
|
2. `auto_login_supported=true`이면 `init_url`을 우선 사용합니다.
|
||||||
|
3. `init_url`이 없으면 `auto_login_url`을 사용합니다.
|
||||||
|
4. 자동 로그인 미지원이면 `init_url`이 있어도 기존 `url`로 이동합니다.
|
||||||
|
|
||||||
|
이 기준 때문에 `auto_login_supported=false`인 RP는 accidental auto-login을 수행하지 않습니다.
|
||||||
|
|
||||||
|
## 검증 체크리스트
|
||||||
|
|
||||||
|
RP 등록자는 다음을 확인해야 합니다.
|
||||||
|
|
||||||
|
1. devfront에서 `자동 로그인 지원`이 켜져 있습니다.
|
||||||
|
2. `자동 로그인 시작 URL`이 실제 RP 로그인 진입점입니다.
|
||||||
|
3. `auto_login_url`에 직접 접속하면 로그인 버튼 클릭 없이 Baron OIDC authorize 요청이 시작됩니다.
|
||||||
|
4. callback URL이 Redirect URI에 등록되어 있습니다.
|
||||||
|
5. callback 이후 RP가 `state`, `nonce`, PKCE 검증을 통과합니다.
|
||||||
|
6. userfront 연동 앱 카드에 자동 로그인 안내 문구가 표시됩니다.
|
||||||
|
7. userfront에서 RP 카드를 클릭하면 RP 로그인 화면에 머물지 않고 OIDC 흐름으로 진입합니다.
|
||||||
|
|
||||||
|
orgfront 기준 검증 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/orgfront-auto-login.spec.ts --project=chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
## 실패 시 확인할 항목
|
||||||
|
|
||||||
|
| 증상 | 확인 항목 |
|
||||||
|
| --- | --- |
|
||||||
|
| userfront에서 일반 URL로만 이동함 | RP metadata의 `auto_login_supported`가 `true`인지 확인합니다. |
|
||||||
|
| userfront 카드에 안내 문구가 없음 | backend `/api/v1/user/rp/linked` 응답에 `auto_login_supported=true`가 내려오는지 확인합니다. |
|
||||||
|
| RP 로그인 화면에 머무름 | RP가 `auto=1` 쿼리를 읽어 자동으로 `signinRedirect` 또는 동일한 OIDC 시작 함수를 호출하는지 확인합니다. |
|
||||||
|
| callback에서 state 오류 발생 | userfront나 backend가 만든 `/oauth2/auth?...` URL을 직접 쓰지 말고 RP 자체 로그인 시작 URL에서 OIDC 요청을 생성해야 합니다. |
|
||||||
|
| 등록 저장이 실패함 | `auto_login_supported=true`일 때 `auto_login_url`이 비어 있거나 `http/https` URL이 아닌지 확인합니다. |
|
||||||
|
|
||||||
|
## 구현 예시
|
||||||
|
|
||||||
|
React RP 예시입니다. 실제 프로젝트에서는 사용하는 OIDC client 라이브러리의 API에 맞춰 적용합니다.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const returnTo = searchParams.get("returnTo") || "/";
|
||||||
|
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
navigate(returnTo, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shouldAutoLogin || auth.isLoading || auth.activeNavigator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void auth.signinRedirect({
|
||||||
|
state: { returnTo },
|
||||||
|
});
|
||||||
|
}, [auth, navigate, returnTo, shouldAutoLogin]);
|
||||||
|
```
|
||||||
|
|
||||||
|
중요한 점은 `signinRedirect`가 RP에서 실행되어야 한다는 것입니다. 그래야 RP가 callback 검증에 필요한 상태를 보유할 수 있습니다.
|
||||||
@@ -19,8 +19,8 @@ fi
|
|||||||
|
|
||||||
if [ "$mode" = "production" ]; then
|
if [ "$mode" = "production" ]; then
|
||||||
echo "Running in production mode with Vite preview..."
|
echo "Running in production mode with Vite preview..."
|
||||||
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
|
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0 --port 5175"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Running in development mode..."
|
echo "Running in development mode..."
|
||||||
exec npm run dev -- --host 0.0.0.0
|
exec npm run dev -- --host 0.0.0.0 --port 5175
|
||||||
|
|||||||
43
orgfront/tests/orgfront-auto-login.spec.ts
Normal file
43
orgfront/tests/orgfront-auto-login.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("orgfront login auto parameter starts OIDC authorization", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let authorizationURL = "";
|
||||||
|
|
||||||
|
await page.route(
|
||||||
|
"http://localhost:5000/oidc/.well-known/openid-configuration",
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
issuer: "http://localhost:5000/oidc",
|
||||||
|
authorization_endpoint: "http://localhost:5000/oidc/oauth2/auth",
|
||||||
|
token_endpoint: "http://localhost:5000/oidc/oauth2/token",
|
||||||
|
jwks_uri: "http://localhost:5000/oidc/.well-known/jwks.json",
|
||||||
|
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
|
||||||
|
},
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route("http://localhost:5000/oidc/oauth2/auth**", async (route) => {
|
||||||
|
authorizationURL = route.request().url();
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: "text/html",
|
||||||
|
body: "<!doctype html><title>Authorization captured</title>",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/login?auto=1&returnTo=%2Fpicker");
|
||||||
|
|
||||||
|
await expect.poll(() => authorizationURL).toContain("/oauth2/auth");
|
||||||
|
|
||||||
|
const parsed = new URL(authorizationURL);
|
||||||
|
expect(parsed.searchParams.get("client_id")).toBe("orgfront");
|
||||||
|
expect(parsed.searchParams.get("redirect_uri")).toBe(
|
||||||
|
"http://localhost:5175/auth/callback",
|
||||||
|
);
|
||||||
|
expect(parsed.searchParams.get("response_type")).toBe("code");
|
||||||
|
expect(parsed.searchParams.get("scope") ?? "").toContain("openid");
|
||||||
|
});
|
||||||
@@ -20,4 +20,10 @@ do
|
|||||||
assert_contains "$workflow" 'BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}'
|
assert_contains "$workflow" 'BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}'
|
||||||
done
|
done
|
||||||
|
|
||||||
|
assert_contains ".gitea/workflows/staging_release.yml" "scp adminfront/seed-tenant.csv"
|
||||||
|
assert_contains "docker/docker-compose.staging.template.yaml" "SEED_TENANT_CSV_PATH=/app/seed-tenant.csv"
|
||||||
|
assert_contains "docker/docker-compose.staging.template.yaml" "./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro"
|
||||||
|
assert_contains "docker/staging_pull_compose.template.yaml" "SEED_TENANT_CSV_PATH=/app/seed-tenant.csv"
|
||||||
|
assert_contains "docker/staging_pull_compose.template.yaml" "./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro"
|
||||||
|
|
||||||
echo "staging workflow env checks passed"
|
echo "staging workflow env checks passed"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ CODE_CHECK="$ROOT_DIR/.gitea/workflows/code_check.yml"
|
|||||||
STAGING_RELEASE="$ROOT_DIR/.gitea/workflows/staging_release.yml"
|
STAGING_RELEASE="$ROOT_DIR/.gitea/workflows/staging_release.yml"
|
||||||
STAGING_PULL="$ROOT_DIR/.gitea/workflows/staging_code_pull.yml"
|
STAGING_PULL="$ROOT_DIR/.gitea/workflows/staging_code_pull.yml"
|
||||||
ORGFRONT_VITE="$ROOT_DIR/orgfront/vite.config.ts"
|
ORGFRONT_VITE="$ROOT_DIR/orgfront/vite.config.ts"
|
||||||
|
ORGFRONT_RUNTIME="$ROOT_DIR/orgfront/scripts/runtime-mode.sh"
|
||||||
|
|
||||||
for file in \
|
for file in \
|
||||||
"$LOCAL_COMPOSE" \
|
"$LOCAL_COMPOSE" \
|
||||||
@@ -40,7 +41,8 @@ for file in \
|
|||||||
"$CODE_CHECK" \
|
"$CODE_CHECK" \
|
||||||
"$STAGING_RELEASE" \
|
"$STAGING_RELEASE" \
|
||||||
"$STAGING_PULL" \
|
"$STAGING_PULL" \
|
||||||
"$ORGFRONT_VITE"
|
"$ORGFRONT_VITE" \
|
||||||
|
"$ORGFRONT_RUNTIME"
|
||||||
do
|
do
|
||||||
if [[ ! -f "$file" ]]; then
|
if [[ ! -f "$file" ]]; then
|
||||||
echo "ERROR: expected file not found: $file" >&2
|
echo "ERROR: expected file not found: $file" >&2
|
||||||
@@ -90,6 +92,8 @@ assert_not_contains "$STAGING_PULL" "VITE_ORGCHART_URL="
|
|||||||
|
|
||||||
assert_contains "$ORGFRONT_VITE" "baron-orgchart.hmac.kr"
|
assert_contains "$ORGFRONT_VITE" "baron-orgchart.hmac.kr"
|
||||||
assert_not_contains "$ORGFRONT_VITE" "VITE_ORGCHART_URL"
|
assert_not_contains "$ORGFRONT_VITE" "VITE_ORGCHART_URL"
|
||||||
|
assert_contains "$ORGFRONT_RUNTIME" "npm run dev -- --host 0.0.0.0 --port 5175"
|
||||||
|
assert_contains "$ORGFRONT_RUNTIME" "npm run preview -- --host 0.0.0.0 --port 5175"
|
||||||
assert_contains "$ROOT_DIR/adminfront/vite.config.ts" 'envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"]'
|
assert_contains "$ROOT_DIR/adminfront/vite.config.ts" 'envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"]'
|
||||||
assert_contains "$ROOT_DIR/deploy/templates/adminfront/vite.config.ts" 'envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"]'
|
assert_contains "$ROOT_DIR/deploy/templates/adminfront/vite.config.ts" 'envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"]'
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final initUrl = rp.initUrl.trim();
|
if (rp.autoLoginSupported) {
|
||||||
if (initUrl.isNotEmpty) {
|
final initUrl = rp.initUrl.trim();
|
||||||
return initUrl;
|
if (initUrl.isNotEmpty) {
|
||||||
|
return initUrl;
|
||||||
|
}
|
||||||
|
final autoLoginUrl = rp.autoLoginUrl.trim();
|
||||||
|
if (autoLoginUrl.isNotEmpty) {
|
||||||
|
return autoLoginUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final url = rp.url.trim();
|
final url = rp.url.trim();
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ class LinkedRp {
|
|||||||
final String logo;
|
final String logo;
|
||||||
final String url;
|
final String url;
|
||||||
final String initUrl;
|
final String initUrl;
|
||||||
|
final bool autoLoginSupported;
|
||||||
|
final String autoLoginUrl;
|
||||||
final String status;
|
final String status;
|
||||||
final List<String> scopes;
|
final List<String> scopes;
|
||||||
final DateTime? lastAuthenticatedAt;
|
final DateTime? lastAuthenticatedAt;
|
||||||
@@ -107,6 +109,8 @@ class LinkedRp {
|
|||||||
required this.logo,
|
required this.logo,
|
||||||
required this.url,
|
required this.url,
|
||||||
required this.initUrl,
|
required this.initUrl,
|
||||||
|
required this.autoLoginSupported,
|
||||||
|
required this.autoLoginUrl,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.scopes,
|
required this.scopes,
|
||||||
this.lastAuthenticatedAt,
|
this.lastAuthenticatedAt,
|
||||||
@@ -129,6 +133,8 @@ class LinkedRp {
|
|||||||
logo: json['logo']?.toString() ?? '',
|
logo: json['logo']?.toString() ?? '',
|
||||||
url: json['url']?.toString() ?? '',
|
url: json['url']?.toString() ?? '',
|
||||||
initUrl: json['init_url']?.toString() ?? '',
|
initUrl: json['init_url']?.toString() ?? '',
|
||||||
|
autoLoginSupported: json['auto_login_supported'] == true,
|
||||||
|
autoLoginUrl: json['auto_login_url']?.toString() ?? '',
|
||||||
status: json['status']?.toString() ?? '',
|
status: json['status']?.toString() ?? '',
|
||||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||||
lastAuthenticatedAt: parsedLastAuth,
|
lastAuthenticatedAt: parsedLastAuth,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class LinkedRp {
|
|||||||
final String logo;
|
final String logo;
|
||||||
final String url;
|
final String url;
|
||||||
final String initUrl;
|
final String initUrl;
|
||||||
|
final bool autoLoginSupported;
|
||||||
|
final String autoLoginUrl;
|
||||||
final String status;
|
final String status;
|
||||||
final List<String> scopes;
|
final List<String> scopes;
|
||||||
final DateTime? lastAuthenticatedAt;
|
final DateTime? lastAuthenticatedAt;
|
||||||
@@ -21,6 +23,8 @@ class LinkedRp {
|
|||||||
required this.logo,
|
required this.logo,
|
||||||
required this.url,
|
required this.url,
|
||||||
required this.initUrl,
|
required this.initUrl,
|
||||||
|
required this.autoLoginSupported,
|
||||||
|
required this.autoLoginUrl,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.scopes,
|
required this.scopes,
|
||||||
required this.lastAuthenticatedAt,
|
required this.lastAuthenticatedAt,
|
||||||
@@ -43,6 +47,8 @@ class LinkedRp {
|
|||||||
logo: json['logo']?.toString() ?? '',
|
logo: json['logo']?.toString() ?? '',
|
||||||
url: json['url']?.toString() ?? '',
|
url: json['url']?.toString() ?? '',
|
||||||
initUrl: json['init_url']?.toString() ?? '',
|
initUrl: json['init_url']?.toString() ?? '',
|
||||||
|
autoLoginSupported: json['auto_login_supported'] == true,
|
||||||
|
autoLoginUrl: json['auto_login_url']?.toString() ?? '',
|
||||||
status: json['status']?.toString() ?? 'unknown',
|
status: json['status']?.toString() ?? 'unknown',
|
||||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||||
lastAuthenticatedAt: parsedLastAuth,
|
lastAuthenticatedAt: parsedLastAuth,
|
||||||
|
|||||||
@@ -1247,6 +1247,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
|
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
|
||||||
url: rp.url,
|
url: rp.url,
|
||||||
launchUrl: resolveLinkedRpLaunchUrl(rp),
|
launchUrl: resolveLinkedRpLaunchUrl(rp),
|
||||||
|
autoLoginSupported: rp.autoLoginSupported,
|
||||||
lastAuthDateTime: rp.lastAuthenticatedAt,
|
lastAuthDateTime: rp.lastAuthenticatedAt,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1393,6 +1394,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
color: _ink,
|
color: _ink,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (item.autoLoginSupported) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
tr(
|
||||||
|
'msg.userfront.dashboard.auto_login_supported',
|
||||||
|
fallback: '연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다.',
|
||||||
|
),
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.green[700]),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -2421,6 +2432,7 @@ class _ActivityItem {
|
|||||||
final String status;
|
final String status;
|
||||||
final String? url;
|
final String? url;
|
||||||
final String? launchUrl;
|
final String? launchUrl;
|
||||||
|
final bool autoLoginSupported;
|
||||||
final List<String> scopes;
|
final List<String> scopes;
|
||||||
final bool isRevoked;
|
final bool isRevoked;
|
||||||
final VoidCallback? onRevoke;
|
final VoidCallback? onRevoke;
|
||||||
@@ -2435,6 +2447,7 @@ class _ActivityItem {
|
|||||||
required this.scopes,
|
required this.scopes,
|
||||||
this.url,
|
this.url,
|
||||||
this.launchUrl,
|
this.launchUrl,
|
||||||
|
this.autoLoginSupported = false,
|
||||||
this.isRevoked = false,
|
this.isRevoked = false,
|
||||||
this.onRevoke,
|
this.onRevoke,
|
||||||
this.lastAuthDateTime,
|
this.lastAuthDateTime,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ LinkedRp _linkedRp({
|
|||||||
required String status,
|
required String status,
|
||||||
String url = '',
|
String url = '',
|
||||||
String initUrl = '',
|
String initUrl = '',
|
||||||
|
bool autoLoginSupported = false,
|
||||||
|
String autoLoginUrl = '',
|
||||||
}) {
|
}) {
|
||||||
return LinkedRp(
|
return LinkedRp(
|
||||||
id: 'client-1',
|
id: 'client-1',
|
||||||
@@ -13,6 +15,8 @@ LinkedRp _linkedRp({
|
|||||||
logo: '',
|
logo: '',
|
||||||
url: url,
|
url: url,
|
||||||
initUrl: initUrl,
|
initUrl: initUrl,
|
||||||
|
autoLoginSupported: autoLoginSupported,
|
||||||
|
autoLoginUrl: autoLoginUrl,
|
||||||
status: status,
|
status: status,
|
||||||
scopes: const ['openid', 'profile'],
|
scopes: const ['openid', 'profile'],
|
||||||
lastAuthenticatedAt: null,
|
lastAuthenticatedAt: null,
|
||||||
@@ -27,20 +31,25 @@ void main() {
|
|||||||
'status': 'active',
|
'status': 'active',
|
||||||
'url': 'https://example.com',
|
'url': 'https://example.com',
|
||||||
'init_url': 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
'init_url': 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||||
|
'auto_login_supported': true,
|
||||||
|
'auto_login_url': 'https://example.com/login?auto=1',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
rp.initUrl,
|
rp.initUrl,
|
||||||
'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||||
);
|
);
|
||||||
|
expect(rp.autoLoginSupported, isTrue);
|
||||||
|
expect(rp.autoLoginUrl, 'https://example.com/login?auto=1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('활성 앱은 initUrl을 우선 진입 URL로 사용한다', () {
|
test('자동 로그인 지원 앱은 initUrl을 우선 진입 URL로 사용한다', () {
|
||||||
final launchUrl = resolveLinkedRpLaunchUrl(
|
final launchUrl = resolveLinkedRpLaunchUrl(
|
||||||
_linkedRp(
|
_linkedRp(
|
||||||
status: 'active',
|
status: 'active',
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||||
|
autoLoginSupported: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -50,7 +59,20 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('활성 앱은 initUrl이 없으면 기존 url로 폴백한다', () {
|
test('자동 로그인 미지원 앱은 initUrl이 있어도 기존 url로 폴백한다', () {
|
||||||
|
final launchUrl = resolveLinkedRpLaunchUrl(
|
||||||
|
_linkedRp(
|
||||||
|
status: 'active',
|
||||||
|
url: 'https://example.com',
|
||||||
|
initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||||
|
autoLoginSupported: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(launchUrl, 'https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('활성 앱은 자동 로그인 URL이 없으면 기존 url로 폴백한다', () {
|
||||||
final launchUrl = resolveLinkedRpLaunchUrl(
|
final launchUrl = resolveLinkedRpLaunchUrl(
|
||||||
_linkedRp(status: 'active', url: 'https://example.com'),
|
_linkedRp(status: 'active', url: 'https://example.com'),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user