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 "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}'"
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}/adminfront'"
# Create the main .env file for Baron SSO on the remote server
# Note: All values are pulled from Gitea secrets and variables
@@ -122,6 +122,7 @@ jobs:
> .env
# Copy compose template and .env file to the remote server
scp adminfront/seed-tenant.csv "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/adminfront/"
scp docker/docker-compose.template.yaml .env "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/"
scp docker/compose.infra.prd.yaml "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/compose.infra.yml"

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}/adminfront"
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
@@ -154,6 +155,7 @@ jobs:
scp -r gateway "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
fi
scp adminfront/seed-tenant.csv "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/adminfront/"
scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml"
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"

View File

@@ -95,6 +95,82 @@ flowchart
- RP 등록 및 관리
- RP별 Consent 관리
## 관리 데이터 Export/Import 정책
AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접 검토하고 재반입할 수 있는 흐름을 기준으로 설계합니다. 기본 원칙은 내부 UUID를 불필요하게 노출하지 않고, 사람이 이해하기 쉬운 `slug`와 이름을 우선 사용하는 것입니다.
### 공통 원칙
- CSV는 Excel 호환을 위해 UTF-8 BOM을 포함해 내려받습니다.
- 기본 export는 시스템 내부 ID를 제외합니다.
- 같은 데이터를 정확히 재동기화해야 하는 운영 작업에서는 `includeIds=true` 옵션으로 내부 ID 컬럼을 포함할 수 있습니다.
- import는 preview/검토 단계를 거친 뒤 실행하는 것을 기본으로 합니다.
- 기존 데이터와 충돌 가능성이 있는 row는 자동 적용하지 않고 관리자 선택 또는 확인 상태로 표시합니다.
- 삭제는 export/import로 암묵 처리하지 않습니다. 삭제가 필요하면 별도 삭제 기능을 사용합니다.
### Tenant Export
- 기본 컬럼은 운영자가 다시 import하기 쉬운 형태를 유지합니다.
- `includeIds=false`가 기본이며, 이 경우 내부 `tenant_id`는 제외합니다.
- `includeIds=true`를 사용하면 기존 테넌트 update 또는 staging/production 간 매핑 확인에 필요한 ID를 포함합니다.
- 주요 의미:
- `tenant_id`: 내부 UUID. 기본 export에서는 제외됩니다.
- `name`: 테넌트 표시 이름입니다.
- `type`: `PERSONAL`, `COMPANY`, `COMPANY_GROUP`, `USER_GROUP` 중 하나입니다.
- `parent_tenant_id`: 상위 테넌트 내부 ID입니다.
- `parent_tenant_slug`: 상위 테넌트를 slug로 연결할 때 사용합니다.
- `slug`: 운영상 사람이 다루는 테넌트 식별자입니다.
- `memo`: 설명 또는 비고입니다.
- `email_domain`: 테넌트에 연결된 이메일 도메인입니다. 여러 도메인은 `;`, `,`, 줄바꿈으로 구분할 수 있습니다.
### Tenant Import
- 필수 컬럼은 `name`, `type`, `slug`입니다.
- 허용되는 header alias:
- `tenant_id`: `id`, `tenantid`, `tenant_id`
- `parent_tenant_id`: `parentid`, `parent_id`, `parenttenantid`, `parent_tenant_id`
- `parent_tenant_slug`: `parenttenantslug`, `parent_tenant_slug`
- `memo`: `memo`, `description`
- `email_domain`: `email-domain`, `emaildomain`, `email_domain`, `domain`, `domains`
- `tenant_id`가 있고 기존 테넌트가 있으면 update 대상으로 봅니다.
- `tenant_id`가 없으면 `slug` 기준으로 기존 테넌트를 찾고, 없으면 신규 생성 후보로 봅니다.
- `parent_tenant_slug`가 같은 import 파일 안에 있으면 부모 row를 먼저 처리하도록 정렬합니다.
- import preview는 이름/slug 유사도 기반 후보를 보여주며, 관리자가 기존 테넌트 사용, 신규 생성, skip 중 선택할 수 있어야 합니다.
- 외부 시스템에서 가져온 `tenant_id`처럼 현재 DB에 없는 ID는 충돌로 표시하고, 관리자가 새 slug 또는 기존 테넌트 매핑을 결정해야 합니다.
### User Export
- 기본 컬럼은 `Email`, `Name`, `Phone`, `Status`, `tenant_slug`, `Position`, `JobTitle`, `CreatedAt`입니다.
- `includeIds=true`이면 `user_id`, `tenant_id`를 함께 포함합니다.
- 사용자 role은 export 기본 컬럼에서 제외합니다. role은 일괄 변경의 실수 위험이 크므로 명시적 관리 화면 또는 별도 정책으로 다룹니다.
- 사용자 metadata는 `Meta:<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)
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 { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -17,6 +16,11 @@ import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { createTenant, fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import {
formatDomainConflictMessage,
type ServerDomainConflict,
} from "../utils/domainTags";
function TenantCreatePage() {
const navigate = useNavigate();
@@ -26,7 +30,10 @@ function TenantCreatePage() {
const [parentId, setParentId] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
const [domains, setDomains] = useState<string[]>([]);
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
[],
);
const parentQuery = useQuery({
queryKey: ["tenants", { limit: 1000 }],
@@ -34,7 +41,7 @@ function TenantCreatePage() {
});
const mutation = useMutation({
mutationFn: () =>
mutationFn: (overrideForceDomains?: string[]) =>
createTenant({
name,
type,
@@ -42,14 +49,34 @@ function TenantCreatePage() {
parentId: parentId || undefined,
description: description || undefined,
status,
domains: domains
.split(",")
.map((d) => d.trim())
.filter((d) => d !== ""),
domains,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
}),
onSuccess: () => {
navigate("/tenants");
},
onError: (
err: AxiosError<{
code?: string;
error?: string;
conflicts?: ServerDomainConflict[];
}>,
) => {
const conflicts = err.response?.data?.conflicts ?? [];
if (
err.response?.data?.code === "tenant_domain_conflict" &&
conflicts.length > 0
) {
const nextForceDomains = Array.from(
new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]),
);
const message = conflicts.map(formatDomainConflictMessage).join("\n");
if (window.confirm(message)) {
setForceDomainConflicts(nextForceDomains);
mutation.mutate(nextForceDomains);
}
}
},
});
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
@@ -195,11 +222,13 @@ function TenantCreatePage() {
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<Input
<DomainTagInput
id="tenant-domains"
name="domains"
value={domains}
onChange={(e) => setDomains(e.target.value)}
onChange={setDomains}
tenants={parentQuery.data?.items ?? []}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder={t(
"ui.admin.tenants.create.form.domains_placeholder",
"example.com, example.kr",
@@ -268,7 +297,7 @@ function TenantCreatePage() {
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => mutation.mutate()}
onClick={() => mutation.mutate(undefined)}
disabled={mutation.isPending || name.trim() === ""}
>
{t("ui.common.create", "생성")}

View File

@@ -51,6 +51,7 @@ import {
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
type TenantImportResolution,
type TenantImportPreviewRow,
buildTenantImportPreview,
parseTenantCSV,
@@ -58,7 +59,7 @@ import {
} from "../utils/tenantCsvImport";
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() {
const navigate = useNavigate();
@@ -72,6 +73,9 @@ function TenantListPage() {
const [selectedMatches, setSelectedMatches] = React.useState<
Record<number, string>
>({});
const [selectedCreateSlugs, setSelectedCreateSlugs] = React.useState<
Record<number, string>
>({});
const [previewOpen, setPreviewOpen] = React.useState(false);
const { data: profile } = useQuery({
@@ -117,7 +121,7 @@ function TenantListPage() {
});
const exportMutation = useMutation({
mutationFn: exportTenantsCSV,
mutationFn: (includeIds: boolean) => exportTenantsCSV(includeIds),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -265,16 +269,44 @@ function TenantListPage() {
setPreviewRows(preview);
setSelectedMatches(
Object.fromEntries(
preview
.filter((row) => row.defaultTenantId)
.map((row) => [row.row.rowNumber, row.defaultTenantId]),
preview.map((row) => [
row.row.rowNumber,
row.defaultTenantId || "__create__",
]),
),
);
setSelectedCreateSlugs(
Object.fromEntries(
preview.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
),
);
setPreviewOpen(true);
};
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" });
importMutation.mutate(file);
};
@@ -343,12 +375,21 @@ function TenantListPage() {
</Button>
<Button
variant="outline"
onClick={() => exportMutation.mutate()}
onClick={() => exportMutation.mutate(false)}
disabled={exportMutation.isPending}
data-testid="tenant-export-btn"
>
<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
variant="outline"
@@ -622,19 +663,41 @@ function TenantListPage() {
</TableCell>
<TableCell className="font-mono text-xs">
{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>
{preview.row.tenantId ? (
<Badge variant="outline">
{t(
"ui.admin.tenants.import_preview.fixed_id",
"ID 지정됨",
)}
</Badge>
) : (
<div className="space-y-2">
<select
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}`}
onChange={(event) =>
setSelectedMatches((prev) => ({
@@ -643,10 +706,10 @@ function TenantListPage() {
}))
}
>
<option value="">
<option value="__create__">
{t(
"ui.admin.tenants.import_preview.create_new",
"신규 생성",
"ui.admin.tenants.import_preview.create_new_reset",
"신규 생성 (ID/slug 재설정)",
)}
</option>
{preview.candidates.map((candidate) => (
@@ -658,7 +721,22 @@ function TenantListPage() {
</option>
))}
</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>
{preview.candidates.length > 0 ? (

View File

@@ -23,6 +23,11 @@ import {
updateTenant,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import {
formatDomainConflictMessage,
type ServerDomainConflict,
} from "../utils/domainTags";
export function TenantProfilePage() {
const { tenantId } = useParams<{ tenantId: string }>();
@@ -53,7 +58,10 @@ export function TenantProfilePage() {
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
const [domains, setDomains] = useState<string[]>([]);
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
[],
);
const [parentId, setParentId] = useState("");
useEffect(() => {
@@ -63,13 +71,14 @@ export function TenantProfilePage() {
setSlug(tenantQuery.data.slug);
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
setDomains(tenantQuery.data.domains ?? []);
setForceDomainConflicts([]);
setParentId(tenantQuery.data.parentId ?? "");
}
}, [tenantQuery.data]);
const updateMutation = useMutation({
mutationFn: () =>
mutationFn: (overrideForceDomains?: string[]) =>
updateTenant(tenantId, {
name,
type,
@@ -77,17 +86,36 @@ export function TenantProfilePage() {
description: description || undefined,
status,
parentId: parentId || undefined,
domains: domains
.split(",")
.map((d) => d.trim())
.filter((d) => d !== ""),
domains,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
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(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
@@ -257,9 +285,14 @@ export function TenantProfilePage() {
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<Input
<DomainTagInput
id="tenant-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"
/>
<p className="text-xs text-muted-foreground">
@@ -322,7 +355,7 @@ export function TenantProfilePage() {
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => updateMutation.mutate()}
onClick={() => updateMutation.mutate(undefined)}
disabled={
updateMutation.isPending ||
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 { t } from "../../../lib/i18n";
type SchemaFieldType =
export type SchemaFieldType =
| "text"
| "number"
| "boolean"
@@ -25,7 +25,7 @@ type SchemaFieldType =
| "float"
| "datetime";
type SchemaField = {
export type SchemaField = {
id: string;
key: string;
label: string;
@@ -35,6 +35,7 @@ type SchemaField = {
validation?: string;
unsigned?: boolean;
isLoginId?: boolean;
indexed?: boolean;
};
function createFieldId() {
@@ -44,6 +45,54 @@ function createFieldId() {
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() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
@@ -71,27 +120,7 @@ export function TenantSchemaPage() {
const rawSchema = tenantQuery.data?.config?.userSchema;
if (Array.isArray(rawSchema)) {
setFields(
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),
})),
);
setFields(rawSchema.map(normalizeSchemaField));
}
}, [tenantQuery.data]);
@@ -158,19 +187,7 @@ export function TenantSchemaPage() {
}
const addField = () => {
setFields([
...fields,
{
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
unsigned: false,
},
]);
setFields([...fields, createSchemaField()]);
};
const removeField = (index: number) => {
@@ -261,16 +278,15 @@ export function TenantSchemaPage() {
value={field.type}
onChange={(e) => {
const nextType = e.target.value;
if (
nextType === "text" ||
nextType === "number" ||
nextType === "boolean" ||
nextType === "date" ||
nextType === "float" ||
nextType === "datetime"
) {
if (isSchemaFieldType(nextType)) {
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"
checked={field.isLoginId || false}
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"
/>
@@ -362,6 +382,23 @@ export function TenantSchemaPage() {
)}
</span>
</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") && (
<label className="flex items-center gap-2 cursor-pointer">
<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",
type: "COMPANY",
parentTenantId: "",
parentTenantSlug: "",
slug: "hanmac-tech",
memo: "Memo",
emailDomain: "hanmac-tech.example.com",
@@ -89,4 +90,88 @@ describe("tenantCsvImport", () => {
"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;
type: string;
parentTenantId: string;
parentTenantSlug: string;
slug: string;
memo: string;
emailDomain: string;
@@ -23,8 +24,30 @@ export type TenantImportPreviewRow = {
row: TenantCSVRow;
candidates: TenantImportCandidate[];
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 = [
"tenant_id",
"name",
@@ -45,6 +68,8 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
parent_id: "parentTenantId",
parenttenantid: "parentTenantId",
parent_tenant_id: "parentTenantId",
parenttenantslug: "parentTenantSlug",
parent_tenant_slug: "parentTenantSlug",
slug: "slug",
memo: "memo",
description: "memo",
@@ -80,6 +105,7 @@ export function parseTenantCSV(text: string): TenantCSVRow[] {
name: value("name"),
type: value("type"),
parentTenantId: value("parentTenantId"),
parentTenantSlug: value("parentTenantSlug"),
slug: value("slug"),
memo: value("memo"),
emailDomain: value("emailDomain"),
@@ -93,14 +119,17 @@ export function buildTenantImportPreview(
): TenantImportPreviewRow[] {
return rows
.map((row) => {
const candidates = row.tenantId ? [] : findTenantCandidates(row, tenants);
const candidates = findTenantCandidates(row, tenants);
const conflicts = findTenantImportConflicts(row, tenants);
return {
row,
candidates,
conflicts,
defaultTenantId:
candidates[0] && candidates[0].score >= 0.95
? candidates[0].tenantId
: "",
defaultCreateSlug: suggestUniqueTenantSlug(row.slug || row.name, tenants),
};
})
.sort((a, b) => {
@@ -113,24 +142,148 @@ export function buildTenantImportPreview(
export function serializeTenantImportCSV(
previewRows: TenantImportPreviewRow[],
selectedTenantIds: Record<number, string>,
selectedTenantIds: Record<number, string | TenantImportResolution>,
) {
const lines = [importHeaders];
for (const preview of [...previewRows].sort(
const sortedRows = [...previewRows].sort(
(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([
preview.row.tenantId || selectedTenantId,
tenantId,
preview.row.name,
preview.row.type,
preview.row.parentTenantId,
preview.row.slug,
parentTenantId,
slug,
preview.row.memo,
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(
@@ -230,6 +383,28 @@ function normalizeToken(value: string) {
.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) {
const a = normalizeToken(left);
const b = normalizeToken(right);

View File

@@ -54,16 +54,7 @@ import {
filterNonHanmacFamilyTenants,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean" | "date";
required?: boolean;
adminOnly?: boolean;
validation?: string;
isLoginId?: boolean;
};
import type { UserSchemaField } from "./userSchemaFields";
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
type UserType = "hanmac" | "external" | "personal";

View File

@@ -77,16 +77,7 @@ import {
isHanmacFamilyUser,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean" | "date";
required?: boolean;
adminOnly?: boolean;
validation?: string;
isLoginId?: boolean;
};
import type { UserSchemaField } from "./userSchemaFields";
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
metadata: Record<string, Record<string, string | number | boolean>>;

View File

@@ -145,7 +145,8 @@ function UserListPage() {
});
const exportMutation = useMutation({
mutationFn: () => exportUsersCSV(search, selectedCompany),
mutationFn: (includeIds: boolean) =>
exportUsersCSV(search, selectedCompany, includeIds),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -190,8 +191,8 @@ function UserListPage() {
}
};
const handleExport = () => {
exportMutation.mutate();
const handleExport = (includeIds = false) => {
exportMutation.mutate(includeIds);
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
@@ -311,12 +312,21 @@ function UserListPage() {
</Button>
<Button
variant="outline"
onClick={handleExport}
onClick={() => handleExport(false)}
className="gap-2"
disabled={exportMutation.isPending}
>
<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>
<UserBulkUploadModal onSuccess={() => query.refetch()} />
<Dialog>

View File

@@ -1,4 +1,4 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
@@ -23,22 +23,129 @@ import {
type BulkUserItem,
type BulkUserResult,
bulkCreateUsers,
createTenant,
fetchTenants,
fetchUsers,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
type TenantCSVRow,
type TenantImportPreviewRow,
buildTenantImportPreview,
} from "../../tenants/utils/tenantCsvImport";
import { parseUserCSV } from "../utils/csvParser";
import {
type HanmacImportEmailPreview,
buildHanmacImportEmailPreview,
} from "../utils/hanmacImportEmail";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
interface UserBulkUploadModalProps {
onSuccess?: () => void;
}
function buildUserTenantPreviewRows(
users: BulkUserItem[],
tenants: Parameters<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) {
const [open, setOpen] = React.useState(false);
const [file, setFile] = React.useState<File | null>(null);
const [parsing, setParsing] = React.useState(false);
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 [preparing, setPreparing] = React.useState(false);
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({
mutationFn: bulkCreateUsers,
onSuccess: (data) => {
@@ -62,20 +169,87 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const text = e.target?.result as string;
const data = parseUserCSV(text);
setPreviewData(data);
const tenantRows = buildUserTenantPreviewRows(
data,
tenantQuery.data?.items ?? [],
);
setTenantPreviewRows(tenantRows);
setSelectedTenantMatches(
Object.fromEntries(
tenantRows.map((row) => [
row.row.rowNumber,
row.defaultTenantId || "__create__",
]),
),
);
setSelectedTenantCreateSlugs(
Object.fromEntries(
tenantRows.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
),
);
setParsing(false);
};
reader.readAsText(file);
};
const handleUpload = () => {
const handleUpload = async () => {
if (previewData.length > 0) {
mutation.mutate(previewData);
setPreparing(true);
try {
const users = await resolveUserImportTenants();
mutation.mutate(users);
} finally {
setPreparing(false);
}
}
};
const resolveUserImportTenants = async () => {
const tenants = tenantQuery.data?.items ?? [];
const tenantSlugByKey = new Map<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 headers =
"email,name,phone,role,tenant,department,position,jobTitle,employee_id";
"email,name,phone,role,tenant_slug,department,position,jobTitle,employee_id";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
const blob = new Blob([`${headers}\n${example}`], {
@@ -92,12 +266,47 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const reset = () => {
setFile(null);
setPreviewData([]);
setTenantPreviewRows([]);
setSelectedTenantMatches({});
setSelectedTenantCreateSlugs({});
setResults(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const successCount = results?.filter((r) => r.success).length ?? 0;
const failCount = results ? results.length - successCount : 0;
const tenants = tenantQuery.data?.items ?? [];
const existingHanmacLocalParts = React.useMemo(() => {
const values = new Set<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 (
<Dialog
@@ -185,6 +394,82 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
</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 && (
<ScrollArea className="h-[200px] rounded-md border">
<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">Name</th>
<th className="p-2 text-left">Tenant</th>
<th className="p-2 text-left">Status</th>
</tr>
</thead>
<tbody>
{previewData.slice(0, 10).map((u) => (
<tr key={u.email} className="border-t">
<td className="p-2">{u.email}</td>
{previewData.slice(0, 10).map((u, index) => (
<tr key={`${u.email}-${index}`} className="border-t">
<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.tenantSlug || "-"}</td>
<td
className={`p-2 text-xs ${hanmacEmailStatusClass(
hanmacEmailPreviews[index],
)}`}
>
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
{hanmacEmailPreviews[index]?.reason && (
<div>{hanmacEmailPreviews[index]?.reason}</div>
)}
</td>
</tr>
))}
{previewData.length > 10 && (
<tr>
<td
colSpan={3}
colSpan={4}
className="p-2 text-center text-muted-foreground italic"
>
... and {previewData.length - 10} more users
@@ -277,11 +587,16 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
{!results ? (
<Button
onClick={handleUpload}
disabled={previewData.length === 0 || mutation.isPending}
disabled={
previewData.length === 0 ||
mutation.isPending ||
preparing ||
hasBlockingHanmacEmailRows
}
className="w-full sm:w-auto"
data-testid="bulk-start-btn"
>
{mutation.isPending && (
{(mutation.isPending || preparing) && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{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].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;
} else if (header === "tenant") {
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") {
item.department = value;
} 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;
status?: string;
domains?: string[];
forceDomainConflicts?: string[];
config?: Record<string, unknown>;
};
@@ -60,6 +61,7 @@ export type TenantUpdateRequest = {
description?: string;
status?: string;
domains?: string[];
forceDomainConflicts?: string[];
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", {
params: { includeIds },
responseType: "blob",
});
const dispositionHeader = response.headers["content-disposition"];
@@ -459,11 +462,26 @@ export type BulkUserItem = {
department?: string;
position?: 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>;
};
export type BulkUserResult = {
email: string;
originalEmail?: string;
suggestedEmail?: string;
status?: string;
warnings?: string[];
success: boolean;
message?: string;
userId?: string;
@@ -508,9 +526,13 @@ export async function createUser(payload: UserCreateRequest) {
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", {
params: { search, tenantSlug },
params: { search, tenantSlug, includeIds },
responseType: "blob",
});
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,
}) => {
let exportRequested = false;
let exportUrl = "";
let importRequested = false;
let importBody = "";
@@ -131,6 +132,7 @@ test.describe("Tenants Management", () => {
if (url.includes("/export")) {
exportRequested = true;
exportUrl = url;
return route.fulfill({
body: "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n",
contentType: "text/csv",
@@ -191,6 +193,7 @@ test.describe("Tenants Management", () => {
await page.getByTestId("tenant-export-btn").click();
await download;
expect(exportRequested).toBe(true);
expect(exportUrl).toContain("includeIds=false");
await page.getByTestId("tenant-import-input").setInputFiles({
name: "tenants.csv",
@@ -213,6 +216,98 @@ test.describe("Tenants Management", () => {
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 }) => {
await page.goto("/tenants/new");
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {

View File

@@ -404,9 +404,11 @@ test.describe("User Management", () => {
page,
}) => {
let authorizationHeader: string | undefined;
let exportUrl = "";
await page.route(/\/admin\/users\/export(\?.*)?$/, async (route) => {
authorizationHeader = route.request().headers().authorization;
exportUrl = route.request().url();
return route.fulfill({
status: 200,
headers: {
@@ -425,6 +427,7 @@ test.describe("User Management", () => {
expect(download.suggestedFilename()).toBe("users.csv");
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 ({

View File

@@ -112,4 +112,78 @@ test.describe("Users Bulk Upload", () => {
const uploadBtn = page.getByTestId("bulk-start-btn");
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();
await expect(errorMsg).toBeVisible();
});
});

View File

@@ -7,6 +7,9 @@ const buildOutDir =
export default defineConfig({
plugins: [react()],
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
cacheDir:
process.env.ADMINFRONT_VITE_CACHE_DIR ??
"/tmp/baron-sso-adminfront-vite-cache",
build: {
outDir: buildOutDir,
},

View File

@@ -285,15 +285,18 @@ func main() {
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db)
rpUserMetadataRepo := repository.NewRPUserMetadataRepository(db)
developerService := service.NewDeveloperService(db)
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
authHandler.HeadlessJWKS = headlessJWKSCache
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
devHandler.HeadlessJWKS = headlessJWKSCache
devHandler.AuditRepo = auditRepo
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
@@ -706,6 +709,8 @@ func main() {
dev.Get("/users", devHandler.SearchUsers)
dev.Get("/clients", devHandler.ListClients)
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/relations", devHandler.ListClientRelations)
dev.Post("/clients/:id/relations", devHandler.AddClientRelation)

View File

@@ -1156,6 +1156,14 @@ components:
type: string
logo:
type: string
url:
type: string
init_url:
type: string
auto_login_supported:
type: boolean
auto_login_url:
type: string
lastAuthenticatedAt:
type: string
status:

View File

@@ -29,6 +29,10 @@ func Run(db *gorm.DB) error {
func migrateSchemas(db *gorm.DB) error {
slog.Info("[Bootstrap] Migrating database schemas...")
if err := dropLegacyTenantDomainUniqueIndex(db); err != nil {
return err
}
// Add all domain models here
return db.AutoMigrate(
&domain.Tenant{},
@@ -43,6 +47,20 @@ func migrateSchemas(db *gorm.DB) error {
&domain.KetoOutbox{},
&domain.SharedLink{},
&domain.DeveloperRequest{},
&domain.RPUserMetadata{},
// &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/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/csv"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"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 {
TenantID string
Name string
Slug string
Type string
@@ -21,32 +39,31 @@ type InitialTenantConfig struct {
Domains []string
}
// Hardcoded for now, can be moved to config file or env later
var defaultTenants = []InitialTenantConfig{
{
Name: "한맥가족",
Slug: "hanmac-family",
Type: domain.TenantTypeCompanyGroup,
},
{
Name: "한맥기술",
Slug: "hanmac",
Type: domain.TenantTypeCompany,
ParentSlug: "hanmac-family",
Description: "Primary Family Company",
Domains: []string{"hanmaceng.co.kr", "hmac.kr"},
},
{
Name: "삼안",
Slug: "saman",
Type: domain.TenantTypeCompany,
ParentSlug: "hanmac-family",
Domains: []string{"samaneng.com"},
},
func SeedTenants(db *gorm.DB) error {
slog.Info("[Bootstrap] Checking initial tenant seed...")
var tenantCount int64
if err := db.Model(&domain.Tenant{}).Count(&tenantCount).Error; err != nil {
return fmt.Errorf("count tenants before seed: %w", err)
}
if tenantCount > 0 {
slog.Info("[Bootstrap] Tenant seed skipped because tenants already exist", "count", tenantCount)
return nil
}
configs, err := loadSeedTenantConfigs()
if err != nil {
return err
}
if len(configs) == 0 {
return errors.New("seed tenant csv has no tenant rows")
}
return seedTenantConfigs(db, configs)
}
func SeedTenants(db *gorm.DB) error {
slog.Info("[Bootstrap] Seeding initial tenants...")
func seedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error {
slog.Info("[Bootstrap] Seeding initial tenants from CSV...", "count", len(configs))
repo := repository.NewTenantRepository(db)
userRepo := repository.NewUserRepository(db)
userGroupRepo := repository.NewUserGroupRepository(db)
@@ -54,7 +71,7 @@ func SeedTenants(db *gorm.DB) error {
svc := service.NewTenantService(repo, userRepo, userGroupRepo, outboxRepo)
ctx := context.Background()
for _, config := range defaultTenants {
for _, config := range orderSeedTenantConfigsByParentSlug(configs) {
tenantType := config.Type
if tenantType == "" {
tenantType = domain.TenantTypeCompany
@@ -73,75 +90,273 @@ func SeedTenants(db *gorm.DB) error {
parentID = &parent.ID
}
existing, err := repo.FindBySlug(ctx, config.Slug)
if err == nil && existing != nil {
slog.Info("[Bootstrap] Tenant already exists, checking domains...", "slug", config.Slug)
changed := false
if existing.Name != config.Name {
existing.Name = config.Name
changed = true
}
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 seed tenant", "name", config.Name, "slug", config.Slug)
var tenant *domain.Tenant
var err error
if config.TenantID != "" {
tenant, err = createSeedTenant(ctx, repo, outboxRepo, config, tenantType, parentID)
} else {
tenant, err = svc.RegisterTenant(ctx, config.Name, config.Slug, tenantType, config.Description, config.Domains, parentID, "")
}
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 {
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
return err
}
// Explicitly set to active during seed
tenant.Status = domain.TenantStatusActive
db.Save(tenant)
if err := db.Save(tenant).Error; err != nil {
return err
}
}
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 (
"baron-sso-backend/internal/domain"
"reflect"
"os"
"path/filepath"
"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 {
name string
slug string
tenantType string
parentSlug string
domains []string
}{
{
name: "한맥가족",
@@ -20,55 +24,61 @@ func TestDefaultTenantsSeedOrderAndHierarchy(t *testing.T) {
tenantType: domain.TenantTypeCompanyGroup,
},
{
name: "한맥기술",
slug: "hanmac",
tenantType: domain.TenantTypeCompany,
parentSlug: "hanmac-family",
domains: []string{"hanmaceng.co.kr", "hmac.kr"},
},
{
name: "삼안",
slug: "saman",
tenantType: domain.TenantTypeCompany,
parentSlug: "hanmac-family",
domains: []string{"samaneng.com"},
name: "Personal",
slug: "personal",
tenantType: domain.TenantTypePersonal,
},
}
if len(defaultTenants) != len(expected) {
t.Fatalf("expected %d default tenants, got %d", len(expected), len(defaultTenants))
if len(configs) != len(expected) {
t.Fatalf("expected %d seed tenants, got %d", len(expected), len(configs))
}
for i, want := range expected {
got := defaultTenants[i]
got := configs[i]
if got.Name != want.name {
t.Fatalf("tenant[%d] name = %q, want %q", i, got.Name, want.name)
}
if 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 {
t.Fatalf("tenant[%d] type = %q, want %q", i, tenantType, want.tenantType)
if got.Type != want.tenantType {
t.Fatalf("tenant[%d] type = %q, want %q", i, got.Type, want.tenantType)
}
if parentSlug := stringField(t, got, "ParentSlug"); parentSlug != want.parentSlug {
t.Fatalf("tenant[%d] parent slug = %q, want %q", i, parentSlug, want.parentSlug)
if got.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 {
t.Helper()
func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
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)
field := value.FieldByName(name)
if !field.IsValid() {
t.Fatalf("InitialTenantConfig.%s is required", name)
configs, err := loadSeedTenantConfigs()
if err != nil {
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
}
if field.Kind() != reflect.String {
t.Fatalf("InitialTenantConfig.%s must be a string", name)
if len(configs) != 2 {
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"
MetadataHeadlessJWKS = "headless_jwks"
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
MetadataAutoLoginSupported = "auto_login_supported"
MetadataAutoLoginURL = "auto_login_url"
)
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.
type TenantDomain struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
TenantID string `gorm:"type:uuid;not null;index" json:"tenantId"`
Domain string `gorm:"uniqueIndex;not null" json:"domain"` // e.g. "example.com"
TenantID string `gorm:"type:uuid;not null;uniqueIndex:idx_tenant_domains_tenant_domain" json:"tenantId"`
Domain string `gorm:"not null;uniqueIndex:idx_tenant_domains_tenant_domain" json:"domain"` // e.g. "example.com"
Verified bool `gorm:"default:false" json:"verified"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`

View File

@@ -85,20 +85,21 @@ const (
)
type AuthHandler struct {
SmsService domain.SmsService
EmailService domain.EmailService
RedisService domain.RedisRepository
HeadlessJWKS *service.HeadlessJWKSCacheService
KratosAdmin service.KratosAdminService
IdpProvider domain.IdentityProvider
AuditRepo domain.AuditRepository
OathkeeperRepo domain.OathkeeperLogRepository
Hydra *service.HydraAdminService
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
ConsentRepo repository.ClientConsentRepository
SmsService domain.SmsService
EmailService domain.EmailService
RedisService domain.RedisRepository
HeadlessJWKS *service.HeadlessJWKSCacheService
KratosAdmin service.KratosAdminService
IdpProvider domain.IdentityProvider
AuditRepo domain.AuditRepository
OathkeeperRepo domain.OathkeeperLogRepository
Hydra *service.HydraAdminService
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
ConsentRepo repository.ClientConsentRepository
RPUserMetadataRepo repository.RPUserMetadataRepository
}
type signupState struct {
@@ -1157,6 +1158,120 @@ func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string
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 {
emails := make([]string, 0)
seen := make(map[string]struct{})
@@ -4792,6 +4907,8 @@ type linkedRpSummary struct {
Logo string `json:"logo,omitempty"`
URL string `json:"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"`
Status string `json:"status"`
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) != "" {
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]
if existing == nil {
records[clientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
ID: clientID,
Name: name,
Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL,
InitURL: initURL,
Status: "active", // Hydra 세션이 있으면 활성
Scopes: scopes,
ID: clientID,
Name: name,
Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL,
InitURL: initURL,
AutoLoginSupported: autoLoginSupported,
AutoLoginURL: autoLoginURL,
Status: "active", // Hydra 세션이 있으면 활성
Scopes: scopes,
},
lastAuth: lastAuth,
}
@@ -4903,6 +5024,12 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
if existing.InitURL == "" {
existing.InitURL = initURL
}
if !existing.AutoLoginSupported {
existing.AutoLoginSupported = autoLoginSupported
}
if existing.AutoLoginURL == "" {
existing.AutoLoginURL = autoLoginURL
}
existing.Scopes = mergeScopes(existing.Scopes, scopes)
if lastAuth.After(existing.lastAuth) {
existing.lastAuth = lastAuth
@@ -4943,11 +5070,13 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
)
}
if record.InitURL == "" {
record.InitURL = resolveLinkedRPInitURL(
client.ClientID,
record.Scopes,
client.RedirectURIs,
)
record.InitURL = resolveLinkedRPInitURL(client.ClientID, client.Metadata)
}
if !record.AutoLoginSupported {
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.RedirectURIs,
)
initURL := resolveLinkedRPInitURL(
client.ClientID,
dc.GrantedScopes,
client.RedirectURIs,
)
autoLoginSupported := resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
autoLoginURL := resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
initURL := resolveLinkedRPInitURL(client.ClientID, client.Metadata)
records[dc.ClientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
ID: dc.ClientID,
Name: name,
Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL,
InitURL: initURL,
Status: status,
Scopes: dc.GrantedScopes,
ID: dc.ClientID,
Name: name,
Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL,
InitURL: initURL,
AutoLoginSupported: autoLoginSupported,
AutoLoginURL: autoLoginURL,
Status: status,
Scopes: dc.GrantedScopes,
},
lastAuth: dc.UpdatedAt,
}
@@ -5087,11 +5216,9 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
}
}
record.URL = clientURL
record.InitURL = resolveLinkedRPInitURL(
client.ClientID,
scopes,
client.RedirectURIs,
)
record.InitURL = resolveLinkedRPInitURL(client.ClientID, client.Metadata)
record.AutoLoginSupported = resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
record.AutoLoginURL = resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
} else {
// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
if record.Name == "" {
@@ -5239,6 +5366,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
currentSessionID,
)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
if err == nil {
return c.JSON(acceptResp)
@@ -5268,6 +5396,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
currentSessionID,
)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
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),
currentSessionID,
)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)
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 != "" {
return value
}
case "orgfront":
if value := strings.TrimSpace(os.Getenv("ORGFRONT_URL")); value != "" {
return value
}
}
clientURL := strings.TrimSpace(clientURI)
@@ -7271,10 +7405,22 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string
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)
if clientID == "" {
return ""
if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" {
return metadataURL
}
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 != "" {
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"), "/")
if hydraPublicURL == "" {
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")

View File

@@ -1,6 +1,7 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"encoding/json"
@@ -178,6 +179,103 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
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) {
var capturedClaims map[string]interface{}

View File

@@ -55,6 +55,21 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
"grant_scope": []string{"openid", "profile"},
"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
}
if r.URL.Path == "/admin/clients/client-audit" {
@@ -129,16 +144,18 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
var res struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
InitURL string `json:"init_url"`
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
InitURL string `json:"init_url"`
AutoLoginSupported bool `json:"auto_login_supported"`
AutoLoginURL string `json:"auto_login_url"`
} `json:"items"`
}
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)
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["orgfront"])
assert.Equal(t, "inactive", statusMap["client-consent"])
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, "1", parsedInitURL.Query().Get("auto"))
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) {

View File

@@ -14,6 +14,7 @@ import (
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strconv"
"strings"
@@ -24,19 +25,20 @@ import (
)
type DevHandler struct {
Hydra *service.HydraAdminService
Redis domain.RedisRepository
HeadlessJWKS *service.HeadlessJWKSCacheService
SecretRepo domain.ClientSecretRepository
AuditRepo domain.AuditRepository
KratosAdmin service.KratosAdminService
ConsentRepo repository.ClientConsentRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
RPSvc service.RelyingPartyService
TenantSvc service.TenantService
DeveloperSvc *service.DeveloperService
Auth interface {
Hydra *service.HydraAdminService
Redis domain.RedisRepository
HeadlessJWKS *service.HeadlessJWKSCacheService
SecretRepo domain.ClientSecretRepository
AuditRepo domain.AuditRepository
KratosAdmin service.KratosAdminService
ConsentRepo repository.ClientConsentRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
RPSvc service.RelyingPartyService
TenantSvc service.TenantService
DeveloperSvc *service.DeveloperService
RPUserMetadataRepo repository.RPUserMetadataRepository
Auth interface {
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
}
}
@@ -1377,6 +1379,86 @@ func (h *DevHandler) publicHeadlessJWKSCacheState(clientID string) (*domain.Head
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) {
if h.HeadlessJWKS == nil {
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil)
@@ -1574,6 +1656,10 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if err != nil {
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, ""))
if tokenAuthMethod == "" {
@@ -1766,6 +1852,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
metadata, err = normalizeClientAutoLoginMetadata(metadata)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
resolvedClientType := currentSummary.Type
if clientType != "" {
resolvedClientType = clientType
@@ -2575,6 +2665,30 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
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(
clientType 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)
}
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) {
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/utils"
"bytes"
"context"
"encoding/csv"
"errors"
"fmt"
@@ -68,14 +69,22 @@ type tenantImportResult struct {
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 {
TenantID string
Name string
Type string
ParentTenantID *string
Slug string
Memo string
Domains []string
TenantID string
Name string
Type string
ParentTenantID *string
ParentTenantSlug string
Slug string
Memo string
Domains []string
}
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
@@ -258,13 +267,24 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
var buf bytes.Buffer
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())
}
slugByID := make(map[string]string, len(tenants))
for _, tenant := range tenants {
slugByID[tenant.ID] = tenant.Slug
}
for _, tenant := range tenants {
parentID := ""
parentSlug := ""
if tenant.ParentID != nil {
parentID = *tenant.ParentID
parentSlug = slugByID[parentID]
}
domains := make([]string, 0, len(tenant.Domains))
for _, domainName := range tenant.Domains {
@@ -273,15 +293,27 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
domains = append(domains, domainName)
}
}
if err := writer.Write([]string{
tenant.ID,
row := []string{
tenant.Name,
tenant.Type,
parentID,
parentSlug,
tenant.Slug,
tenant.Description,
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())
}
}
@@ -305,33 +337,60 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
records = orderTenantCSVRecordsByParentSlug(records)
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
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)}
for i, record := range records {
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 != "") {
updated, err := h.upsertTenantCSVRecord(c, record)
tenant, updated, err := h.upsertTenantCSVRecord(c, record)
if err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
continue
}
if updated {
tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID
result.Updated++
continue
}
}
if err := h.createTenantCSVRecord(c, record, creatorID); err != nil {
tenant, err := h.createTenantCSVRecord(c, record, creatorID)
if err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
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++
}
@@ -414,13 +473,14 @@ func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
}
records = append(records, tenantCSVRecord{
TenantID: tenantCSVValue(row, header, "tenant_id"),
Name: name,
Type: tenantType,
ParentTenantID: parentID,
Slug: slug,
Memo: tenantCSVValue(row, header, "memo"),
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
TenantID: tenantCSVValue(row, header, "tenant_id"),
Name: name,
Type: tenantType,
ParentTenantID: parentID,
ParentTenantSlug: tenantCSVValue(row, header, "parent_tenant_slug"),
Slug: slug,
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 {
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",
"parentid": "parent_tenant_id",
"parent_id": "parent_tenant_id",
"parenttenantid": "parent_tenant_id",
"parent_tenant_id": "parent_tenant_id",
"slug": "slug",
"memo": "memo",
"description": "memo",
"email-domain": "email_domain",
"emaildomain": "email_domain",
"email_domain": "email_domain",
"domain": "email_domain",
"domains": "email_domain",
"id": "tenant_id",
"tenantid": "tenant_id",
"tenant_id": "tenant_id",
"name": "name",
"type": "type",
"parentid": "parent_tenant_id",
"parent_id": "parent_tenant_id",
"parenttenantid": "parent_tenant_id",
"parent_tenant_id": "parent_tenant_id",
"parenttenantslug": "parent_tenant_slug",
"parent_tenant_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))
@@ -475,6 +537,40 @@ func tenantCSVRowIsEmpty(row []string) bool {
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 {
value = strings.ReplaceAll(value, "\n", ";")
value = strings.ReplaceAll(value, ",", ";")
@@ -492,12 +588,203 @@ func splitTenantCSVDomains(value string) []string {
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 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
@@ -510,10 +797,10 @@ func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVReco
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
return nil, false, nil
}
if err != nil {
return false, err
return nil, false, err
}
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 {
return false, err
return nil, false, err
}
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)
for _, domainName := range record.Domains {
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 != "" {
var exists int64
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 {
return errors.New("tenant slug already exists")
return nil, errors.New("tenant slug already exists")
}
tenant := domain.Tenant{
@@ -561,7 +848,7 @@ func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVReco
Status: domain.TenantStatusActive,
}
if err := h.DB.Create(&tenant).Error; err != nil {
return err
return nil, err
}
if h.KetoOutbox != nil {
_ = 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)
for _, domainName := range record.Domains {
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)
return err
tenant, err := h.Service.RegisterTenant(c.Context(), record.Name, record.Slug, record.Type, record.Memo, record.Domains, record.ParentTenantID, creatorID)
return tenant, err
}
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
@@ -646,14 +933,15 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
}
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
Type string `json:"type"`
Description string `json:"description"`
Status string `json:"status"`
Domains []string `json:"domains"`
ParentID *string `json:"parentId"`
Config map[string]any `json:"config"`
Name string `json:"name"`
Slug string `json:"slug"`
Type string `json:"type"`
Description string `json:"description"`
Status string `json:"status"`
Domains []string `json:"domains"`
ForceDomains []string `json:"forceDomainConflicts"`
ParentID *string `json:"parentId"`
Config map[string]any `json:"config"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
@@ -701,7 +989,16 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
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 strings.Contains(err.Error(), "already exists") {
return errorJSON(c, fiber.StatusConflict, err.Error())
@@ -713,10 +1010,20 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
summary.MemberCount = 0
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)
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)
}
@@ -740,14 +1047,15 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
}
var req struct {
Name *string `json:"name"`
Type *string `json:"type"`
Slug *string `json:"slug"`
Description *string `json:"description"`
Status *string `json:"status"`
ParentID *string `json:"parentId"`
Domains []string `json:"domains"`
Config map[string]any `json:"config"`
Name *string `json:"name"`
Type *string `json:"type"`
Slug *string `json:"slug"`
Description *string `json:"description"`
Status *string `json:"status"`
ParentID *string `json:"parentId"`
Domains []string `json:"domains"`
ForceDomains []string `json:"forceDomainConflicts"`
Config map[string]any `json:"config"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
@@ -835,7 +1143,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
}
}
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 {
@@ -844,18 +1156,16 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
// Update domains if provided
if req.Domains != nil {
// Simple approach: Delete existing and recreate
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to clear old domains")
normalizedDomains := normalizeTenantDomainInputs(req.Domains)
conflicts, err := h.findTenantDomainConflicts(c.Context(), tenant.ID, normalizedDomains, req.ForceDomains)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
for _, d := range req.Domains {
if strings.TrimSpace(d) == "" {
continue
}
// Use repository for consistency
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)
}
if len(conflicts) > 0 {
return tenantDomainConflictJSON(c, conflicts)
}
if err := h.replaceTenantDomains(c.Context(), tenant.ID, normalizedDomains, req.ForceDomains); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}

View File

@@ -189,7 +189,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
}
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)
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)
req := httptest.NewRequest("GET", "/tenants/export", nil)
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
resp, _ := app.Test(req)
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv")
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), "t1,Tenant A,COMPANY,parent-1,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com")
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")
}
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) {
@@ -304,6 +344,7 @@ func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
assert.NoError(t, err)
assert.NoError(t, writer.Close())
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once()
mockSvc.On(
"RegisterTenant",
mock.Anything,
@@ -331,6 +372,127 @@ func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.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) {
app := fiber.New()
mockSvc := new(MockTenantService)

View File

@@ -425,6 +425,15 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
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
for k, v := range req.Metadata {
// Don't overwrite core fields
@@ -534,10 +543,14 @@ type bulkUserItem struct {
}
type bulkUserResult struct {
Email string `json:"email"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
UserID string `json:"userId,omitempty"`
Email string `json:"email"`
OriginalEmail string `json:"originalEmail,omitempty"`
SuggestedEmail string `json:"suggestedEmail,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 {
@@ -565,6 +578,9 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
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
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)
role := item.Role
if role == "" {
@@ -665,7 +728,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
}
userEmail := email
userPhone := normalizePhoneNumber(item.Phone)
// Validate all collected LoginIDs
@@ -673,7 +735,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
valid := true
for _, lid := range collectedIDs {
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
break
}
@@ -692,14 +754,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
if err != nil {
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
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 == "" {
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
}
slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID)
} 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
}
}
@@ -709,7 +771,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
if h.UserRepo != nil {
localUser := &domain.User{
ID: identityID,
Email: email,
Email: userEmail,
Name: name,
Phone: normalizePhoneNumber(item.Phone),
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{
@@ -870,12 +940,19 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
defer writer.Flush()
// 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
metaKeysMap := make(map[string]bool)
for _, u := range filtered {
for k := range u.Metadata {
if !includeIDs && csvMetadataKeyIsID(k) {
continue
}
metaKeysMap[k] = true
}
}
@@ -891,8 +968,11 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
// Data rows
for _, u := range filtered {
tenantID := ""
if u.TenantID != nil {
tenantID = *u.TenantID
}
row := []string{
u.ID,
u.Email,
u.Name,
u.Phone,
@@ -902,6 +982,20 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
u.JobTitle,
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
for _, k := range metaKeys {
val := ""
@@ -918,6 +1012,11 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
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 {
var req struct {
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)
}
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) {
args := m.Called(ctx, domainName)
if args.Get(0) == nil {
@@ -167,20 +175,66 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
},
}, 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)
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, "ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,test-tenant")
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.NotContains(t, body, "Role")
assert.NotContains(t, body, "Department")
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) {
app := fiber.New()
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) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
}
// 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 {
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 (
"baron-sso-backend/internal/domain"
"context"
"errors"
"strings"
"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 {
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{
TenantID: tenantID,
Domain: domainName,

View File

@@ -60,6 +60,49 @@ func TestTenantRepository(t *testing.T) {
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) {
tenant := &domain.Tenant{
Name: "Before Update",

View File

@@ -4,6 +4,9 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
cacheDir:
process.env.ADMINFRONT_VITE_CACHE_DIR ??
"/tmp/baron-sso-adminfront-vite-cache",
server: {
host: "127.0.0.1",
// 인스턴스별 도메인을 자동으로 허용

View File

@@ -81,8 +81,11 @@ services:
- DB_HOST=postgres
- REDIS_ADDR=redis:6379
- CLICKHOUSE_HOST=clickhouse
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
ports:
- "${BACKEND_PORT}:${BACKEND_PORT}"
volumes:
- ../../adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
networks: [app_net]
depends_on:
postgres: { condition: service_healthy }

View File

@@ -157,6 +157,8 @@ function ClientGeneralPage() {
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
const [tenantSearch, setTenantSearch] = useState("");
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
const [autoLoginSupported, setAutoLoginSupported] = useState(false);
const [autoLoginUrl, setAutoLoginUrl] = useState("");
// Public Key Registration States
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
@@ -203,6 +205,9 @@ function ClientGeneralPage() {
if (typeof metadata.description === "string")
setDescription(metadata.description);
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;
setHeadlessLoginEnabled(headlessEnabled);
@@ -287,8 +292,12 @@ function ClientGeneralPage() {
const securityProfile: SecurityProfile =
clientType === "pkce" ? "pkce" : "private";
const trimmedLogoUrl = logoUrl.trim();
const trimmedAutoLoginUrl = autoLoginUrl.trim();
const hasLogoUrl = trimmedLogoUrl.length > 0;
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
const hasValidAutoLoginUrl =
!autoLoginSupported ||
(trimmedAutoLoginUrl.length > 0 && isValidUrl(trimmedAutoLoginUrl));
useEffect(() => {
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 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(
scopes,
@@ -648,6 +673,8 @@ function ClientGeneralPage() {
metadata: {
description,
logo_url: trimmedLogoUrl,
auto_login_supported: autoLoginSupported,
auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined,
structured_scopes: normalizedScopes,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
headless_login_enabled: headlessLoginEnabled,
@@ -1057,6 +1084,84 @@ function ClientGeneralPage() {
</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 */}
<Card className="glass-panel">
<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);
});
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 ({
page,
}) => {

View File

@@ -28,6 +28,7 @@ services:
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
depends_on:
- infra_check
networks:
@@ -35,6 +36,7 @@ services:
- ory-net
volumes:
- ./backend:/app
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["go", "run", "./cmd/server"]
healthcheck:

View File

@@ -12,8 +12,11 @@ services:
- IDP_PROVIDER=ory
- OATHKEEPER_API_URL=http://oathkeeper:4456
- PROFILE_CACHE_TTL="${PROFILE_CACHE_TTL:-30m}"
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
ports:
- "${BACKEND_PORT:-3000}:3000"
volumes:
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
depends_on:
oathkeeper:
condition: service_healthy

View File

@@ -17,8 +17,11 @@ services:
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
- USERFRONT_URL=${USERFRONT_URL:-https://sso.hmac.kr}
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
ports:
- "${BACKEND_PORT:-3010}:3010"
volumes:
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
depends_on:
- infra_check
healthcheck:

View File

@@ -350,6 +350,7 @@ services:
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
depends_on:
clickhouse:
condition: service_healthy
@@ -370,6 +371,7 @@ services:
- ory-net
volumes:
- ./backend:/app
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["go", "run", "./cmd/server"]
healthcheck:
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
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
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 }}'
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"

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_PULL="$ROOT_DIR/.gitea/workflows/staging_code_pull.yml"
ORGFRONT_VITE="$ROOT_DIR/orgfront/vite.config.ts"
ORGFRONT_RUNTIME="$ROOT_DIR/orgfront/scripts/runtime-mode.sh"
for file in \
"$LOCAL_COMPOSE" \
@@ -40,7 +41,8 @@ for file in \
"$CODE_CHECK" \
"$STAGING_RELEASE" \
"$STAGING_PULL" \
"$ORGFRONT_VITE"
"$ORGFRONT_VITE" \
"$ORGFRONT_RUNTIME"
do
if [[ ! -f "$file" ]]; then
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_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/deploy/templates/adminfront/vite.config.ts" 'envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"]'

View File

@@ -7,9 +7,15 @@ String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
return null;
}
final initUrl = rp.initUrl.trim();
if (initUrl.isNotEmpty) {
return initUrl;
if (rp.autoLoginSupported) {
final initUrl = rp.initUrl.trim();
if (initUrl.isNotEmpty) {
return initUrl;
}
final autoLoginUrl = rp.autoLoginUrl.trim();
if (autoLoginUrl.isNotEmpty) {
return autoLoginUrl;
}
}
final url = rp.url.trim();

View File

@@ -97,6 +97,8 @@ class LinkedRp {
final String logo;
final String url;
final String initUrl;
final bool autoLoginSupported;
final String autoLoginUrl;
final String status;
final List<String> scopes;
final DateTime? lastAuthenticatedAt;
@@ -107,6 +109,8 @@ class LinkedRp {
required this.logo,
required this.url,
required this.initUrl,
required this.autoLoginSupported,
required this.autoLoginUrl,
required this.status,
required this.scopes,
this.lastAuthenticatedAt,
@@ -129,6 +133,8 @@ class LinkedRp {
logo: json['logo']?.toString() ?? '',
url: json['url']?.toString() ?? '',
initUrl: json['init_url']?.toString() ?? '',
autoLoginSupported: json['auto_login_supported'] == true,
autoLoginUrl: json['auto_login_url']?.toString() ?? '',
status: json['status']?.toString() ?? '',
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
lastAuthenticatedAt: parsedLastAuth,

View File

@@ -11,6 +11,8 @@ class LinkedRp {
final String logo;
final String url;
final String initUrl;
final bool autoLoginSupported;
final String autoLoginUrl;
final String status;
final List<String> scopes;
final DateTime? lastAuthenticatedAt;
@@ -21,6 +23,8 @@ class LinkedRp {
required this.logo,
required this.url,
required this.initUrl,
required this.autoLoginSupported,
required this.autoLoginUrl,
required this.status,
required this.scopes,
required this.lastAuthenticatedAt,
@@ -43,6 +47,8 @@ class LinkedRp {
logo: json['logo']?.toString() ?? '',
url: json['url']?.toString() ?? '',
initUrl: json['init_url']?.toString() ?? '',
autoLoginSupported: json['auto_login_supported'] == true,
autoLoginUrl: json['auto_login_url']?.toString() ?? '',
status: json['status']?.toString() ?? 'unknown',
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
lastAuthenticatedAt: parsedLastAuth,

View File

@@ -1247,6 +1247,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
url: rp.url,
launchUrl: resolveLinkedRpLaunchUrl(rp),
autoLoginSupported: rp.autoLoginSupported,
lastAuthDateTime: rp.lastAuthenticatedAt,
),
);
@@ -1393,6 +1394,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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),
Row(
children: [
@@ -2421,6 +2432,7 @@ class _ActivityItem {
final String status;
final String? url;
final String? launchUrl;
final bool autoLoginSupported;
final List<String> scopes;
final bool isRevoked;
final VoidCallback? onRevoke;
@@ -2435,6 +2447,7 @@ class _ActivityItem {
required this.scopes,
this.url,
this.launchUrl,
this.autoLoginSupported = false,
this.isRevoked = false,
this.onRevoke,
this.lastAuthDateTime,

View File

@@ -6,6 +6,8 @@ LinkedRp _linkedRp({
required String status,
String url = '',
String initUrl = '',
bool autoLoginSupported = false,
String autoLoginUrl = '',
}) {
return LinkedRp(
id: 'client-1',
@@ -13,6 +15,8 @@ LinkedRp _linkedRp({
logo: '',
url: url,
initUrl: initUrl,
autoLoginSupported: autoLoginSupported,
autoLoginUrl: autoLoginUrl,
status: status,
scopes: const ['openid', 'profile'],
lastAuthenticatedAt: null,
@@ -27,20 +31,25 @@ void main() {
'status': 'active',
'url': 'https://example.com',
'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(
rp.initUrl,
'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(
_linkedRp(
status: 'active',
url: 'https://example.com',
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(
_linkedRp(status: 'active', url: 'https://example.com'),
);