forked from baron/baron-sso
refactor: 보조 이메일 키값을 sub_email로 통일 및 수동 폼 추가 (#917)
- `secondary_emails` 대신 `sub_email`을 키값으로 사용하도록 전면 수정 - 관리자 화면의 수동 사용자 생성(Create) 및 수정(Detail) 폼에 `sub_email` 입력 필드 추가 - CSV 템플릿의 컬럼명을 `sub_email`로 변경 - 백엔드의 Kratos Traits 조회 및 배열 추출 로직을 `sub_email` 기준으로 업데이트 - E2E 테스트(`users_bulk.spec.ts`, `users_bulk_secondary.spec.ts`)에서 `sub_email` 검증하도록 수정 및 통과 확인
This commit is contained in:
@@ -58,7 +58,11 @@ import {
|
|||||||
import type { UserSchemaField } from "./userSchemaFields";
|
import type { UserSchemaField } from "./userSchemaFields";
|
||||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||||
|
|
||||||
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
type UserFormValues = UserCreateRequest & {
|
||||||
|
metadata: Record<string, unknown> & {
|
||||||
|
sub_email?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
type UserCategory = "hanmac" | "external" | "personal";
|
type UserCategory = "hanmac" | "external" | "personal";
|
||||||
|
|
||||||
type PickerTarget = { kind: "appointment"; index: number };
|
type PickerTarget = { kind: "appointment"; index: number };
|
||||||
@@ -161,7 +165,9 @@ function UserCreatePage() {
|
|||||||
position: "",
|
position: "",
|
||||||
jobTitle: "",
|
jobTitle: "",
|
||||||
role: "user",
|
role: "user",
|
||||||
metadata: {},
|
metadata: {
|
||||||
|
sub_email: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,10 +373,22 @@ function UserCreatePage() {
|
|||||||
const {
|
const {
|
||||||
hanmacFamily: _hanmacFamily,
|
hanmacFamily: _hanmacFamily,
|
||||||
userType: _userType,
|
userType: _userType,
|
||||||
|
sub_email: rawSubEmail,
|
||||||
...formMetadata
|
...formMetadata
|
||||||
} = data.metadata ?? {};
|
} = data.metadata ?? {};
|
||||||
|
|
||||||
|
// Parse sub_email
|
||||||
|
let sub_email: string[] = [];
|
||||||
|
if (typeof rawSubEmail === "string" && rawSubEmail.trim() !== "") {
|
||||||
|
sub_email = rawSubEmail
|
||||||
|
.split(/[;,\n\r\t]/)
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.filter((e) => e.includes("@"));
|
||||||
|
}
|
||||||
|
|
||||||
const metadata: Record<string, unknown> = {
|
const metadata: Record<string, unknown> = {
|
||||||
...formMetadata,
|
...formMetadata,
|
||||||
|
...(sub_email.length > 0 ? { sub_email } : { sub_email: [] }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload: UserCreateRequest = {
|
const payload: UserCreateRequest = {
|
||||||
@@ -580,6 +598,26 @@ function UserCreatePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="sub_email"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{t("ui.admin.users.create.form.sub_email", "보조 이메일")}
|
||||||
|
<span className="text-[10px] text-muted-foreground font-normal">
|
||||||
|
(여러 개 입력 시 콤마 또는 세미콜론으로 구분)
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="sub_email"
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.users.create.form.sub_email_placeholder",
|
||||||
|
"sub1@example.com, sub2@test.com",
|
||||||
|
)}
|
||||||
|
{...register("metadata.sub_email")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">
|
<Label htmlFor="password">
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ import {
|
|||||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||||
|
|
||||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
email: string;
|
||||||
|
metadata: Record<string, Record<string, string | number | boolean>> & {
|
||||||
|
sub_email?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
type UserCategory = "hanmac" | "external" | "personal";
|
type UserCategory = "hanmac" | "external" | "personal";
|
||||||
|
|
||||||
@@ -612,6 +615,7 @@ function UserDetailPage() {
|
|||||||
: null);
|
: null);
|
||||||
|
|
||||||
reset({
|
reset({
|
||||||
|
email: user.email || "",
|
||||||
name: user.name,
|
name: user.name,
|
||||||
phone: user.phone || "",
|
phone: user.phone || "",
|
||||||
role: user.role,
|
role: user.role,
|
||||||
@@ -626,11 +630,17 @@ function UserDetailPage() {
|
|||||||
grade: user.grade || "",
|
grade: user.grade || "",
|
||||||
position: user.position || "",
|
position: user.position || "",
|
||||||
jobTitle: user.jobTitle || "",
|
jobTitle: user.jobTitle || "",
|
||||||
metadata:
|
metadata: {
|
||||||
(user.metadata as unknown as Record<
|
...((user.metadata as unknown as Record<
|
||||||
string,
|
string,
|
||||||
Record<string, string | number | boolean>
|
Record<string, string | number | boolean>
|
||||||
>) || {},
|
>) || {}),
|
||||||
|
sub_email: Array.isArray(user.metadata?.sub_email)
|
||||||
|
? user.metadata.sub_email.join(", ")
|
||||||
|
: typeof user.metadata?.sub_email === "string"
|
||||||
|
? user.metadata.sub_email
|
||||||
|
: "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const isUserHanmacFamily = isHanmacFamilyUser(
|
const isUserHanmacFamily = isHanmacFamilyUser(
|
||||||
user,
|
user,
|
||||||
@@ -742,16 +752,32 @@ function UserDetailPage() {
|
|||||||
const {
|
const {
|
||||||
hanmacFamily: _hanmacFamily,
|
hanmacFamily: _hanmacFamily,
|
||||||
userType: _userType,
|
userType: _userType,
|
||||||
|
sub_email: rawSubEmail,
|
||||||
...safeMetadata
|
...safeMetadata
|
||||||
} = cleanMetadata;
|
} = cleanMetadata;
|
||||||
|
|
||||||
|
// Parse sub_email
|
||||||
|
let sub_email: string[] = [];
|
||||||
|
if (typeof rawSubEmail === "string" && rawSubEmail.trim() !== "") {
|
||||||
|
sub_email = rawSubEmail
|
||||||
|
.split(/[;,\n\r\t]/)
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.filter((e) => e.includes("@"));
|
||||||
|
}
|
||||||
|
|
||||||
const metadata: Record<string, unknown> = {
|
const metadata: Record<string, unknown> = {
|
||||||
...safeMetadata,
|
...safeMetadata,
|
||||||
|
...(sub_email.length > 0 ? { sub_email } : { sub_email: [] }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload: UserUpdateRequest = {
|
const payload: UserUpdateRequest = {
|
||||||
...data,
|
...data,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
// email cannot be updated directly via this API in current backend implementation,
|
||||||
|
// so we delete it from payload if it spread
|
||||||
|
// @ts-ignore
|
||||||
|
delete payload.email;
|
||||||
payload.role = undefined;
|
payload.role = undefined;
|
||||||
|
|
||||||
if (userCategory === "personal") {
|
if (userCategory === "personal") {
|
||||||
@@ -928,13 +954,13 @@ function UserDetailPage() {
|
|||||||
<Mail size={14} className="text-primary/70" />
|
<Mail size={14} className="text-primary/70" />
|
||||||
{user.email}
|
{user.email}
|
||||||
</div>
|
</div>
|
||||||
{user.metadata?.secondary_emails &&
|
{user.metadata?.sub_email &&
|
||||||
Array.isArray(user.metadata.secondary_emails) &&
|
Array.isArray(user.metadata.sub_email) &&
|
||||||
user.metadata.secondary_emails.length > 0 && (
|
user.metadata.sub_email.length > 0 && (
|
||||||
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
||||||
<Mail size={14} className="text-primary/40" />
|
<Mail size={14} className="text-primary/40" />
|
||||||
<span className="text-[10px] font-bold">
|
<span className="text-[10px] font-bold">
|
||||||
+{user.metadata.secondary_emails.length}
|
+{user.metadata.sub_email.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1058,48 +1084,32 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user.metadata?.secondary_emails &&
|
|
||||||
Array.isArray(user.metadata.secondary_emails) &&
|
|
||||||
user.metadata.secondary_emails.length > 0 && (
|
|
||||||
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
||||||
<div className="space-y-3 col-span-full">
|
<div className="space-y-2 col-span-full">
|
||||||
<Label className="text-xs font-bold uppercase text-muted-foreground flex items-center gap-2">
|
<Label
|
||||||
|
htmlFor="sub_email"
|
||||||
|
className="text-xs font-bold uppercase text-muted-foreground flex items-center gap-2"
|
||||||
|
>
|
||||||
<Mail size={14} />
|
<Mail size={14} />
|
||||||
{t(
|
{t("ui.admin.users.detail.form.sub_email", "보조 이메일")}
|
||||||
"ui.admin.users.detail.form.secondary_emails",
|
<span className="text-[10px] text-muted-foreground font-normal normal-case">
|
||||||
"보조 이메일",
|
(여러 개 입력 시 콤마 또는 세미콜론으로 구분)
|
||||||
)}
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[9px] h-4 px-1.5 font-bold"
|
|
||||||
>
|
|
||||||
{user.metadata.secondary_emails.length}
|
|
||||||
</Badge>
|
|
||||||
</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(user.metadata.secondary_emails as string[]).map(
|
|
||||||
(email, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-xl border bg-muted/30 text-sm font-medium hover:border-primary/30 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-primary/70">
|
|
||||||
<Mail size={12} />
|
|
||||||
</span>
|
</span>
|
||||||
{email}
|
</Label>
|
||||||
</div>
|
<Input
|
||||||
),
|
id="sub_email"
|
||||||
)}
|
{...register("metadata.sub_email")}
|
||||||
</div>
|
className="h-11 shadow-sm"
|
||||||
<p className="text-[10px] text-muted-foreground">
|
placeholder="sub1@example.com, sub2@test.com"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.users.detail.secondary_emails_help",
|
"msg.admin.users.detail.sub_email_help",
|
||||||
"* 보조 이메일은 일괄 등록 또는 외부 연동을 통해 관리됩니다.",
|
"* 보조 이메일로도 로그인이 가능하며 계정 찾기 등에 활용될 수 있습니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
|||||||
|
|
||||||
export const downloadUserTemplate = () => {
|
export const downloadUserTemplate = () => {
|
||||||
const headers =
|
const headers =
|
||||||
"email,secondary_emails,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
"email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
||||||
const example =
|
const example =
|
||||||
"user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
"user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
||||||
const blob = new Blob([`${headers}\n${example}`], {
|
const blob = new Blob([`${headers}\n${example}`], {
|
||||||
@@ -297,7 +297,7 @@ export function UserBulkUploadModal({
|
|||||||
|
|
||||||
const downloadTemplate = () => {
|
const downloadTemplate = () => {
|
||||||
const headers =
|
const headers =
|
||||||
"email,secondary_emails,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
"email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
||||||
const example =
|
const example =
|
||||||
"user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
"user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
||||||
const blob = new Blob([`${headers}\n${example}`], {
|
const blob = new Blob([`${headers}\n${example}`], {
|
||||||
|
|||||||
@@ -350,10 +350,11 @@ function applySecondaryEmailMetadata(
|
|||||||
value: string,
|
value: string,
|
||||||
) {
|
) {
|
||||||
const emails = splitEmailTokens(value);
|
const emails = splitEmailTokens(value);
|
||||||
item.metadata.secondary_emails = uniqueEmails([
|
item.metadata.sub_email = uniqueEmails([
|
||||||
...metadataEmailList(item.metadata.secondary_emails),
|
...metadataEmailList(item.metadata.sub_email),
|
||||||
...emails,
|
...emails,
|
||||||
]);
|
]);
|
||||||
|
addWorksmobileAliasEmails(item, emails);
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitOrganizationPath(value: string) {
|
function splitOrganizationPath(value: string) {
|
||||||
|
|||||||
@@ -302,10 +302,7 @@ test.describe("Users Bulk Upload", () => {
|
|||||||
const payload = JSON.parse(bulkPayload);
|
const payload = JSON.parse(bulkPayload);
|
||||||
expect(payload.users[0].tenantSlug).toBe("primary-tenant");
|
expect(payload.users[0].tenantSlug).toBe("primary-tenant");
|
||||||
expect(payload.users[0].metadata.employee_id).toBe("EMP001");
|
expect(payload.users[0].metadata.employee_id).toBe("EMP001");
|
||||||
expect(payload.users[0].metadata.sub_email).toBe(
|
expect(payload.users[0].metadata.sub_email).toEqual([
|
||||||
"dual.alias@hanmaceng.co.kr",
|
|
||||||
);
|
|
||||||
expect(payload.users[0].metadata.secondary_emails).toEqual([
|
|
||||||
"dual.alias@hanmaceng.co.kr",
|
"dual.alias@hanmaceng.co.kr",
|
||||||
]);
|
]);
|
||||||
expect(payload.users[0].metadata.aliasEmails).toEqual([
|
expect(payload.users[0].metadata.aliasEmails).toEqual([
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ test.describe("Users Bulk Upload Secondary Emails", () => {
|
|||||||
await page.getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i }).click();
|
await page.getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i }).click();
|
||||||
|
|
||||||
// Create a mock CSV with secondary_emails
|
// Create a mock CSV with secondary_emails
|
||||||
const csvContent = `email,secondary_emails,name,phone,role,tenant_slug\ntest@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug`;
|
const csvContent = `email,sub_email,name,phone,role,tenant_slug\ntest@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug`;
|
||||||
|
|
||||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||||
await page.getByText(/파일 선택|Change file|Select file/i).click();
|
await page.getByText(/파일 선택|Change file|Select file/i).click();
|
||||||
@@ -89,7 +89,7 @@ test.describe("Users Bulk Upload Secondary Emails", () => {
|
|||||||
expect(bulkPayload.users).toHaveLength(1);
|
expect(bulkPayload.users).toHaveLength(1);
|
||||||
|
|
||||||
// The most important check - does it parse to the metadata
|
// The most important check - does it parse to the metadata
|
||||||
expect(bulkPayload.users[0].metadata.secondary_emails).toContain("sub1@test.com");
|
expect(bulkPayload.users[0].metadata.sub_email).toContain("sub1@test.com");
|
||||||
expect(bulkPayload.users[0].metadata.secondary_emails).toContain("sub2@test.com");
|
expect(bulkPayload.users[0].metadata.sub_email).toContain("sub2@test.com");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1769,7 +1769,7 @@ func collectEmailList(traits map[string]any, primaryEmail string) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if raw, ok := traits["secondary_emails"]; ok {
|
if raw, ok := traits["sub_email"]; ok {
|
||||||
switch value := raw.(type) {
|
switch value := raw.(type) {
|
||||||
case []string:
|
case []string:
|
||||||
for _, email := range value {
|
for _, email := range value {
|
||||||
|
|||||||
@@ -1226,7 +1226,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
valid := true
|
valid := true
|
||||||
// Collect all emails
|
// Collect all emails
|
||||||
allEmails := []string{userEmail}
|
allEmails := []string{userEmail}
|
||||||
if secondaryRaw, exists := item.Metadata["secondary_emails"]; exists {
|
if secondaryRaw, exists := item.Metadata["sub_email"]; exists {
|
||||||
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok {
|
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok {
|
||||||
for _, se := range secondaryEmails {
|
for _, se := range secondaryEmails {
|
||||||
if seStr, ok := se.(string); ok {
|
if seStr, ok := se.(string); ok {
|
||||||
@@ -2052,7 +2052,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
userPhone := extractTraitString(traits, "phone_number")
|
userPhone := extractTraitString(traits, "phone_number")
|
||||||
|
|
||||||
allEmails := []string{userEmail}
|
allEmails := []string{userEmail}
|
||||||
if secondaryRaw, exists := traits["secondary_emails"]; exists {
|
if secondaryRaw, exists := traits["sub_email"]; exists {
|
||||||
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok {
|
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok {
|
||||||
for _, se := range secondaryEmails {
|
for _, se := range secondaryEmails {
|
||||||
if seStr, ok := se.(string); ok {
|
if seStr, ok := se.(string); ok {
|
||||||
|
|||||||
Reference in New Issue
Block a user