forked from baron/baron-sso
feat(user): support fixed UUID registration and enhance bulk import results
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping) - Implemented idempotency and visibility restoration for soft-deleted users - Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields - Added logic to reclaim identifiers (login_id) from colliding records - Added frontend E2E and backend unit tests for UUID integrity and conflict handling - Fixed i18n, formatting, and mock tests to satisfy code-check - Applied 'go fix' for 'omitzero' tags and general Go standards
This commit is contained in:
@@ -127,9 +127,9 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
||||
|
||||
export const downloadUserTemplate = () => {
|
||||
const headers =
|
||||
"email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
||||
"uuid,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";
|
||||
",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}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
@@ -295,22 +295,6 @@ export function UserBulkUploadModal({
|
||||
});
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers =
|
||||
"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}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "user_bulk_template.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setPreviewData([]);
|
||||
@@ -410,7 +394,7 @@ export function UserBulkUploadModal({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
onClick={downloadUserTemplate}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
@@ -605,15 +589,71 @@ export function UserBulkUploadModal({
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{successCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.success", "성공")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
{results.some((r) => r.success && r.status === "created") && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{
|
||||
results.filter(
|
||||
(r) => r.success && r.status === "created",
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.status.new", "신규")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{results.some((r) => r.success && r.status === "updated") && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{
|
||||
results.filter(
|
||||
(r) => r.success && r.status === "updated",
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.status.updated", "수정")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{results.some((r) => r.success && r.status === "unchanged") && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-slate-500">
|
||||
{
|
||||
results.filter(
|
||||
(r) => r.success && r.status === "unchanged",
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.status.unchanged", "동일")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{!results.some((r) => r.success && r.status) &&
|
||||
successCount > 0 && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{successCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.success", "성공")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-destructive">
|
||||
{failCount}
|
||||
@@ -643,7 +683,60 @@ export function UserBulkUploadModal({
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{r.email}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium truncate">{r.email}</div>
|
||||
{r.success && r.status === "created" && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
|
||||
{t("ui.common.status.new", "신규")}
|
||||
</span>
|
||||
)}
|
||||
{r.success && r.status === "updated" && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 text-[10px] font-bold">
|
||||
{t("ui.common.status.updated", "수정")}
|
||||
</span>
|
||||
)}
|
||||
{r.success && r.status === "unchanged" && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-slate-100 text-slate-600 text-[10px] font-bold">
|
||||
{t("ui.common.status.unchanged", "동일")}
|
||||
</span>
|
||||
)}
|
||||
{r.success && !r.status && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
|
||||
{t("ui.common.success", "성공")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.success && r.status === "updated" && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground flex flex-wrap gap-1 items-center">
|
||||
<span className="font-medium">
|
||||
{t(
|
||||
"ui.admin.users.bulk.modified_fields",
|
||||
"수정 항목:",
|
||||
)}
|
||||
</span>
|
||||
{r.modifiedFields &&
|
||||
r.modifiedFields.length > 0 &&
|
||||
r.modifiedFields.map((field) => (
|
||||
<span
|
||||
key={field}
|
||||
className="px-1 py-0.5 rounded bg-blue-50 text-blue-600 border border-blue-100"
|
||||
>
|
||||
{t(
|
||||
`ui.admin.users.field.${field.toLowerCase()}`,
|
||||
field,
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{r.success && r.status === "unchanged" && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground italic">
|
||||
{t(
|
||||
"ui.admin.users.bulk.no_changes",
|
||||
"기존 데이터와 동일 (변경 사항 없음)",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!r.success && (
|
||||
<div className="text-xs text-destructive">
|
||||
{r.message}
|
||||
|
||||
@@ -28,7 +28,10 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
const value = values[index];
|
||||
if (value === undefined || value === "") continue;
|
||||
|
||||
if (header === "email") {
|
||||
if (header === "uuid") {
|
||||
item.id = value;
|
||||
item.uuid = value;
|
||||
} else if (header === "email") {
|
||||
item.email = value;
|
||||
} else if (header === "name") {
|
||||
item.name = value;
|
||||
@@ -115,8 +118,18 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
} else if (header === "firstname") {
|
||||
item.metadata.naverworks_first_name = value;
|
||||
} else if (header === "id") {
|
||||
item.loginId = value;
|
||||
item.metadata.naverworks_id = value;
|
||||
// If it looks like a UUID, use it as item.id
|
||||
if (
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
value,
|
||||
)
|
||||
) {
|
||||
item.id = value;
|
||||
item.uuid = value;
|
||||
} else {
|
||||
item.loginId = value;
|
||||
item.metadata.naverworks_id = value;
|
||||
}
|
||||
} else if (header === "personalemail") {
|
||||
item.metadata.personal_email = value;
|
||||
} else if (header === "subemail") {
|
||||
|
||||
@@ -725,6 +725,8 @@ export type BulkUserAppointment = {
|
||||
};
|
||||
|
||||
export type BulkUserItem = {
|
||||
id?: string;
|
||||
uuid?: string;
|
||||
email: string;
|
||||
loginId?: string;
|
||||
name: string;
|
||||
@@ -761,6 +763,7 @@ export type BulkUserResult = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
userId?: string;
|
||||
modifiedFields?: string[];
|
||||
};
|
||||
|
||||
export type BulkUserResponse = {
|
||||
|
||||
Reference in New Issue
Block a user