1
0
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:
2026-05-29 11:07:59 +09:00
parent 00310448e9
commit 62b1938c42
8 changed files with 112 additions and 66 deletions

View File

@@ -58,7 +58,11 @@ import {
import type { UserSchemaField } from "./userSchemaFields";
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 PickerTarget = { kind: "appointment"; index: number };
@@ -161,7 +165,9 @@ function UserCreatePage() {
position: "",
jobTitle: "",
role: "user",
metadata: {},
metadata: {
sub_email: "",
},
},
});
@@ -367,10 +373,22 @@ function UserCreatePage() {
const {
hanmacFamily: _hanmacFamily,
userType: _userType,
sub_email: rawSubEmail,
...formMetadata
} = 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> = {
...formMetadata,
...(sub_email.length > 0 ? { sub_email } : { sub_email: [] }),
};
const payload: UserCreateRequest = {
@@ -580,6 +598,26 @@ function UserCreatePage() {
)}
</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="flex items-center justify-between">
<Label htmlFor="password">

View File

@@ -93,7 +93,10 @@ import {
import { resolvePersonalTenant } from "./utils/personalTenant";
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";
@@ -612,6 +615,7 @@ function UserDetailPage() {
: null);
reset({
email: user.email || "",
name: user.name,
phone: user.phone || "",
role: user.role,
@@ -626,11 +630,17 @@ function UserDetailPage() {
grade: user.grade || "",
position: user.position || "",
jobTitle: user.jobTitle || "",
metadata:
(user.metadata as unknown as Record<
metadata: {
...((user.metadata as unknown as Record<
string,
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(
user,
@@ -742,16 +752,32 @@ function UserDetailPage() {
const {
hanmacFamily: _hanmacFamily,
userType: _userType,
sub_email: rawSubEmail,
...safeMetadata
} = 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> = {
...safeMetadata,
...(sub_email.length > 0 ? { sub_email } : { sub_email: [] }),
};
const payload: UserUpdateRequest = {
...data,
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;
if (userCategory === "personal") {
@@ -928,13 +954,13 @@ function UserDetailPage() {
<Mail size={14} className="text-primary/70" />
{user.email}
</div>
{user.metadata?.secondary_emails &&
Array.isArray(user.metadata.secondary_emails) &&
user.metadata.secondary_emails.length > 0 && (
{user.metadata?.sub_email &&
Array.isArray(user.metadata.sub_email) &&
user.metadata.sub_email.length > 0 && (
<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" />
<span className="text-[10px] font-bold">
+{user.metadata.secondary_emails.length}
+{user.metadata.sub_email.length}
</span>
</div>
)}
@@ -1058,48 +1084,32 @@ function UserDetailPage() {
</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="space-y-3 col-span-full">
<Label className="text-xs font-bold uppercase text-muted-foreground flex items-center gap-2">
<Mail size={14} />
{t(
"ui.admin.users.detail.form.secondary_emails",
"보조 이메일",
)}
<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>
{email}
</div>
),
)}
</div>
<p className="text-[10px] text-muted-foreground">
{t(
"msg.admin.users.detail.secondary_emails_help",
"* 보조 이메일은 일괄 등록 또는 외부 연동을 통해 관리됩니다.",
)}
</p>
</div>
</div>
)}
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
<div className="space-y-2 col-span-full">
<Label
htmlFor="sub_email"
className="text-xs font-bold uppercase text-muted-foreground flex items-center gap-2"
>
<Mail size={14} />
{t("ui.admin.users.detail.form.sub_email", "보조 이메일")}
<span className="text-[10px] text-muted-foreground font-normal normal-case">
( )
</span>
</Label>
<Input
id="sub_email"
{...register("metadata.sub_email")}
className="h-11 shadow-sm"
placeholder="sub1@example.com, sub2@test.com"
/>
<p className="text-[10px] text-muted-foreground mt-1">
{t(
"msg.admin.users.detail.sub_email_help",
"* 보조 이메일로도 로그인이 가능하며 계정 찾기 등에 활용될 수 있습니다.",
)}
</p>
</div>
</div>
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
<div className="space-y-2">

View File

@@ -127,7 +127,7 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
export const downloadUserTemplate = () => {
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 =
"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}`], {
@@ -297,7 +297,7 @@ export function UserBulkUploadModal({
const downloadTemplate = () => {
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 =
"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}`], {

View File

@@ -350,10 +350,11 @@ function applySecondaryEmailMetadata(
value: string,
) {
const emails = splitEmailTokens(value);
item.metadata.secondary_emails = uniqueEmails([
...metadataEmailList(item.metadata.secondary_emails),
item.metadata.sub_email = uniqueEmails([
...metadataEmailList(item.metadata.sub_email),
...emails,
]);
addWorksmobileAliasEmails(item, emails);
}
function splitOrganizationPath(value: string) {