1
0
forked from baron/baron-sso

Implement tenant import and RP auto login policies

This commit is contained in:
2026-04-30 15:45:34 +09:00
parent 24807eab0f
commit f7e4d43b16
76 changed files with 5307 additions and 441 deletions

View File

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

View File

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

View File

@@ -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 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).

View File

@@ -0,0 +1,3 @@
name,type,parent_tenant_slug,slug,memo,email_domain
한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,
Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,
1 name type parent_tenant_slug slug memo email_domain
2 한맥가족 COMPANY_GROUP hanmac-family 한맥가족 기본 루트 테넌트
3 Personal PERSONAL personal 개인 사용자 기본 루트 테넌트

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
);
});
});

View 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} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", "등록 시작")}

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

View File

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

View File

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

View File

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

View 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, "\\$&");
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -230,4 +230,5 @@ test.describe("User Schema Dynamic Form", () => {
.first(); .first();
await expect(errorMsg).toBeVisible(); await expect(errorMsg).toBeVisible();
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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",
// 인스턴스별 도메인을 자동으로 허용 // 인스턴스별 도메인을 자동으로 허용

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View 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 검증에 필요한 상태를 보유할 수 있습니다.

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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