@@ -1146,6 +1190,37 @@ function UserDetailPage() {
className="h-11 shadow-sm"
/>
+
diff --git a/adminfront/src/features/users/components/UserBulkUploadModal.tsx b/adminfront/src/features/users/components/UserBulkUploadModal.tsx
index 1c473094..434e76bc 100644
--- a/adminfront/src/features/users/components/UserBulkUploadModal.tsx
+++ b/adminfront/src/features/users/components/UserBulkUploadModal.tsx
@@ -115,6 +115,13 @@ function hanmacEmailStatusLabel(preview?: HanmacImportEmailPreview) {
return "";
}
+function userImportErrorLabel(user: BulkUserItem) {
+ if (!user.importErrors?.includes("duplicateEmail")) {
+ return "";
+ }
+ return "중복 이메일";
+}
+
function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
if (!preview) return "text-muted-foreground";
if (preview.status === "blockingError") return "text-destructive";
@@ -355,6 +362,9 @@ export function UserBulkUploadModal({
const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some(
(preview) => preview?.status === "blockingError",
);
+ const hasBlockingImportRows = previewData.some(
+ (user) => (user.importErrors?.length ?? 0) > 0,
+ );
const triggerProps = {
disabled: mutation.isPending,
@@ -576,11 +586,22 @@ export function UserBulkUploadModal({
{u.name} |
{u.tenantSlug || "-"} |
- {hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
+ {u.importErrors?.length
+ ? "오류"
+ : hanmacEmailStatusLabel(
+ hanmacEmailPreviews[index],
+ )}
+ {u.importErrors?.length ? (
+ {userImportErrorLabel(u)}
+ ) : null}
{hanmacEmailPreviews[index]?.reason && (
{hanmacEmailPreviews[index]?.reason}
)}
@@ -665,7 +686,8 @@ export function UserBulkUploadModal({
previewData.length === 0 ||
mutation.isPending ||
preparing ||
- hasBlockingHanmacEmailRows
+ hasBlockingHanmacEmailRows ||
+ hasBlockingImportRows
}
className="w-full sm:w-auto"
data-testid="bulk-start-btn"
diff --git a/adminfront/src/features/users/utils/csvParser.test.ts b/adminfront/src/features/users/utils/csvParser.test.ts
index 101ca7e7..55b4aba0 100644
--- a/adminfront/src/features/users/utils/csvParser.test.ts
+++ b/adminfront/src/features/users/utils/csvParser.test.ts
@@ -97,6 +97,22 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl
});
});
+ it("should preserve exported user_id for UUID based restore", () => {
+ const csv = `user_id,email,name,tenant_id,tenant_slug
+9f8cc1b1-af8d-45d4-946c-924a529c2556,restore@test.com,Restore User,tenant-id,restore-tenant`;
+
+ const result = parseUserCSV(csv);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ userId: "9f8cc1b1-af8d-45d4-946c-924a529c2556",
+ email: "restore@test.com",
+ name: "Restore User",
+ tenantId: "tenant-id",
+ tenantSlug: "restore-tenant",
+ });
+ });
+
it("should parse one nullable additional appointment from numbered columns", () => {
const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002
@@ -146,4 +162,28 @@ primary@samaneng.com,Primary User,rnd-saman,EMP001,secondary@hanmaceng.co.kr`;
},
});
});
+
+ it("should mark duplicate bulk alias emails as blocking import errors", () => {
+ const csv = `email,name,tenant_slug,sub_email
+user1@samaneng.com,User One,rnd-saman,shared@hanmaceng.co.kr
+user2@samaneng.com,User Two,rnd-saman,shared@hanmaceng.co.kr`;
+
+ const result = parseUserCSV(csv);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].importErrors).toContain("duplicateEmail");
+ expect(result[1].importErrors).toContain("duplicateEmail");
+ });
+
+ it("should mark a primary email reused as a sub email as a blocking import error", () => {
+ const csv = `email,name,tenant_slug,sub_email
+user1@samaneng.com,User One,rnd-saman,user2@samaneng.com
+user2@samaneng.com,User Two,rnd-saman,alias@hanmaceng.co.kr`;
+
+ const result = parseUserCSV(csv);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].importErrors).toContain("duplicateEmail");
+ expect(result[1].importErrors).toContain("duplicateEmail");
+ });
});
diff --git a/adminfront/src/features/users/utils/csvParser.ts b/adminfront/src/features/users/utils/csvParser.ts
index 16673a65..439e6a63 100644
--- a/adminfront/src/features/users/utils/csvParser.ts
+++ b/adminfront/src/features/users/utils/csvParser.ts
@@ -28,7 +28,9 @@ export function parseUserCSV(text: string): BulkUserItem[] {
const value = values[index];
if (value === undefined || value === "") continue;
- if (header === "email") {
+ if (header === "user_id") {
+ item.userId = value;
+ } else if (header === "email") {
item.email = value;
} else if (header === "name") {
item.name = value;
@@ -186,7 +188,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
}
}
- return data;
+ return markBulkEmailDuplicateErrors(data);
}
function cleanAdditionalAppointment(
@@ -335,6 +337,66 @@ function uniqueEmails(values: string[]) {
return result;
}
+function bulkUserImportErrorList(user: BulkUserItem) {
+ return Array.isArray(user.importErrors) ? user.importErrors : [];
+}
+
+function withBulkUserImportError(user: BulkUserItem, error: string) {
+ const errors = Array.from(new Set([...bulkUserImportErrorList(user), error]));
+ return { ...user, importErrors: errors };
+}
+
+function bulkUserAliasEmails(user: BulkUserItem) {
+ return uniqueEmails([
+ ...metadataEmailList(user.metadata.sub_email),
+ ...metadataEmailList(user.metadata.aliasEmails),
+ ...metadataEmailList(user.metadata.secondary_emails),
+ ...metadataEmailList(user.metadata.worksmobileAliasEmails),
+ ]);
+}
+
+function markBulkEmailDuplicateErrors(users: BulkUserItem[]) {
+ const duplicateIndexes = new Set();
+ const owners = new Map>();
+
+ users.forEach((user, index) => {
+ const primaryEmail = user.email.trim().toLowerCase();
+ const aliases = bulkUserAliasEmails(user);
+ const rowEmails = new Set();
+
+ if (primaryEmail) {
+ rowEmails.add(primaryEmail);
+ }
+ for (const alias of aliases) {
+ if (primaryEmail && alias === primaryEmail) {
+ duplicateIndexes.add(index);
+ }
+ rowEmails.add(alias);
+ }
+
+ for (const email of rowEmails) {
+ const existing = owners.get(email) ?? new Set();
+ existing.add(index);
+ owners.set(email, existing);
+ }
+ });
+
+ for (const indexes of owners.values()) {
+ if (indexes.size < 2) {
+ continue;
+ }
+ for (const index of indexes) {
+ duplicateIndexes.add(index);
+ }
+ }
+
+ return users.map((user, index) =>
+ duplicateIndexes.has(index)
+ ? withBulkUserImportError(user, "duplicateEmail")
+ : user,
+ );
+}
+
function addWorksmobileAliasEmails(
item: Partial & { metadata: Record },
emails: string[],
diff --git a/adminfront/src/lib/adminApi.contract.test.ts b/adminfront/src/lib/adminApi.contract.test.ts
index 5c6de00e..f5da134a 100644
--- a/adminfront/src/lib/adminApi.contract.test.ts
+++ b/adminfront/src/lib/adminApi.contract.test.ts
@@ -73,7 +73,12 @@ describe("adminApi endpoint contracts", () => {
await adminApi.fetchUser("user-1");
await adminApi.fetchWorksmobileOverview("tenant-1");
await adminApi.fetchWorksmobileComparison("tenant-1", true);
+ await adminApi.fetchWorksmobileCredentialBatches("tenant-1");
await adminApi.downloadWorksmobileInitialPasswordsCSV("tenant-1");
+ await adminApi.downloadWorksmobileInitialPasswordsCSV(
+ "tenant-1",
+ "credential-batch-1",
+ );
await adminApi.fetchPasswordPolicy();
await adminApi.fetchUserRpHistory("user-1");
await adminApi.fetchMe();
@@ -104,6 +109,16 @@ describe("adminApi endpoint contracts", () => {
"/v1/admin/tenants/tenant-1/worksmobile/comparison",
{ params: { includeMatched: true } },
);
+ expect(apiClient.get).toHaveBeenCalledWith(
+ "/v1/admin/tenants/tenant-1/worksmobile/credential-batches",
+ );
+ expect(apiClient.get).toHaveBeenCalledWith(
+ "/v1/admin/tenants/tenant-1/worksmobile/initial-passwords.csv",
+ {
+ params: { batchId: "credential-batch-1" },
+ responseType: "blob",
+ },
+ );
expect(await adminApi.exportTenantsCSV(true, "parent-1")).toMatchObject({
filename: "export.csv",
});
@@ -148,6 +163,20 @@ describe("adminApi endpoint contracts", () => {
await adminApi.enqueueWorksmobileOrgUnitSync("tenant-1", "org/unit");
await adminApi.enqueueWorksmobileOrgUnitDelete("tenant-1", "org/unit");
await adminApi.enqueueWorksmobileUserSync("tenant-1", "user-1");
+ await adminApi.enqueueWorksmobileUserSync(
+ "tenant-1",
+ "user-2",
+ "credential-batch-1",
+ );
+ await adminApi.resetWorksmobileUserPassword(
+ "tenant-1",
+ "user-2",
+ "credential-batch-2",
+ );
+ await adminApi.deleteWorksmobileCredentialBatchPasswords(
+ "tenant-1",
+ "credential-batch-1",
+ );
await adminApi.retryWorksmobileJob("tenant-1", "job-1");
await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" });
await adminApi.bulkDeleteUsers(["user-1"]);
@@ -178,6 +207,17 @@ describe("adminApi endpoint contracts", () => {
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/tenants/tenant-1/worksmobile/orgunits/org%2Funit/sync",
);
+ expect(apiClient.post).toHaveBeenCalledWith(
+ "/v1/admin/tenants/tenant-1/worksmobile/users/user-2/sync",
+ { credentialBatchId: "credential-batch-1" },
+ );
+ expect(apiClient.post).toHaveBeenCalledWith(
+ "/v1/admin/tenants/tenant-1/worksmobile/users/user-2/password/reset",
+ { credentialBatchId: "credential-batch-2" },
+ );
+ expect(apiClient.delete).toHaveBeenCalledWith(
+ "/v1/admin/tenants/tenant-1/worksmobile/credential-batches/credential-batch-1/passwords",
+ );
expect(apiClient.delete).toHaveBeenCalledWith(
"/v1/admin/relying-parties/client-1/owners/User:user-1",
);
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts
index f75e56d2..1032b5a8 100644
--- a/adminfront/src/lib/adminApi.ts
+++ b/adminfront/src/lib/adminApi.ts
@@ -676,6 +676,7 @@ export type UserCreateResponse = UserSummary & {
};
export type UserUpdateRequest = {
+ email?: string;
loginId?: string;
password?: string;
name?: string;
@@ -725,6 +726,7 @@ export type BulkUserAppointment = {
};
export type BulkUserItem = {
+ userId?: string;
email: string;
loginId?: string;
name: string;
@@ -750,6 +752,7 @@ export type BulkUserItem = {
emailDomain?: string;
};
metadata: Record;
+ importErrors?: string[];
};
export type BulkUserResult = {
@@ -790,6 +793,30 @@ export type WorksmobileOverview = {
recentJobs: WorksmobileOutboxItem[];
};
+export type WorksmobileCredentialBatch = {
+ batchId: string;
+ operation?: string;
+ userCount: number;
+ pendingCount?: number;
+ processingCount?: number;
+ processedCount?: number;
+ failedCount?: number;
+ hasPasswords: boolean;
+ deletedAt?: string;
+ failures?: WorksmobileCredentialBatchFailure[];
+ createdAt?: string;
+ updatedAt?: string;
+};
+
+export type WorksmobileCredentialBatchFailure = {
+ userId?: string;
+ email?: string;
+ status: string;
+ retryCount: number;
+ lastError?: string;
+ updatedAt?: string;
+};
+
export type WorksmobileComparisonItem = {
resourceType: string;
baronId?: string;
@@ -823,6 +850,10 @@ export type WorksmobileComparisonItem = {
worksmobileParentName?: string;
worksmobileParentEmail?: string;
worksmobileParentExternalKey?: string;
+ worksmobileJobStatus?: string;
+ worksmobileJobRetryCount?: number;
+ worksmobileLastError?: string;
+ worksmobileLastAttemptAt?: string;
status: string;
};
@@ -906,10 +937,22 @@ export async function fetchWorksmobileComparison(
return data;
}
-export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
+export async function fetchWorksmobileCredentialBatches(tenantId: string) {
+ const { data } = await apiClient.get(
+ `/v1/admin/tenants/${tenantId}/worksmobile/credential-batches`,
+ );
+ return data;
+}
+
+export async function downloadWorksmobileInitialPasswordsCSV(
+ tenantId: string,
+ batchId?: string,
+) {
+ const trimmedBatchId = batchId?.trim();
const response = await apiClient.get(
`/v1/admin/tenants/${tenantId}/worksmobile/initial-passwords.csv`,
{
+ ...(trimmedBatchId ? { params: { batchId: trimmedBatchId } } : {}),
responseType: "blob",
},
);
@@ -924,6 +967,16 @@ export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
};
}
+export async function deleteWorksmobileCredentialBatchPasswords(
+ tenantId: string,
+ batchId: string,
+) {
+ const { data } = await apiClient.delete(
+ `/v1/admin/tenants/${tenantId}/worksmobile/credential-batches/${encodeURIComponent(batchId)}/passwords`,
+ );
+ return data;
+}
+
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
const { data } = await apiClient.post(
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,
@@ -954,10 +1007,30 @@ export async function enqueueWorksmobileOrgUnitDelete(
export async function enqueueWorksmobileUserSync(
tenantId: string,
userId: string,
+ credentialBatchId?: string,
) {
- const { data } = await apiClient.post(
- `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`,
- );
+ const trimmedBatchId = credentialBatchId?.trim();
+ const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`;
+ const { data } = trimmedBatchId
+ ? await apiClient.post(path, {
+ credentialBatchId: trimmedBatchId,
+ })
+ : await apiClient.post(path);
+ return data;
+}
+
+export async function resetWorksmobileUserPassword(
+ tenantId: string,
+ userId: string,
+ credentialBatchId?: string,
+) {
+ const trimmedBatchId = credentialBatchId?.trim();
+ const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/password/reset`;
+ const { data } = trimmedBatchId
+ ? await apiClient.post(path, {
+ credentialBatchId: trimmedBatchId,
+ })
+ : await apiClient.post(path);
return data;
}
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index f94a7e72..153f40e2 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -750,11 +750,14 @@ func main() {
admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview)
admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison)
+ admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches)
+ admin.Delete("/tenants/:tenantId/worksmobile/credential-batches/:batchId/passwords", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteCredentialBatchPasswords)
admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV)
admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun)
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit)
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
+ admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword)
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
diff --git a/backend/internal/domain/worksmobile.go b/backend/internal/domain/worksmobile.go
index a21f17d8..7beb9c73 100644
--- a/backend/internal/domain/worksmobile.go
+++ b/backend/internal/domain/worksmobile.go
@@ -20,10 +20,11 @@ const (
)
const (
- WorksmobileActionUpsert = "UPSERT"
- WorksmobileActionDelete = "DELETE"
- WorksmobileActionDryRun = "DRY_RUN"
- WorksmobileActionSuspend = "SUSPEND"
+ WorksmobileActionUpsert = "UPSERT"
+ WorksmobileActionDelete = "DELETE"
+ WorksmobileActionDryRun = "DRY_RUN"
+ WorksmobileActionSuspend = "SUSPEND"
+ WorksmobileActionPasswordReset = "PASSWORD_RESET"
)
type WorksmobileOutbox struct {
diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go
index 40e4bb8a..6a2f692e 100644
--- a/backend/internal/handler/user_handler.go
+++ b/backend/internal/handler/user_handler.go
@@ -13,8 +13,10 @@ import (
"fmt"
"log/slog"
"net/http"
+ "net/mail"
"os"
"regexp"
+ "sort"
"strconv"
"strings"
"time"
@@ -850,6 +852,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
type bulkUserItem struct {
+ UserID string `json:"userId"`
Email string `json:"email"`
LoginID string `json:"loginId"`
Name string `json:"name"`
@@ -906,6 +909,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
var hanmacScope *hanmacEmailScope
var hanmacLocalParts map[string]bool
hanmacScopeLoaded := false
+ bulkEmailErrors := validateBulkUserEmailUniqueness(req.Users)
// Pre-fetch tenant data to avoid redundant DB calls
type tenantCacheItem struct {
@@ -1011,7 +1015,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
}
- for _, item := range req.Users {
+ for index, item := range req.Users {
email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name)
tenantID := strings.TrimSpace(item.TenantID)
@@ -1026,6 +1030,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
continue
}
+ if message, exists := bulkEmailErrors[index]; exists {
+ results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: message})
+ continue
+ }
var tItem tenantCacheItem
var err error
@@ -1192,6 +1200,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
item.Metadata["additionalAppointments"] = resolvedAppointments
}
+ normalizeBulkUserAliasMetadata(item.Metadata)
item.Metadata = sanitizeUserMetadata(item.Metadata)
password, _ := utils.GeneratePasswordWithPolicy(policy)
@@ -1252,6 +1261,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
+ ID: strings.TrimSpace(item.UserID),
Email: userEmail,
Name: item.Name,
PhoneNumber: userPhone,
@@ -1845,6 +1855,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
var req struct {
+ Email *string `json:"email"`
LoginID *string `json:"loginId"`
Password *string `json:"password"`
Name *string `json:"name"`
@@ -1948,6 +1959,31 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if traits == nil {
traits = map[string]interface{}{}
}
+ if req.Email != nil {
+ currentEmail := strings.TrimSpace(extractTraitString(traits, "email"))
+ nextEmail := strings.ToLower(strings.TrimSpace(*req.Email))
+ if nextEmail == "" {
+ return errorJSON(c, fiber.StatusBadRequest, "email is required")
+ }
+ parsed, parseErr := mail.ParseAddress(nextEmail)
+ if parseErr != nil || !strings.EqualFold(parsed.Address, nextEmail) {
+ return errorJSON(c, fiber.StatusBadRequest, "invalid email")
+ }
+ if !strings.EqualFold(currentEmail, nextEmail) {
+ if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
+ return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user email")
+ }
+ if h.UserRepo != nil {
+ if existing, err := h.UserRepo.FindByEmail(c.Context(), nextEmail); err == nil && existing != nil && existing.ID != userID {
+ return errorJSON(c, fiber.StatusConflict, "email is already used by another user")
+ }
+ if taken, err := h.UserRepo.IsLoginIDTaken(c.Context(), nextEmail); err == nil && taken {
+ return errorJSON(c, fiber.StatusConflict, "email is already used as a login ID")
+ }
+ }
+ traits["email"] = nextEmail
+ }
+ }
delete(traits, "hanmacFamily")
delete(traits, "userType")
@@ -2048,6 +2084,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}
}
+ if subEmailRaw, exists := req.Metadata["sub_email"]; exists {
+ subEmails := normalizeUserSubEmailValues(subEmailRaw)
+ traits["sub_email"] = subEmails
+ traits["aliasEmails"] = subEmails
+ traits["secondary_emails"] = subEmails
+ traits["worksmobileAliasEmails"] = subEmails
+ }
// [LoginID Sync based on Tenant Settings]
// Perform sync AFTER metadata merge to ensure traits contains current values
@@ -2860,6 +2903,156 @@ func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
}
}
+func normalizeUserSubEmailValues(raw any) []interface{} {
+ values := make([]string, 0)
+ switch typed := raw.(type) {
+ case []string:
+ values = append(values, typed...)
+ case []interface{}:
+ for _, item := range typed {
+ values = append(values, fmt.Sprint(item))
+ }
+ case string:
+ values = append(values, typed)
+ default:
+ if raw != nil {
+ values = append(values, fmt.Sprint(raw))
+ }
+ }
+
+ seen := map[string]bool{}
+ result := make([]interface{}, 0, len(values))
+ for _, value := range values {
+ for _, part := range strings.Split(value, ",") {
+ normalized := strings.ToLower(strings.TrimSpace(part))
+ if normalized == "" || seen[normalized] {
+ continue
+ }
+ seen[normalized] = true
+ result = append(result, normalized)
+ }
+ }
+ return result
+}
+
+func validateBulkUserEmailUniqueness(users []bulkUserItem) map[int]string {
+ owners := map[string]map[int]bool{}
+ errorsByIndex := map[int]string{}
+
+ for index, user := range users {
+ primaryEmail := normalizeBulkUserEmail(user.Email)
+ aliases := bulkUserAliasEmailSet(user.Metadata)
+ rowEmails := map[string]bool{}
+ if primaryEmail != "" {
+ rowEmails[primaryEmail] = true
+ }
+ for alias := range aliases {
+ if primaryEmail != "" && alias == primaryEmail {
+ errorsByIndex[index] = "duplicate email in bulk request: " + alias
+ }
+ rowEmails[alias] = true
+ }
+ for email := range rowEmails {
+ if owners[email] == nil {
+ owners[email] = map[int]bool{}
+ }
+ owners[email][index] = true
+ }
+ }
+
+ for email, indexes := range owners {
+ if len(indexes) < 2 {
+ continue
+ }
+ for index := range indexes {
+ errorsByIndex[index] = "duplicate email in bulk request: " + email
+ }
+ }
+
+ return errorsByIndex
+}
+
+func normalizeBulkUserAliasMetadata(metadata map[string]any) {
+ if metadata == nil {
+ return
+ }
+ aliases := bulkUserAliasEmailSet(metadata)
+ hasAliasField := false
+ for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
+ if _, exists := metadata[key]; exists {
+ hasAliasField = true
+ break
+ }
+ }
+ if !hasAliasField {
+ return
+ }
+ values := make([]interface{}, 0, len(aliases))
+ for alias := range aliases {
+ values = append(values, alias)
+ }
+ sort.Slice(values, func(i, j int) bool {
+ return fmt.Sprint(values[i]) < fmt.Sprint(values[j])
+ })
+ metadata["sub_email"] = values
+ metadata["aliasEmails"] = values
+ metadata["secondary_emails"] = values
+ metadata["worksmobileAliasEmails"] = values
+}
+
+func bulkUserAliasEmailSet(metadata map[string]any) map[string]bool {
+ aliases := map[string]bool{}
+ for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
+ for _, value := range bulkUserEmailValues(metadata[key]) {
+ aliases[value] = true
+ }
+ }
+ return aliases
+}
+
+func bulkUserEmailValues(raw any) []string {
+ values := make([]string, 0)
+ switch typed := raw.(type) {
+ case []string:
+ values = append(values, typed...)
+ case []interface{}:
+ for _, item := range typed {
+ values = append(values, fmt.Sprint(item))
+ }
+ case string:
+ values = append(values, typed)
+ default:
+ if raw != nil {
+ values = append(values, fmt.Sprint(raw))
+ }
+ }
+
+ result := make([]string, 0, len(values))
+ for _, value := range values {
+ for _, token := range strings.FieldsFunc(value, func(r rune) bool {
+ return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t'
+ }) {
+ email := normalizeBulkUserEmail(token)
+ if email != "" {
+ result = append(result, email)
+ }
+ }
+ }
+ return result
+}
+
+func normalizeBulkUserEmail(value string) string {
+ normalized := strings.ToLower(strings.TrimSpace(value))
+ if normalized == "" {
+ return ""
+ }
+ parsed, err := mail.ParseAddress(normalized)
+ if err != nil {
+ return normalized
+ }
+ return strings.ToLower(strings.TrimSpace(parsed.Address))
+}
+
func formatTime(value time.Time) string {
if value.IsZero() {
return ""
diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go
index 91563777..876c7931 100644
--- a/backend/internal/handler/user_handler_test.go
+++ b/backend/internal/handler/user_handler_test.go
@@ -600,6 +600,171 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
})
}
+func TestUserHandler_BulkCreateUsersPreservesRequestedUserID(t *testing.T) {
+ app := fiber.New()
+ mockKratos := new(MockKratosAdmin)
+ mockOry := new(MockOryProvider)
+ mockTenant := new(MockTenantServiceForUser)
+ const requestedUserID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
+
+ h := &UserHandler{
+ KratosAdmin: mockKratos,
+ OryProvider: mockOry,
+ TenantService: mockTenant,
+ }
+
+ app.Post("/users/bulk", h.BulkCreateUsers)
+
+ mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
+ mockTenant.On("GetTenant", mock.Anything, "tenant-123").Return(&domain.Tenant{
+ ID: "tenant-123",
+ Slug: "restore-tenant",
+ Config: domain.JSONMap{},
+ }, nil).Once()
+ mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
+ return user != nil && user.ID == requestedUserID && user.Email == "restore@test.com"
+ }), mock.Anything).Return(requestedUserID, nil).Once()
+
+ payload := map[string]any{
+ "users": []map[string]any{
+ {
+ "userId": requestedUserID,
+ "email": "restore@test.com",
+ "name": "Restore User",
+ "tenantId": "tenant-123",
+ "tenantSlug": "restore-tenant",
+ "metadata": map[string]any{},
+ },
+ },
+ }
+ body, _ := json.Marshal(payload)
+ req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, _ := app.Test(req)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var result map[string]any
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
+ results := result["results"].([]any)
+ require.Len(t, results, 1)
+ row := results[0].(map[string]any)
+ assert.True(t, row["success"].(bool))
+ assert.Equal(t, requestedUserID, row["userId"])
+ mockOry.AssertExpectations(t)
+}
+
+func TestUserHandler_BulkCreateUsersRejectsDuplicateAliasEmailsInBatch(t *testing.T) {
+ app := fiber.New()
+ mockKratos := new(MockKratosAdmin)
+ mockOry := new(MockOryProvider)
+ mockTenant := new(MockTenantServiceForUser)
+ h := &UserHandler{
+ KratosAdmin: mockKratos,
+ OryProvider: mockOry,
+ TenantService: mockTenant,
+ }
+ app.Post("/users/bulk", h.BulkCreateUsers)
+
+ mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
+
+ payload := map[string]interface{}{
+ "users": []map[string]interface{}{
+ {
+ "email": "user1@samaneng.com",
+ "name": "User One",
+ "tenantSlug": "rnd-saman",
+ "metadata": map[string]interface{}{
+ "sub_email": []interface{}{"shared@hanmaceng.co.kr"},
+ },
+ },
+ {
+ "email": "user2@samaneng.com",
+ "name": "User Two",
+ "tenantSlug": "rnd-saman",
+ "metadata": map[string]interface{}{
+ "worksmobileAliasEmails": []interface{}{"shared@hanmaceng.co.kr"},
+ },
+ },
+ },
+ }
+ body, _ := json.Marshal(payload)
+ req := httptest.NewRequest(http.MethodPost, "/users/bulk", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var result map[string]interface{}
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
+ results := result["results"].([]interface{})
+ require.Len(t, results, 2)
+ for _, item := range results {
+ row := item.(map[string]interface{})
+ require.False(t, row["success"].(bool))
+ require.Equal(t, "blockingError", row["status"])
+ require.Contains(t, row["message"].(string), "duplicate email")
+ }
+ mockOry.AssertExpectations(t)
+ mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
+ mockTenant.AssertNotCalled(t, "GetTenantBySlug", mock.Anything, mock.Anything)
+}
+
+func TestUserHandler_BulkCreateUsersRejectsPrimaryEmailUsedAsSubEmail(t *testing.T) {
+ app := fiber.New()
+ mockKratos := new(MockKratosAdmin)
+ mockOry := new(MockOryProvider)
+ mockTenant := new(MockTenantServiceForUser)
+ h := &UserHandler{
+ KratosAdmin: mockKratos,
+ OryProvider: mockOry,
+ TenantService: mockTenant,
+ }
+ app.Post("/users/bulk", h.BulkCreateUsers)
+
+ mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
+
+ payload := map[string]interface{}{
+ "users": []map[string]interface{}{
+ {
+ "email": "user1@samaneng.com",
+ "name": "User One",
+ "tenantSlug": "rnd-saman",
+ "metadata": map[string]interface{}{
+ "sub_email": []interface{}{"user2@samaneng.com"},
+ },
+ },
+ {
+ "email": "user2@samaneng.com",
+ "name": "User Two",
+ "tenantSlug": "rnd-saman",
+ },
+ },
+ }
+ body, _ := json.Marshal(payload)
+ req := httptest.NewRequest(http.MethodPost, "/users/bulk", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var result map[string]interface{}
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
+ results := result["results"].([]interface{})
+ require.Len(t, results, 2)
+ for _, item := range results {
+ row := item.(map[string]interface{})
+ require.False(t, row["success"].(bool))
+ require.Equal(t, "blockingError", row["status"])
+ require.Contains(t, row["message"].(string), "duplicate email")
+ }
+ mockOry.AssertExpectations(t)
+ mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
+ mockTenant.AssertNotCalled(t, "GetTenantBySlug", mock.Anything, mock.Anything)
+}
+
func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -1429,6 +1594,138 @@ func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
mockKratos.AssertExpectations(t)
}
+func TestUserHandler_UpdateUser_AllowsSuperAdminEmailChange(t *testing.T) {
+ app := fiber.New()
+ mockKratos := new(MockKratosAdmin)
+ h := &UserHandler{KratosAdmin: mockKratos}
+ app.Put("/users/:id", func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
+ return h.UpdateUser(c)
+ })
+
+ userID := "u-1"
+ mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
+ ID: userID,
+ Traits: map[string]interface{}{
+ "email": "old@example.com",
+ "name": "사용자",
+ "role": domain.RoleUser,
+ },
+ State: "active",
+ }, nil).Once()
+ mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
+ return traits["email"] == "new@example.com"
+ }), "").Return(&service.KratosIdentity{
+ ID: userID,
+ Traits: map[string]interface{}{
+ "email": "new@example.com",
+ "name": "사용자",
+ "role": domain.RoleUser,
+ },
+ State: "active",
+ }, nil).Once()
+
+ body, _ := json.Marshal(map[string]interface{}{"email": "new@example.com"})
+ req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ mockKratos.AssertExpectations(t)
+}
+
+func TestUserHandler_UpdateUserClearsWorksmobileAliasMetadataWhenSubEmailIsCleared(t *testing.T) {
+ app := fiber.New()
+ mockKratos := new(MockKratosAdmin)
+ h := &UserHandler{KratosAdmin: mockKratos}
+ app.Put("/users/:id", func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
+ return h.UpdateUser(c)
+ })
+
+ userID := "u-1"
+ mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
+ ID: userID,
+ Traits: map[string]interface{}{
+ "email": "user@example.com",
+ "name": "사용자",
+ "role": domain.RoleUser,
+ "sub_email": []interface{}{"alias@hanmaceng.co.kr"},
+ "aliasEmails": []interface{}{"alias@hanmaceng.co.kr"},
+ "secondary_emails": []interface{}{"alias@hanmaceng.co.kr"},
+ "worksmobileAliasEmails": []interface{}{"alias@hanmaceng.co.kr"},
+ },
+ State: "active",
+ }, nil).Once()
+ mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
+ for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
+ values, ok := traits[key].([]interface{})
+ if !ok || len(values) != 0 {
+ return false
+ }
+ }
+
+ return true
+ }), "").Return(&service.KratosIdentity{
+ ID: userID,
+ Traits: map[string]interface{}{
+ "email": "user@example.com",
+ "name": "사용자",
+ "role": domain.RoleUser,
+ "sub_email": []interface{}{},
+ "aliasEmails": []interface{}{},
+ "secondary_emails": []interface{}{},
+ "worksmobileAliasEmails": []interface{}{},
+ },
+ State: "active",
+ }, nil).Once()
+
+ body, _ := json.Marshal(map[string]interface{}{
+ "metadata": map[string]interface{}{
+ "sub_email": []interface{}{},
+ },
+ })
+ req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ mockKratos.AssertExpectations(t)
+}
+
+func TestUserHandler_UpdateUser_RejectsNonSuperAdminEmailChange(t *testing.T) {
+ app := fiber.New()
+ mockKratos := new(MockKratosAdmin)
+ h := &UserHandler{KratosAdmin: mockKratos}
+ app.Put("/users/:id", func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-2", Role: domain.RoleUser})
+ return h.UpdateUser(c)
+ })
+
+ userID := "u-1"
+ mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
+ ID: userID,
+ Traits: map[string]interface{}{
+ "email": "old@example.com",
+ "name": "사용자",
+ "role": domain.RoleUser,
+ },
+ State: "active",
+ }, nil).Once()
+
+ body, _ := json.Marshal(map[string]interface{}{"email": "new@example.com"})
+ req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusForbidden, resp.StatusCode)
+ mockKratos.AssertExpectations(t)
+ mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
+}
+
func TestSyncCustomLoginIDs_IgnoresFlatMetadataMaps(t *testing.T) {
mockTenant := new(MockTenantServiceForUser)
tenantID := "tenant-uuid"
diff --git a/backend/internal/handler/worksmobile_handler.go b/backend/internal/handler/worksmobile_handler.go
index 15bdc89d..16c0d10b 100644
--- a/backend/internal/handler/worksmobile_handler.go
+++ b/backend/internal/handler/worksmobile_handler.go
@@ -72,13 +72,30 @@ func (h *WorksmobileHandler) DeleteOrgUnit(c *fiber.Ctx) error {
func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
userID := strings.TrimSpace(c.Params("userId"))
- job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID)
+ credentialBatchID, err := parseWorksmobileCredentialBatchID(c)
+ if err != nil {
+ return errorJSON(c, fiber.StatusBadRequest, err.Error())
+ }
+ job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID)
if err != nil {
return worksmobileGuardError(c, err, "sync_user", "user_id", userID)
}
return c.Status(fiber.StatusAccepted).JSON(job)
}
+func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error {
+ userID := strings.TrimSpace(c.Params("userId"))
+ credentialBatchID, err := parseWorksmobileCredentialBatchID(c)
+ if err != nil {
+ return errorJSON(c, fiber.StatusBadRequest, err.Error())
+ }
+ job, err := h.Service.EnqueueUserPasswordReset(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID)
+ if err != nil {
+ return worksmobileGuardError(c, err, "reset_user_password", "user_id", userID)
+ }
+ return c.Status(fiber.StatusAccepted).JSON(job)
+}
+
func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
jobID := strings.TrimSpace(c.Params("jobId"))
job, err := h.Service.RetryJob(c.Context(), strings.TrimSpace(c.Params("tenantId")), jobID)
@@ -89,18 +106,18 @@ func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
}
func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
- credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")))
+ credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")), strings.TrimSpace(c.Query("batchId")))
if err != nil {
return worksmobileGuardError(c, err, "download_initial_passwords")
}
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
- if err := writer.Write([]string{"email", "initialPassword", "status", "lastError"}); err != nil {
+ if err := writer.Write([]string{"email", "name", "primaryLeafOrgName", "initialPassword", "status", "lastError"}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
for _, credential := range credentials {
- if err := writer.Write([]string{credential.Email, credential.InitialPassword, credential.Status, credential.LastError}); err != nil {
+ if err := writer.Write([]string{credential.Email, credential.Name, credential.PrimaryLeafOrgName, credential.InitialPassword, credential.Status, credential.LastError}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
@@ -114,6 +131,42 @@ func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
return c.Send(buf.Bytes())
}
+func (h *WorksmobileHandler) ListCredentialBatches(c *fiber.Ctx) error {
+ batches, err := h.Service.ListCredentialBatches(c.Context(), strings.TrimSpace(c.Params("tenantId")))
+ if err != nil {
+ return worksmobileGuardError(c, err, "list_credential_batches")
+ }
+ return c.JSON(batches)
+}
+
+func (h *WorksmobileHandler) DeleteCredentialBatchPasswords(c *fiber.Ctx) error {
+ batchID := strings.TrimSpace(c.Params("batchId"))
+ batch, err := h.Service.DeleteCredentialBatchPasswords(c.Context(), strings.TrimSpace(c.Params("tenantId")), batchID)
+ if err != nil {
+ return worksmobileGuardError(c, err, "delete_credential_batch_passwords", "batch_id", batchID)
+ }
+ return c.JSON(batch)
+}
+
+type worksmobileCredentialBatchRequest struct {
+ CredentialBatchID string `json:"credentialBatchId"`
+}
+
+func parseWorksmobileCredentialBatchID(c *fiber.Ctx) (string, error) {
+ batchID := strings.TrimSpace(c.Query("credentialBatchId"))
+ if len(bytes.TrimSpace(c.Body())) == 0 {
+ return batchID, nil
+ }
+ var req worksmobileCredentialBatchRequest
+ if err := c.BodyParser(&req); err != nil {
+ return "", err
+ }
+ if bodyBatchID := strings.TrimSpace(req.CredentialBatchID); bodyBatchID != "" {
+ return bodyBatchID, nil
+ }
+ return batchID, nil
+}
+
func worksmobileOverviewAllowed(overview service.WorksmobileTenantOverview) bool {
return overview.Tenant.Slug == service.HanmacFamilyTenantSlug && overview.Tenant.ParentID == nil
}
diff --git a/backend/internal/handler/worksmobile_handler_test.go b/backend/internal/handler/worksmobile_handler_test.go
index bcaafc7d..f0a11ec0 100644
--- a/backend/internal/handler/worksmobile_handler_test.go
+++ b/backend/internal/handler/worksmobile_handler_test.go
@@ -9,6 +9,7 @@ import (
"io"
"log/slog"
"net/http/httptest"
+ "strings"
"testing"
"github.com/gofiber/fiber/v2"
@@ -51,7 +52,13 @@ func TestWorksmobileHandlerReturnsOverviewForHanmacTenant(t *testing.T) {
func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) {
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
credentials: []service.WorksmobileInitialPasswordCredential{
- {Email: "user@hanmaceng.co.kr", InitialPassword: "Aa1!Aa1!Aa1!Aa1!", Status: "processed"},
+ {
+ Email: "user@hanmaceng.co.kr",
+ Name: "홍길동",
+ PrimaryLeafOrgName: "인재성장",
+ InitialPassword: "Aa1!Aa1!Aa1!Aa1!",
+ Status: "processed",
+ },
},
})
app := fiber.New()
@@ -63,8 +70,87 @@ func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) {
require.Contains(t, resp.Header.Get("Content-Disposition"), "worksmobile_initial_passwords.csv")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
- require.Contains(t, string(body), "email,initialPassword,status,lastError")
- require.Contains(t, string(body), "user@hanmaceng.co.kr,Aa1!Aa1!Aa1!Aa1!,processed,")
+ require.Contains(t, string(body), "email,name,primaryLeafOrgName,initialPassword,status,lastError")
+ require.Contains(t, string(body), "user@hanmaceng.co.kr,홍길동,인재성장,Aa1!Aa1!Aa1!Aa1!,processed,")
+}
+
+func TestWorksmobileHandlerPassesInitialPasswordBatchID(t *testing.T) {
+ fakeService := &fakeWorksmobileAdminService{
+ credentials: []service.WorksmobileInitialPasswordCredential{
+ {Email: "batch-user@hanmaceng.co.kr", InitialPassword: "BatchPass1!", Status: "pending"},
+ },
+ }
+ h := NewWorksmobileHandler(fakeService)
+ app := fiber.New()
+ app.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", h.DownloadInitialPasswordsCSV)
+
+ resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/initial-passwords.csv?batchId=batch-1", nil))
+
+ require.NoError(t, err)
+ require.Equal(t, fiber.StatusOK, resp.StatusCode)
+ require.Equal(t, "batch-1", fakeService.downloadCredentialBatchID)
+}
+
+func TestWorksmobileHandlerPassesSyncUserCredentialBatchID(t *testing.T) {
+ fakeService := &fakeWorksmobileAdminService{}
+ h := NewWorksmobileHandler(fakeService)
+ app := fiber.New()
+ app.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", h.SyncUser)
+
+ req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", strings.NewReader(`{"credentialBatchId":"batch-1"}`))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := app.Test(req)
+
+ require.NoError(t, err)
+ require.Equal(t, fiber.StatusAccepted, resp.StatusCode)
+ require.Equal(t, "batch-1", fakeService.syncUserCredentialBatchID)
+}
+
+func TestWorksmobileHandlerPassesPasswordResetCredentialBatchID(t *testing.T) {
+ fakeService := &fakeWorksmobileAdminService{}
+ h := NewWorksmobileHandler(fakeService)
+ app := fiber.New()
+ app.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", h.ResetUserPassword)
+
+ req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/password/reset", strings.NewReader(`{"credentialBatchId":"batch-1"}`))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := app.Test(req)
+
+ require.NoError(t, err)
+ require.Equal(t, fiber.StatusAccepted, resp.StatusCode)
+ require.Equal(t, "batch-1", fakeService.resetPasswordCredentialBatchID)
+}
+
+func TestWorksmobileHandlerReturnsCredentialBatchHistory(t *testing.T) {
+ h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
+ credentialBatches: []service.WorksmobileCredentialBatch{
+ {BatchID: "batch-1", UserCount: 2, HasPasswords: true},
+ },
+ })
+ app := fiber.New()
+ app.Get("/tenants/:tenantId/worksmobile/credential-batches", h.ListCredentialBatches)
+
+ resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/credential-batches", nil))
+
+ require.NoError(t, err)
+ require.Equal(t, fiber.StatusOK, resp.StatusCode)
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.Contains(t, string(body), `"batchId":"batch-1"`)
+ require.Contains(t, string(body), `"userCount":2`)
+}
+
+func TestWorksmobileHandlerDeletesCredentialBatchPasswords(t *testing.T) {
+ fakeService := &fakeWorksmobileAdminService{}
+ h := NewWorksmobileHandler(fakeService)
+ app := fiber.New()
+ app.Delete("/tenants/:tenantId/worksmobile/credential-batches/:batchId/passwords", h.DeleteCredentialBatchPasswords)
+
+ resp, err := app.Test(httptest.NewRequest("DELETE", "/tenants/hanmac-id/worksmobile/credential-batches/batch-1/passwords", nil))
+
+ require.NoError(t, err)
+ require.Equal(t, fiber.StatusOK, resp.StatusCode)
+ require.Equal(t, "batch-1", fakeService.deletedCredentialBatchID)
}
func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
@@ -91,9 +177,14 @@ func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
}
type fakeWorksmobileAdminService struct {
- overview service.WorksmobileTenantOverview
- credentials []service.WorksmobileInitialPasswordCredential
- syncUserErr error
+ overview service.WorksmobileTenantOverview
+ credentials []service.WorksmobileInitialPasswordCredential
+ syncUserErr error
+ syncUserCredentialBatchID string
+ resetPasswordCredentialBatchID string
+ downloadCredentialBatchID string
+ deletedCredentialBatchID string
+ credentialBatches []service.WorksmobileCredentialBatch
}
func (f *fakeWorksmobileAdminService) GetTenantOverview(ctx context.Context, tenantID string) (service.WorksmobileTenantOverview, error) {
@@ -116,17 +207,33 @@ func (f *fakeWorksmobileAdminService) EnqueueOrgUnitDelete(ctx context.Context,
return &domain.WorksmobileOutbox{ID: "job-orgunit-delete", ResourceID: orgUnitID, Action: domain.WorksmobileActionDelete}, nil
}
-func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
+func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
+ f.syncUserCredentialBatchID = credentialBatchID
if f.syncUserErr != nil {
return nil, f.syncUserErr
}
return &domain.WorksmobileOutbox{ID: "job-user", ResourceID: userID}, nil
}
+func (f *fakeWorksmobileAdminService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
+ f.resetPasswordCredentialBatchID = credentialBatchID
+ return &domain.WorksmobileOutbox{ID: "job-user-password-reset", ResourceID: userID}, nil
+}
+
func (f *fakeWorksmobileAdminService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
return &domain.WorksmobileOutbox{ID: jobID}, nil
}
-func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]service.WorksmobileInitialPasswordCredential, error) {
+func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]service.WorksmobileInitialPasswordCredential, error) {
+ f.downloadCredentialBatchID = credentialBatchID
return f.credentials, nil
}
+
+func (f *fakeWorksmobileAdminService) ListCredentialBatches(ctx context.Context, tenantID string) ([]service.WorksmobileCredentialBatch, error) {
+ return f.credentialBatches, nil
+}
+
+func (f *fakeWorksmobileAdminService) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (service.WorksmobileCredentialBatch, error) {
+ f.deletedCredentialBatchID = credentialBatchID
+ return service.WorksmobileCredentialBatch{BatchID: credentialBatchID}, nil
+}
diff --git a/backend/internal/repository/worksmobile_outbox_repository.go b/backend/internal/repository/worksmobile_outbox_repository.go
index 8da5f5a8..e6f88d7c 100644
--- a/backend/internal/repository/worksmobile_outbox_repository.go
+++ b/backend/internal/repository/worksmobile_outbox_repository.go
@@ -12,6 +12,8 @@ import (
type WorksmobileOutboxRepository interface {
Create(ctx context.Context, item *domain.WorksmobileOutbox) error
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
+ ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
+ UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error)
MarkRetry(ctx context.Context, id string) error
@@ -56,6 +58,24 @@ func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int)
return rows, err
}
+func (r *worksmobileOutboxRepository) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
+ query := r.db.WithContext(ctx).
+ Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "")
+ if credentialBatchID != "" {
+ query = query.Where("payload ->> 'credentialBatchId' = ?", credentialBatchID)
+ }
+ var rows []domain.WorksmobileOutbox
+ err := query.Order("created_at desc").Find(&rows).Error
+ return rows, err
+}
+
+func (r *worksmobileOutboxRepository) UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error {
+ return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ?", id).Updates(map[string]any{
+ "payload": payload,
+ "updated_at": time.Now(),
+ }).Error
+}
+
func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
if limit <= 0 || limit > 100 {
limit = 20
diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go
index d040bee7..2f67c9cc 100644
--- a/backend/internal/service/kratos_admin_service.go
+++ b/backend/internal/service/kratos_admin_service.go
@@ -290,6 +290,9 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
},
"state": "active",
}
+ if requestedID := strings.TrimSpace(user.ID); requestedID != "" {
+ payload["id"] = requestedID
+ }
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
@@ -316,6 +319,9 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
return "", err
}
+ if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID {
+ return "", fmt.Errorf("kratos admin: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID)
+ }
return created.ID, nil
}
diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go
index 99affaaa..8a1db385 100644
--- a/backend/internal/service/ory_service.go
+++ b/backend/internal/service/ory_service.go
@@ -134,6 +134,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
},
},
}
+ if requestedID := strings.TrimSpace(user.ID); requestedID != "" {
+ payload["id"] = requestedID
+ }
verifiable := []map[string]interface{}{
{
"value": user.Email,
@@ -179,6 +182,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
return "", fmt.Errorf("ory provider: decode create identity response failed: %w", err)
}
+ if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID {
+ return "", fmt.Errorf("ory provider: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID)
+ }
slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email)
return created.ID, nil
diff --git a/backend/internal/service/ory_service_test.go b/backend/internal/service/ory_service_test.go
index f7791089..4546dcb6 100644
--- a/backend/internal/service/ory_service_test.go
+++ b/backend/internal/service/ory_service_test.go
@@ -1,6 +1,7 @@
package service
import (
+ "baron-sso-backend/internal/domain"
"bytes"
"encoding/json"
"io"
@@ -35,6 +36,76 @@ type roundTripperFunc func(req *http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
+func TestCreateUserSendsRequestedIdentityID(t *testing.T) {
+ const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
+ _ = json.NewEncoder(w).Encode([]map[string]string{})
+ return
+ case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
+ var payload map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("failed to decode payload: %v", err)
+ }
+ if payload["id"] != requestedID {
+ t.Fatalf("expected id=%s, got=%v", requestedID, payload["id"])
+ }
+ _ = json.NewEncoder(w).Encode(map[string]string{"id": requestedID})
+ return
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
+ }
+ })
+
+ provider := &OryProvider{
+ KratosAdminURL: "http://kratos-admin.local",
+ HTTPClient: clientForHandler(handler),
+ }
+
+ id, err := provider.CreateUser(&domain.BrokerUser{
+ ID: requestedID,
+ Email: "restore@test.com",
+ Name: "Restore User",
+ }, "Sup3rStr0ng!Pass#2026")
+ if err != nil {
+ t.Fatalf("CreateUser returned error: %v", err)
+ }
+ if id != requestedID {
+ t.Fatalf("expected %s, got %s", requestedID, id)
+ }
+}
+
+func TestCreateUserRejectsRequestedIdentityIDMismatch(t *testing.T) {
+ const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
+ _ = json.NewEncoder(w).Encode([]map[string]string{})
+ return
+ case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
+ _ = json.NewEncoder(w).Encode(map[string]string{"id": "generated-id"})
+ return
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
+ }
+ })
+
+ provider := &OryProvider{
+ KratosAdminURL: "http://kratos-admin.local",
+ HTTPClient: clientForHandler(handler),
+ }
+
+ _, err := provider.CreateUser(&domain.BrokerUser{
+ ID: requestedID,
+ Email: "restore@test.com",
+ Name: "Restore User",
+ }, "Sup3rStr0ng!Pass#2026")
+ if err == nil || !strings.Contains(err.Error(), "requested identity id was not preserved") {
+ t.Fatalf("expected requested identity id mismatch error, got: %v", err)
+ }
+}
+
func TestUpdateUserPassword_Success(t *testing.T) {
const (
loginID = "user@example.com"
diff --git a/backend/internal/service/worksmobile_client.go b/backend/internal/service/worksmobile_client.go
index 68888573..a985fde0 100644
--- a/backend/internal/service/worksmobile_client.go
+++ b/backend/internal/service/worksmobile_client.go
@@ -30,6 +30,8 @@ type WorksmobileDirectoryClient interface {
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
+ AddUserAliasEmail(ctx context.Context, userID string, email string) error
+ ResetUserPassword(ctx context.Context, userID string, password string) error
DeleteUser(ctx context.Context, userID string) error
SetUserActive(ctx context.Context, userID string, active bool) error
ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error)
@@ -283,6 +285,45 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
return err
}
+func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
+ userID = strings.TrimSpace(userID)
+ email = strings.TrimSpace(email)
+ if userID == "" {
+ return fmt.Errorf("worksmobile user id is required")
+ }
+ if email == "" {
+ return fmt.Errorf("worksmobile alias email is required")
+ }
+ err := c.sendDirectoryJSON(
+ ctx,
+ http.MethodPost,
+ "/v1.0/users/"+url.PathEscape(userID)+"/alias-emails/"+url.PathEscape(email),
+ nil,
+ )
+ if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
+ return nil
+ }
+ return err
+}
+
+func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
+ userID = strings.TrimSpace(userID)
+ password = strings.TrimSpace(password)
+ if userID == "" {
+ return fmt.Errorf("worksmobile user id is required")
+ }
+ if password == "" {
+ return fmt.Errorf("worksmobile password is required")
+ }
+ payload := map[string]any{
+ "passwordConfig": WorksmobilePasswordConfig{
+ PasswordCreationType: "ADMIN",
+ Password: password,
+ },
+ }
+ return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(userID), payload)
+}
+
func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
@@ -756,22 +797,23 @@ type WorksmobileOrgUnitPatchPayload struct {
}
type WorksmobileRemoteUser struct {
- ID string `json:"id"`
- ExternalID string `json:"externalId"`
- UserName string `json:"userName"`
- Email string `json:"email"`
- DisplayName string `json:"displayName"`
- LevelID string `json:"levelId"`
- LevelName string `json:"levelName"`
- Task string `json:"task"`
- DomainID int64 `json:"domainId"`
- DomainName string `json:"domainName"`
- PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
- PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
- PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
- PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
- PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
- Active bool `json:"active"`
+ ID string `json:"id"`
+ ExternalID string `json:"externalId"`
+ UserName string `json:"userName"`
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+ LevelID string `json:"levelId"`
+ LevelName string `json:"levelName"`
+ Task string `json:"task"`
+ DomainID int64 `json:"domainId"`
+ DomainName string `json:"domainName"`
+ PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
+ PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
+ PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
+ PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
+ PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
+ OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"`
+ Active bool `json:"active"`
}
type WorksmobileRemoteGroup struct {
@@ -907,6 +949,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
user.PrimaryOrgUnitPositionID = primaryOrgUnit.PositionID
user.PrimaryOrgUnitPositionName = primaryOrgUnit.PositionName
user.PrimaryOrgUnitIsManager = primaryOrgUnit.IsManager
+ user.OrgUnitManagers = parseWorksmobileOrgUnitManagers(resource)
return user
}
@@ -1029,6 +1072,43 @@ func parseWorksmobilePrimaryOrgUnitDetail(resource map[string]any) worksmobileOr
return worksmobileOrgUnitDetail{}
}
+func parseWorksmobileOrgUnitManagers(resource map[string]any) map[string]*bool {
+ result := map[string]*bool{}
+ collectWorksmobileOrgUnitManagers(resource["organizations"], result)
+ collectWorksmobileOrgUnitManagers(resource["orgUnits"], result)
+ for key, raw := range resource {
+ if !strings.Contains(strings.ToLower(key), "works") {
+ continue
+ }
+ if values, ok := raw.(map[string]any); ok {
+ collectWorksmobileOrgUnitManagers(values["organizations"], result)
+ collectWorksmobileOrgUnitManagers(values["orgUnits"], result)
+ }
+ }
+ if len(result) == 0 {
+ return nil
+ }
+ return result
+}
+
+func collectWorksmobileOrgUnitManagers(raw any, result map[string]*bool) {
+ values, ok := raw.([]any)
+ if !ok {
+ return
+ }
+ for _, item := range values {
+ orgUnit, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ if id := firstStringFromMap(orgUnit, "orgUnitId", "id", "value"); id != "" {
+ result[id] = boolPointerFromMap(orgUnit, "isManager", "manager")
+ }
+ collectWorksmobileOrgUnitManagers(orgUnit["organizations"], result)
+ collectWorksmobileOrgUnitManagers(orgUnit["orgUnits"], result)
+ }
+}
+
func parseWorksmobileParentOrgUnit(resource map[string]any) (string, string) {
id := firstStringFromMap(resource, "parentOrgUnitId", "parentId")
name := firstStringFromMap(resource, "parentOrgUnitName", "parentName")
diff --git a/backend/internal/service/worksmobile_client_test.go b/backend/internal/service/worksmobile_client_test.go
index c30d96f2..b4cb92a8 100644
--- a/backend/internal/service/worksmobile_client_test.go
+++ b/backend/internal/service/worksmobile_client_test.go
@@ -113,6 +113,50 @@ func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOr
require.Equal(t, "user-1", patchPayload["userExternalKey"])
}
+func TestWorksmobileHTTPClientAddUserAliasEmailPostsDirectoryAliasEndpoint(t *testing.T) {
+ transport := &captureRoundTripper{
+ statusCode: http.StatusCreated,
+ body: `{}`,
+ }
+ client := &WorksmobileHTTPClient{
+ BaseURL: "https://works.example.test",
+ DirectoryToken: "directory-token-1",
+ HTTPClient: &http.Client{Transport: transport},
+ }
+
+ err := client.AddUserAliasEmail(context.Background(), "ypshim@samaneng.com", "ypshim@hanmaceng.co.kr")
+
+ require.NoError(t, err)
+ require.NotNil(t, transport.request)
+ require.Equal(t, http.MethodPost, transport.request.Method)
+ require.Equal(t, "/v1.0/users/ypshim@samaneng.com/alias-emails/ypshim@hanmaceng.co.kr", transport.request.URL.Path)
+ require.Equal(t, "Bearer directory-token-1", transport.request.Header.Get("Authorization"))
+}
+
+func TestWorksmobileHTTPClientResetUserPasswordPatchesPasswordConfig(t *testing.T) {
+ transport := &captureRoundTripper{
+ statusCode: http.StatusOK,
+ body: `{}`,
+ }
+ client := &WorksmobileHTTPClient{
+ BaseURL: "https://works.example.test",
+ DirectoryToken: "directory-token-1",
+ HTTPClient: &http.Client{Transport: transport},
+ }
+
+ err := client.ResetUserPassword(context.Background(), "target@samaneng.com", "Aa1!Aa1!Aa1!Aa1!")
+
+ require.NoError(t, err)
+ require.NotNil(t, transport.request)
+ require.Equal(t, http.MethodPatch, transport.request.Method)
+ require.Equal(t, "/v1.0/users/target@samaneng.com", transport.request.URL.Path)
+ var payload map[string]any
+ require.NoError(t, json.Unmarshal(transport.requestBody, &payload))
+ passwordConfig := payload["passwordConfig"].(map[string]any)
+ require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
+ require.Equal(t, "Aa1!Aa1!Aa1!Aa1!", passwordConfig["password"])
+}
+
func TestWorksmobileHTTPClientCreateUserRequiresDirectoryToken(t *testing.T) {
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
@@ -472,6 +516,71 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
}
+func TestWorksmobileRelayWorkerRegistersAliasEmailsAfterUserUpsert(t *testing.T) {
+ repo := &fakeWorksmobileOutboxRepo{
+ ready: []domain.WorksmobileOutbox{
+ {
+ ID: "job-1",
+ ResourceType: domain.WorksmobileResourceUser,
+ ResourceID: "user-1",
+ Action: domain.WorksmobileActionUpsert,
+ Status: domain.WorksmobileOutboxStatusPending,
+ Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
+ Email: "ypshim@samaneng.com",
+ UserExternalKey: "user-1",
+ AliasEmails: []string{"ypshim@hanmaceng.co.kr"},
+ PasswordConfig: WorksmobilePasswordConfig{
+ PasswordCreationType: "ADMIN",
+ Password: "Aa1!Aa1!Aa1!Aa1!",
+ },
+ }),
+ },
+ },
+ }
+ client := &fakeWorksmobileDirectoryClient{}
+ worker := NewWorksmobileRelayWorker(repo, client)
+
+ err := worker.ProcessOnce(context.Background())
+
+ require.NoError(t, err)
+ require.Equal(t, []string{"job-1"}, repo.processedIDs)
+ require.Equal(t, "ypshim@samaneng.com", client.createdUsers[0].Email)
+ require.Empty(t, client.createdUsers[0].AliasEmails)
+ require.Equal(t, []string{"ypshim@samaneng.com:ypshim@hanmaceng.co.kr"}, client.aliasEmails)
+}
+
+func TestWorksmobileRelayWorkerProcessesUserPasswordResetAndMarksProcessed(t *testing.T) {
+ repo := &fakeWorksmobileOutboxRepo{
+ ready: []domain.WorksmobileOutbox{
+ {
+ ID: "job-reset",
+ ResourceType: domain.WorksmobileResourceUser,
+ ResourceID: "user-1",
+ Action: domain.WorksmobileActionPasswordReset,
+ Status: domain.WorksmobileOutboxStatusPending,
+ Payload: domain.JSONMap{
+ "loginEmail": "target@samaneng.com",
+ "request": map[string]any{
+ "email": "target@samaneng.com",
+ "passwordConfig": map[string]any{
+ "passwordCreationType": "ADMIN",
+ "password": "Aa1!Aa1!Aa1!Aa1!",
+ },
+ },
+ },
+ },
+ },
+ }
+ client := &fakeWorksmobileDirectoryClient{}
+ worker := NewWorksmobileRelayWorker(repo, client)
+
+ err := worker.ProcessOnce(context.Background())
+
+ require.NoError(t, err)
+ require.Equal(t, []string{"job-reset"}, repo.processedIDs)
+ require.Equal(t, []string{"target@samaneng.com:Aa1!Aa1!Aa1!Aa1!"}, client.passwordResets)
+}
+
func TestWorksmobileRelayWorkerProcessesUserSuspendAndMarksProcessed(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
@@ -615,7 +724,7 @@ func TestCompareWorksmobileUsersIncludesBaronAndWorksPrimaryOrg(t *testing.T) {
require.Equal(t, "WORKS 기술기획", items[0].WorksmobilePrimaryOrgName)
}
-func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t *testing.T) {
+func TestCompareWorksmobileUsersMarksEmailMatchWithoutExternalIDNeedsUpdate(t *testing.T) {
localUsers := []domain.User{
{ID: "user-1", Email: "tester@samaneng.com", Name: "Tester"},
}
@@ -626,13 +735,37 @@ func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t
diffOnly := compareWorksmobileUsers(localUsers, remoteUsers, false, nil)
all := compareWorksmobileUsers(localUsers, remoteUsers, true, nil)
- require.Empty(t, diffOnly)
+ require.Len(t, diffOnly, 1)
+ require.Equal(t, "needs_update", diffOnly[0].Status)
require.Len(t, all, 1)
- require.Equal(t, "matched", all[0].Status)
+ require.Equal(t, "needs_update", all[0].Status)
require.Equal(t, "works-1", all[0].WorksmobileID)
require.Empty(t, all[0].ExternalKey)
}
+func TestCompareWorksmobileUsersIncludesRecentFailedJobForMissingUser(t *testing.T) {
+ localUsers := []domain.User{
+ {ID: "user-1", Email: "missing@samaneng.com", Name: "Missing"},
+ }
+ jobSummaries := map[string]worksmobileUserJobSummary{
+ "user-1": {
+ Status: domain.WorksmobileOutboxStatusFailed,
+ RetryCount: 3,
+ LastError: "worksmobile api failed",
+ LastAttemptAt: "2026-06-01T05:00:00Z",
+ },
+ }
+
+ items := compareWorksmobileUsers(localUsers, nil, false, nil, jobSummaries)
+
+ require.Len(t, items, 1)
+ require.Equal(t, "missing_in_worksmobile", items[0].Status)
+ require.Equal(t, domain.WorksmobileOutboxStatusFailed, items[0].WorksmobileJobStatus)
+ require.Equal(t, 3, items[0].WorksmobileJobRetryCount)
+ require.Equal(t, "worksmobile api failed", items[0].WorksmobileLastError)
+ require.Equal(t, "2026-06-01T05:00:00Z", items[0].WorksmobileLastAttemptAt)
+}
+
func TestCompareWorksmobileUsersIncludesWorksOnlyRowsWithoutExternalIDWhenIncludingMatched(t *testing.T) {
remoteUsers := []WorksmobileRemoteUser{
{ID: "works-1", ExternalID: "", Email: "works-only@samaneng.com", DisplayName: "Works Only"},
@@ -894,6 +1027,41 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing
require.Equal(t, "팀장", user.PrimaryOrgUnitPositionName)
require.NotNil(t, user.PrimaryOrgUnitIsManager)
require.True(t, *user.PrimaryOrgUnitIsManager)
+ require.NotNil(t, user.OrgUnitManagers["works-org-1"])
+ require.True(t, *user.OrgUnitManagers["works-org-1"])
+}
+
+func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.T) {
+ user := parseWorksmobileDirectoryUser(map[string]any{
+ "userId": "works-user",
+ "email": "tester@samaneng.com",
+ "userName": map[string]any{
+ "lastName": "홍길동",
+ },
+ "organizations": []any{
+ map[string]any{
+ "primary": true,
+ "orgUnits": []any{
+ map[string]any{
+ "orgUnitId": "externalKey:primary-org",
+ "primary": true,
+ "isManager": false,
+ },
+ map[string]any{
+ "orgUnitId": "externalKey:secondary-org",
+ "primary": false,
+ "isManager": true,
+ },
+ },
+ },
+ },
+ })
+
+ require.Len(t, user.OrgUnitManagers, 2)
+ require.NotNil(t, user.OrgUnitManagers["externalKey:primary-org"])
+ require.False(t, *user.OrgUnitManagers["externalKey:primary-org"])
+ require.NotNil(t, user.OrgUnitManagers["externalKey:secondary-org"])
+ require.True(t, *user.OrgUnitManagers["externalKey:secondary-org"])
}
func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
@@ -908,11 +1076,14 @@ func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
}
type fakeWorksmobileOutboxRepo struct {
- ready []domain.WorksmobileOutbox
- created []domain.WorksmobileOutbox
- processingIDs []string
- processedIDs []string
- failedIDs []string
+ recent []domain.WorksmobileOutbox
+ ready []domain.WorksmobileOutbox
+ created []domain.WorksmobileOutbox
+ credentialBatchJobs []domain.WorksmobileOutbox
+ payloadUpdates []domain.JSONMap
+ processingIDs []string
+ processedIDs []string
+ failedIDs []string
}
func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
@@ -921,7 +1092,31 @@ func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.Wor
}
func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
- return nil, nil
+ return f.recent, nil
+}
+
+func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
+ rows := make([]domain.WorksmobileOutbox, 0)
+ for _, row := range f.credentialBatchJobs {
+ if stringValue(row.Payload["tenantRootId"]) != tenantRootID {
+ continue
+ }
+ if credentialBatchID != "" && stringValue(row.Payload["credentialBatchId"]) != credentialBatchID {
+ continue
+ }
+ rows = append(rows, row)
+ }
+ return rows, nil
+}
+
+func (f *fakeWorksmobileOutboxRepo) UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error {
+ f.payloadUpdates = append(f.payloadUpdates, payload)
+ for i := range f.credentialBatchJobs {
+ if f.credentialBatchJobs[i].ID == id {
+ f.credentialBatchJobs[i].Payload = payload
+ }
+ }
+ return nil
}
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
@@ -958,6 +1153,8 @@ type fakeWorksmobileDirectoryClient struct {
deletedUsers []string
activeUsers []string
suspendedUsers []string
+ aliasEmails []string
+ passwordResets []string
users []WorksmobileRemoteUser
orgUnitMatchKeys []string
groups []WorksmobileRemoteGroup
@@ -1062,6 +1259,16 @@ func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload
return nil
}
+func (f *fakeWorksmobileDirectoryClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
+ f.aliasEmails = append(f.aliasEmails, userID+":"+email)
+ return nil
+}
+
+func (f *fakeWorksmobileDirectoryClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
+ f.passwordResets = append(f.passwordResets, userID+":"+password)
+ return nil
+}
+
func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID string) error {
f.deletedUsers = append(f.deletedUsers, userID)
return nil
diff --git a/backend/internal/service/worksmobile_live_flow_test.go b/backend/internal/service/worksmobile_live_flow_test.go
index c06b3570..59cd7e8c 100644
--- a/backend/internal/service/worksmobile_live_flow_test.go
+++ b/backend/internal/service/worksmobile_live_flow_test.go
@@ -56,7 +56,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
require.NoError(t, outboxRepo.MarkProcessed(ctx, job.ID))
continue
}
- item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID)
+ item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID, "")
require.NoError(t, err)
require.NotEmpty(t, item)
require.NoError(t, outboxRepo.MarkRetry(ctx, job.ID))
@@ -70,7 +70,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
require.Equal(t, domain.WorksmobileOutboxStatusProcessed, processed.Status)
}
- credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID)
+ credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID, "")
require.NoError(t, err)
seen := map[string]bool{}
for _, credential := range credentials {
diff --git a/backend/internal/service/worksmobile_mapper.go b/backend/internal/service/worksmobile_mapper.go
index 0a7ce7df..5f7de72d 100644
--- a/backend/internal/service/worksmobile_mapper.go
+++ b/backend/internal/service/worksmobile_mapper.go
@@ -51,15 +51,21 @@ type WorksmobilePasswordConfig struct {
Password string `json:"password"`
}
+type WorksmobilePasswordResetPayload struct {
+ Email string `json:"email"`
+ PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"`
+}
+
type WorksmobileUserOrganization struct {
DomainID int64 `json:"domainId,omitempty"`
- Primary bool `json:"primary,omitempty"`
+ Email string `json:"email,omitempty"`
+ Primary bool `json:"primary"`
OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
}
type WorksmobileUserOrgUnit struct {
OrgUnitID string `json:"orgUnitId"`
- Primary bool `json:"primary,omitempty"`
+ Primary bool `json:"primary"`
PositionID string `json:"positionId,omitempty"`
IsManager *bool `json:"isManager,omitempty"`
}
@@ -156,12 +162,11 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
tenantByID = map[string]domain.Tenant{}
}
tenantByID[tenant.ID] = tenant
- domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
- domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
+ domainID, err := ResolveWorksmobileAccountDomainIDFromEmail(user.Email, tenant, rootConfig)
if err != nil {
return WorksmobileUserPayload{}, err
}
- employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
+ employeeNumber := metadataEmployeeNumber(user.Metadata)
organizations, task, err := buildWorksmobileUserOrganizations(user, tenant, tenantByID, rootConfig)
if err != nil {
return WorksmobileUserPayload{}, err
@@ -202,28 +207,19 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
if len(appointments) == 0 {
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
}
- primaryTenantID := metadataString(user.Metadata, "primaryTenantId", "primary_tenant_id")
- if primaryTenantID == "" && user.TenantID != nil {
- primaryTenantID = *user.TenantID
- }
- hasPrimary := false
- for i := range appointments {
- if appointments[i].TenantID == primaryTenantID || appointments[i].IsPrimary {
- appointments[i].IsPrimary = true
- hasPrimary = true
- break
- }
- }
- if !hasPrimary {
- for i := range appointments {
- if appointments[i].TenantID == tenant.ID {
- appointments[i].IsPrimary = true
- break
- }
- }
+ accountDomainTenant := worksmobileAccountDomainTenantFromEmail(user.Email, tenant, tenantByID)
+ accountDomainEnvKey := worksmobileTenantDomainIDEnvKey(accountDomainTenant)
+ if !worksmobileAppointmentsContainDomain(appointments, tenantByID, accountDomainEnvKey) && accountDomainTenant.ID != "" {
+ appointments = append([]worksmobileAppointment{{
+ TenantID: accountDomainTenant.ID,
+ IsPrimary: true,
+ JobTitle: strings.TrimSpace(user.JobTitle),
+ PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
+ }}, appointments...)
}
- organizations := make([]WorksmobileUserOrganization, 0, len(appointments))
+ organizations := make([]WorksmobileUserOrganization, 0)
+ organizationIndexByDomainID := map[int64]int{}
seen := map[string]bool{}
task := ""
for _, appointment := range appointments {
@@ -242,21 +238,34 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
if err != nil {
return nil, "", err
}
+ isAccountDomain := worksmobileTenantDomainIDEnvKey(domainTenant) == accountDomainEnvKey
+ isPrimaryOrganization := isAccountDomain && !worksmobileOrganizationsHavePrimary(organizations)
+ organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
orgUnit := WorksmobileUserOrgUnit{
OrgUnitID: "externalKey:" + appointmentTenant.ID,
- Primary: appointment.IsPrimary,
+ Primary: !organizationExists,
PositionID: appointment.PositionID,
}
if appointment.HasManager {
isManager := appointment.IsManager
orgUnit.IsManager = &isManager
}
- organizations = append(organizations, WorksmobileUserOrganization{
- DomainID: domainID,
- Primary: appointment.IsPrimary,
- OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
- })
- if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" {
+ if organizationExists {
+ if isPrimaryOrganization {
+ organizations[organizationIndex].Primary = true
+ organizations[organizationIndex].Email = worksmobileOrganizationEmail(user, domainTenant)
+ }
+ organizations[organizationIndex].OrgUnits = append(organizations[organizationIndex].OrgUnits, orgUnit)
+ } else {
+ organizationIndexByDomainID[domainID] = len(organizations)
+ organizations = append(organizations, WorksmobileUserOrganization{
+ DomainID: domainID,
+ Email: worksmobileOrganizationEmail(user, domainTenant),
+ Primary: isPrimaryOrganization,
+ OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
+ })
+ }
+ if isPrimaryOrganization && strings.TrimSpace(appointment.JobTitle) != "" {
task = strings.TrimSpace(appointment.JobTitle)
}
seen[appointment.TenantID] = true
@@ -264,10 +273,39 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
if len(organizations) == 0 {
return nil, "", errors.New("no valid worksmobile organization")
}
+ if !worksmobileOrganizationsHavePrimary(organizations) {
+ organizations[0].Primary = true
+ if len(organizations[0].OrgUnits) > 0 {
+ organizations[0].OrgUnits[0].Primary = true
+ }
+ }
sortWorksmobileOrganizations(organizations)
return organizations, task, nil
}
+func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant, envKey string) bool {
+ for _, appointment := range appointments {
+ tenant, ok := tenantByID[appointment.TenantID]
+ if !ok {
+ continue
+ }
+ domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
+ if worksmobileTenantDomainIDEnvKey(domainTenant) == envKey {
+ return true
+ }
+ }
+ return false
+}
+
+func worksmobileOrganizationsHavePrimary(organizations []WorksmobileUserOrganization) bool {
+ for _, organization := range organizations {
+ if organization.Primary {
+ return true
+ }
+ }
+ return false
+}
+
func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment {
rawAppointments, ok := metadata["additionalAppointments"].([]any)
if !ok {
@@ -326,7 +364,7 @@ func BuildWorksmobileAliasEmails(user domain.User, tenant domain.Tenant) []strin
} {
candidates = append(candidates, metadataStringList(user.Metadata, key)...)
}
- employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
+ employeeNumber := metadataEmployeeNumber(user.Metadata)
if isHanmacWorksmobileTenant(tenant) && employeeNumber != "" {
candidates = append(candidates, employeeNumber+"@hanmaceng.co.kr")
}
@@ -351,26 +389,21 @@ func normalizeWorksmobileAliasEmails(primaryEmail string, candidates []string) [
return result
}
-func ValidateWorksmobileAliasLocalParts(primaryEmail string, aliasEmails []string, existingLocalParts map[string]string) error {
- seen := map[string]string{}
- primaryLocalPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail)
- if err != nil {
- return err
- }
- seen[primaryLocalPart] = primaryEmail
+func ValidateWorksmobileAliasEmails(primaryEmail string, aliasEmails []string, existingEmails map[string]string) error {
+ seen := map[string]string{strings.ToLower(strings.TrimSpace(primaryEmail)): primaryEmail}
for _, aliasEmail := range aliasEmails {
- localPart, err := domain.ExtractNormalizedEmailLocalPart(aliasEmail)
- if err != nil {
+ normalized := strings.ToLower(strings.TrimSpace(aliasEmail))
+ if _, err := mail.ParseAddress(normalized); err != nil {
return err
}
- if previous, ok := seen[localPart]; ok {
- return fmt.Errorf("worksmobile alias local-part duplicates %s: %s and %s", localPart, previous, aliasEmail)
+ if previous, ok := seen[normalized]; ok {
+ return fmt.Errorf("worksmobile alias email duplicates: %s and %s", previous, aliasEmail)
}
- if owner, ok := existingLocalParts[localPart]; ok {
- return fmt.Errorf("worksmobile alias local-part %s는 이미 사용 중입니다: %s", localPart, owner)
+ if owner, ok := existingEmails[normalized]; ok {
+ return fmt.Errorf("worksmobile alias email %s는 이미 사용 중입니다: %s", normalized, owner)
}
- seen[localPart] = aliasEmail
+ seen[normalized] = aliasEmail
}
return nil
}
@@ -446,6 +479,91 @@ func ResolveWorksmobileDomainIDFromTenant(tenant domain.Tenant, _ domain.JSONMap
return 0, fmt.Errorf("worksmobile domain id env is missing for tenant: %s", envKey)
}
+func ResolveWorksmobileAccountDomainIDFromEmail(email string, fallbackTenant domain.Tenant, rootConfig domain.JSONMap) (int64, error) {
+ switch worksmobileEmailDomainName(email) {
+ case "samaneng.com":
+ if domainID, ok := worksmobileDomainIDFromEnv("SAMAN_DOMAIN_ID"); ok {
+ return domainID, nil
+ }
+ case "hanmaceng.co.kr":
+ if domainID, ok := worksmobileDomainIDFromEnv("HANMAC_DOMAIN_ID"); ok {
+ return domainID, nil
+ }
+ case "baroncs.co.kr":
+ if domainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID"); ok {
+ return domainID, nil
+ }
+ case "brsw.kr":
+ if domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID"); ok {
+ return domainID, nil
+ }
+ }
+ return ResolveWorksmobileDomainIDFromTenant(fallbackTenant, rootConfig)
+}
+
+func worksmobileAccountDomainTenantFromEmail(email string, fallbackTenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant {
+ envKey := worksmobileDomainIDEnvKeyFromEmail(email)
+ for _, tenant := range tenantByID {
+ if isWorksmobileDomainRootTenant(tenant) && worksmobileTenantDomainIDEnvKey(tenant) == envKey {
+ return tenant
+ }
+ }
+ for _, tenant := range tenantByID {
+ if worksmobileTenantDomainIDEnvKey(tenant) == envKey {
+ return worksmobileDomainClassificationTenant(tenant, tenantByID)
+ }
+ }
+ return worksmobileDomainClassificationTenant(fallbackTenant, tenantByID)
+}
+
+func worksmobileDomainIDEnvKeyFromEmail(email string) string {
+ switch worksmobileEmailDomainName(email) {
+ case "samaneng.com":
+ return "SAMAN_DOMAIN_ID"
+ case "hanmaceng.co.kr":
+ return "HANMAC_DOMAIN_ID"
+ case "baroncs.co.kr":
+ return "GPDTDC_DOMAIN_ID"
+ case "brsw.kr":
+ return "BARONGROUP_DOMAIN_ID"
+ default:
+ return worksmobileTenantDomainIDEnvKey(domain.Tenant{})
+ }
+}
+
+func worksmobileEmailDomainName(email string) string {
+ address, err := mail.ParseAddress(strings.TrimSpace(email))
+ if err != nil {
+ return ""
+ }
+ parts := strings.Split(address.Address, "@")
+ if len(parts) != 2 {
+ return ""
+ }
+ return strings.ToLower(strings.TrimSpace(parts[1]))
+}
+
+func worksmobileOrganizationEmail(user domain.User, domainTenant domain.Tenant) string {
+ domainName := worksmobileTenantMailDomain(domainTenant)
+ if domainName == "" {
+ return ""
+ }
+ primaryEmail := strings.ToLower(strings.TrimSpace(user.Email))
+ if worksmobileEmailDomainName(primaryEmail) == domainName {
+ return primaryEmail
+ }
+ for _, alias := range BuildWorksmobileAliasEmails(user, domainTenant) {
+ if worksmobileEmailDomainName(alias) == domainName {
+ return alias
+ }
+ }
+ localPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail)
+ if err != nil || localPart == "" {
+ return ""
+ }
+ return localPart + "@" + domainName
+}
+
func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string {
if tenantHasDomain(tenant, "samaneng.com") || tenantMatchesAny(tenant, "saman", "삼안") {
return "SAMAN_DOMAIN_ID"
@@ -597,6 +715,70 @@ func metadataString(metadata domain.JSONMap, keys ...string) string {
return ""
}
+func metadataEmployeeNumber(metadata domain.JSONMap) string {
+ for _, key := range []string{"employee_id", "employeeNumber", "employee_number"} {
+ value, ok := metadata[key]
+ if !ok {
+ continue
+ }
+ if normalized := normalizeMetadataEmployeeNumber(value); normalized != "" {
+ return normalized
+ }
+ }
+ return ""
+}
+
+func normalizeMetadataEmployeeNumber(value any) string {
+ switch v := value.(type) {
+ case string:
+ return strings.TrimSpace(v)
+ case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
+ return strings.TrimSpace(fmt.Sprint(v))
+ case map[string]any:
+ return normalizeMetadataCharacterMap(v)
+ case domain.JSONMap:
+ return normalizeMetadataCharacterMap(map[string]any(v))
+ case map[string]string:
+ converted := make(map[string]any, len(v))
+ for key, value := range v {
+ converted[key] = value
+ }
+ return normalizeMetadataCharacterMap(converted)
+ default:
+ return ""
+ }
+}
+
+func normalizeMetadataCharacterMap(value map[string]any) string {
+ type characterEntry struct {
+ index int
+ value string
+ }
+ entries := make([]characterEntry, 0, len(value))
+ for key, raw := range value {
+ index, err := strconv.Atoi(key)
+ if err != nil {
+ return ""
+ }
+ part, ok := raw.(string)
+ if !ok || part == "" {
+ return ""
+ }
+ entries = append(entries, characterEntry{index: index, value: part})
+ }
+ if len(entries) == 0 {
+ return ""
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].index < entries[j].index
+ })
+ var builder strings.Builder
+ for _, entry := range entries {
+ builder.WriteString(entry.value)
+ }
+ return strings.TrimSpace(builder.String())
+}
+
func metadataBool(metadata domain.JSONMap, keys ...string) bool {
value, _ := metadataOptionalBool(metadata, keys...)
return value
diff --git a/backend/internal/service/worksmobile_mapper_test.go b/backend/internal/service/worksmobile_mapper_test.go
index 7ae66696..c7644b48 100644
--- a/backend/internal/service/worksmobile_mapper_test.go
+++ b/backend/internal/service/worksmobile_mapper_test.go
@@ -2,6 +2,7 @@ package service
import (
"baron-sso-backend/internal/domain"
+ "encoding/json"
"strings"
"testing"
@@ -134,6 +135,36 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
require.Equal(t, "externalKey:"+tenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
}
+func TestBuildWorksmobileUserPayloadNormalizesLegacyCharacterMapEmployeeID(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ tenantID := "33333333-3333-3333-3333-333333333333"
+ user := domain.User{
+ ID: "44444444-4444-4444-4444-444444444444",
+ Email: "john1@samaneng.com",
+ Name: "John Doe",
+ TenantID: &tenantID,
+ Metadata: domain.JSONMap{
+ "employee_id": map[string]any{
+ "0": "j",
+ "1": "o",
+ "2": "h",
+ "3": "n",
+ },
+ },
+ }
+ tenant := domain.Tenant{
+ ID: tenantID,
+ Slug: "saman",
+ Name: "Saman",
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ }
+
+ payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
+
+ require.NoError(t, err)
+ require.Equal(t, "john", payload.EmployeeNumber)
+}
+
func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
@@ -198,7 +229,7 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
- require.False(t, payload.Organizations[1].OrgUnits[0].Primary)
+ require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager)
require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
}
@@ -259,7 +290,7 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
)
require.NoError(t, err)
- require.Equal(t, int64(1003), payload.DomainID)
+ require.Equal(t, int64(1001), payload.DomainID)
require.Equal(t, "First affiliation task", payload.Task)
require.Len(t, payload.Organizations, 2)
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
@@ -269,7 +300,99 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
require.Equal(t, int64(1003), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
require.Equal(t, "externalKey:"+secondTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
- require.False(t, payload.Organizations[1].OrgUnits[0].Primary)
+ require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
+}
+
+func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOrgIsGPDTDC(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ samanID := "11111111-1111-1111-1111-111111111111"
+ gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
+ leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
+ user := domain.User{
+ ID: "44444444-4444-4444-4444-444444444444",
+ Email: "dhlee@samaneng.com",
+ Name: "GPDTDC Saman User",
+ TenantID: &leafTenantID,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": leafTenantID,
+ "isPrimary": true,
+ },
+ },
+ },
+ }
+ samanTenant := domain.Tenant{
+ ID: samanID,
+ Slug: "saman",
+ Name: "삼안",
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ }
+ gpdtdcTenant := domain.Tenant{
+ ID: gpdtdcID,
+ Slug: "gpdtdc",
+ Name: "총괄기획&기술개발센터",
+ }
+ leafTenant := domain.Tenant{
+ ID: leafTenantID,
+ Slug: "infra-bim2",
+ Name: "인프라 BIM2",
+ ParentID: &gpdtdcID,
+ }
+
+ payload, err := BuildWorksmobileUserPayloadForDomainTenants(
+ user,
+ leafTenant,
+ map[string]domain.Tenant{
+ samanID: samanTenant,
+ gpdtdcID: gpdtdcTenant,
+ leafTenantID: leafTenant,
+ },
+ nil,
+ )
+
+ require.NoError(t, err)
+ require.Equal(t, int64(1001), payload.DomainID)
+ require.Len(t, payload.Organizations, 2)
+ require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
+ require.True(t, payload.Organizations[0].Primary)
+ require.Equal(t, "dhlee@samaneng.com", payload.Organizations[0].Email)
+ require.Equal(t, "externalKey:"+samanID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
+ require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
+ require.Equal(t, int64(1003), payload.Organizations[1].DomainID)
+ require.False(t, payload.Organizations[1].Primary)
+ require.Equal(t, "dhlee@baroncs.co.kr", payload.Organizations[1].Email)
+ require.Equal(t, "externalKey:"+leafTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
+ require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
+}
+
+func TestWorksmobileUserPayloadJSONIncludesFalsePrimaryFields(t *testing.T) {
+ payload := WorksmobileUserPayload{
+ Email: "user@samaneng.com",
+ Organizations: []WorksmobileUserOrganization{
+ {
+ DomainID: 1001,
+ Primary: true,
+ OrgUnits: []WorksmobileUserOrgUnit{
+ {OrgUnitID: "externalKey:primary", Primary: true},
+ },
+ },
+ {
+ DomainID: 1003,
+ Primary: false,
+ OrgUnits: []WorksmobileUserOrgUnit{
+ {OrgUnitID: "externalKey:secondary", Primary: false},
+ },
+ },
+ },
+ }
+
+ data, err := json.Marshal(payload)
+
+ require.NoError(t, err)
+ require.Contains(t, string(data), `"primary":false`)
+ require.Contains(t, string(data), `"orgUnitId":"externalKey:secondary","primary":false`)
}
func TestResolveWorksmobileDomainIDFromTenantIgnoresRootDomainMappings(t *testing.T) {
@@ -441,19 +564,51 @@ func TestBuildWorksmobileUserPayloadAddsSubEmailMetadataAlias(t *testing.T) {
require.Equal(t, []string{"alias1@hanmaceng.co.kr", "alias2@hanmaceng.co.kr"}, payload.AliasEmails)
}
-func TestValidateWorksmobileAliasLocalPartsRejectsPrimaryAndAliasCollisions(t *testing.T) {
- err := ValidateWorksmobileAliasLocalParts(
+func TestBuildWorksmobileUserPayloadKeepsSubEmailAliasWithPrimaryLocalPart(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ tenantID := "33333333-3333-3333-3333-333333333333"
+ user := domain.User{
+ ID: "44444444-4444-4444-4444-444444444444",
+ Email: "ypshim@samaneng.com",
+ Name: "Saman User",
+ TenantID: &tenantID,
+ Metadata: domain.JSONMap{
+ "sub_email": "ypshim@hanmaceng.co.kr",
+ },
+ }
+ tenant := domain.Tenant{
+ ID: tenantID,
+ Slug: "saman",
+ Name: "삼안",
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ }
+
+ payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
+
+ require.NoError(t, err)
+ require.Equal(t, []string{"ypshim@hanmaceng.co.kr"}, payload.AliasEmails)
+}
+
+func TestValidateWorksmobileAliasEmailsAllowsSameLocalPartOnDifferentDomains(t *testing.T) {
+ err := ValidateWorksmobileAliasEmails(
"main@samaneng.com",
[]string{"main@hanmaceng.co.kr"},
map[string]string{},
)
- require.Error(t, err)
- require.Contains(t, err.Error(), "local-part")
+ require.NoError(t, err)
- err = ValidateWorksmobileAliasLocalParts(
+ err = ValidateWorksmobileAliasEmails(
+ "main@samaneng.com",
+ []string{"main@samaneng.com"},
+ map[string]string{},
+ )
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "duplicates")
+
+ err = ValidateWorksmobileAliasEmails(
"main@samaneng.com",
[]string{"alias@hanmaceng.co.kr"},
- map[string]string{"alias": "existing-user"},
+ map[string]string{"alias@hanmaceng.co.kr": "existing-user"},
)
require.Error(t, err)
require.Contains(t, err.Error(), "이미 사용 중")
diff --git a/backend/internal/service/worksmobile_relay_worker.go b/backend/internal/service/worksmobile_relay_worker.go
index 3e971466..c17f347f 100644
--- a/backend/internal/service/worksmobile_relay_worker.go
+++ b/backend/internal/service/worksmobile_relay_worker.go
@@ -100,9 +100,16 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
return err
}
+ aliasEmails := append([]string(nil), payload.AliasEmails...)
+ payload.AliasEmails = nil
if err := w.client.UpsertUser(ctx, payload); err != nil {
return err
}
+ for _, aliasEmail := range aliasEmails {
+ if err := w.client.AddUserAliasEmail(ctx, payload.Email, aliasEmail); err != nil {
+ return err
+ }
+ }
if stringValue(job.Payload["baronStatus"]) == domain.UserStatusActive {
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true)
}
@@ -111,6 +118,16 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
return w.client.DeleteUser(ctx, worksmobileOutboxUserIdentifier(job))
case domain.WorksmobileActionSuspend:
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), false)
+ case domain.WorksmobileActionPasswordReset:
+ var payload WorksmobilePasswordResetPayload
+ if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
+ return err
+ }
+ identifier := strings.TrimSpace(payload.Email)
+ if identifier == "" {
+ identifier = worksmobileOutboxUserIdentifier(job)
+ }
+ return w.client.ResetUserPassword(ctx, identifier, payload.PasswordConfig.Password)
default:
return nil
}
diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go
index 6d318f6b..733eedeb 100644
--- a/backend/internal/service/worksmobile_sync_service.go
+++ b/backend/internal/service/worksmobile_sync_service.go
@@ -5,9 +5,11 @@ import (
"baron-sso-backend/internal/repository"
"context"
"errors"
+ "net/mail"
"os"
"sort"
"strings"
+ "time"
)
const HanmacFamilyTenantSlug = "hanmac-family"
@@ -25,9 +27,12 @@ type WorksmobileAdminService interface {
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error)
- EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error)
+ EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
+ EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
- ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error)
+ ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error)
+ ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error)
+ DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error)
}
type WorksmobileConfigSummary struct {
@@ -49,10 +54,36 @@ type WorksmobileBackfillDryRun struct {
}
type WorksmobileInitialPasswordCredential struct {
- Email string `json:"email"`
- InitialPassword string `json:"initialPassword"`
- Status string `json:"status"`
- LastError string `json:"lastError,omitempty"`
+ Email string `json:"email"`
+ Name string `json:"name,omitempty"`
+ PrimaryLeafOrgName string `json:"primaryLeafOrgName,omitempty"`
+ InitialPassword string `json:"initialPassword"`
+ Status string `json:"status"`
+ LastError string `json:"lastError,omitempty"`
+}
+
+type WorksmobileCredentialBatch struct {
+ BatchID string `json:"batchId"`
+ Operation string `json:"operation,omitempty"`
+ UserCount int `json:"userCount"`
+ PendingCount int `json:"pendingCount"`
+ ProcessingCount int `json:"processingCount"`
+ ProcessedCount int `json:"processedCount"`
+ FailedCount int `json:"failedCount"`
+ HasPasswords bool `json:"hasPasswords"`
+ DeletedAt string `json:"deletedAt,omitempty"`
+ Failures []WorksmobileCredentialBatchFailure `json:"failures,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type WorksmobileCredentialBatchFailure struct {
+ UserID string `json:"userId,omitempty"`
+ Email string `json:"email,omitempty"`
+ Status string `json:"status"`
+ RetryCount int `json:"retryCount"`
+ LastError string `json:"lastError,omitempty"`
+ UpdatedAt string `json:"updatedAt,omitempty"`
}
type WorksmobileComparison struct {
@@ -93,6 +124,10 @@ type WorksmobileComparisonItem struct {
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
+ WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
+ WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
+ WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
+ WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
Status string `json:"status"`
}
@@ -185,8 +220,10 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
return WorksmobileComparison{}, err
}
+ recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
+
return WorksmobileComparison{
- Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID),
+ Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)),
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
}, nil
}
@@ -340,7 +377,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
return item, nil
}
-func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
+func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return nil, err
@@ -394,18 +431,104 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
}
+ item.Payload["displayName"] = strings.TrimSpace(user.Name)
+ item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID)
+ if batchID := strings.TrimSpace(credentialBatchID); batchID != "" {
+ item.Payload["credentialBatchId"] = batchID
+ item.Payload["credentialOperation"] = "worksmobile_user_sync"
+ item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
+ }
if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err
}
return item, nil
}
-func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error) {
+func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return nil, err
}
- jobs, err := s.outboxRepo.ListRecent(ctx, 1000)
+ user, err := s.userRepo.FindByID(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ if user.TenantID == nil {
+ return nil, errors.New("target user has no tenant")
+ }
+ tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID)
+ if err != nil {
+ return nil, err
+ }
+ tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
+ if err != nil {
+ return nil, err
+ }
+ if !ok || tenantRoot.ID != root.ID {
+ return nil, errors.New("target user is outside hanmac-family subtree")
+ }
+ if !domain.IsWorksProvisionedUserStatus(user.Status) {
+ return nil, errors.New("target user status is excluded from Worksmobile password reset")
+ }
+ scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
+ if err != nil {
+ return nil, err
+ }
+ tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
+ payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
+ if err != nil {
+ return nil, err
+ }
+ password := GenerateWorksmobileInitialPassword()
+ request := WorksmobilePasswordResetPayload{
+ Email: strings.TrimSpace(payload.Email),
+ PasswordConfig: WorksmobilePasswordConfig{
+ PasswordCreationType: "ADMIN",
+ Password: password,
+ },
+ }
+ batchID := strings.TrimSpace(credentialBatchID)
+ batchCreatedAt := time.Now().UTC().Format(time.RFC3339Nano)
+ dedupeSuffix := batchID
+ if dedupeSuffix == "" {
+ dedupeSuffix = batchCreatedAt
+ }
+ item := &domain.WorksmobileOutbox{
+ ResourceType: domain.WorksmobileResourceUser,
+ ResourceID: user.ID,
+ Action: domain.WorksmobileActionPasswordReset,
+ DedupeKey: "user:password-reset:" + user.ID + ":" + dedupeSuffix,
+ Payload: domain.JSONMap{
+ "tenantRootId": root.ID,
+ "loginEmail": request.Email,
+ "userExternalKey": user.ID,
+ "initialPassword": password,
+ "displayName": strings.TrimSpace(user.Name),
+ "primaryLeafOrgName": worksmobileUserPrimaryOrgName(*user, tenantByID),
+ "credentialBatchId": batchID,
+ "credentialOperation": "worksmobile_password_reset",
+ "credentialBatchCreatedAt": batchCreatedAt,
+ "request": request,
+ },
+ }
+ if err := s.outboxRepo.Create(ctx, item); err != nil {
+ return nil, err
+ }
+ return item, nil
+}
+
+func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error) {
+ root, err := s.hanmacRoot(ctx, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ credentialBatchID = strings.TrimSpace(credentialBatchID)
+ var jobs []domain.WorksmobileOutbox
+ if credentialBatchID != "" {
+ jobs, err = s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID)
+ } else {
+ jobs, err = s.outboxRepo.ListRecent(ctx, 1000)
+ }
if err != nil {
return nil, err
}
@@ -418,6 +541,9 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont
if stringValue(job.Payload["tenantRootId"]) != root.ID {
continue
}
+ if credentialBatchID != "" && stringValue(job.Payload["credentialBatchId"]) != credentialBatchID {
+ continue
+ }
email := stringValue(job.Payload["loginEmail"])
password := stringValue(job.Payload["initialPassword"])
if email == "" || password == "" || seen[email] {
@@ -425,15 +551,60 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont
}
seen[email] = true
credentials = append(credentials, WorksmobileInitialPasswordCredential{
- Email: email,
- InitialPassword: password,
- Status: job.Status,
- LastError: job.LastError,
+ Email: email,
+ Name: stringValue(job.Payload["displayName"]),
+ PrimaryLeafOrgName: stringValue(job.Payload["primaryLeafOrgName"]),
+ InitialPassword: password,
+ Status: job.Status,
+ LastError: job.LastError,
})
}
return credentials, nil
}
+func (s *worksmobileSyncService) ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error) {
+ root, err := s.hanmacRoot(ctx, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, "")
+ if err != nil {
+ return nil, err
+ }
+ return aggregateWorksmobileCredentialBatches(jobs), nil
+}
+
+func (s *worksmobileSyncService) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error) {
+ root, err := s.hanmacRoot(ctx, tenantID)
+ if err != nil {
+ return WorksmobileCredentialBatch{}, err
+ }
+ credentialBatchID = strings.TrimSpace(credentialBatchID)
+ if credentialBatchID == "" {
+ return WorksmobileCredentialBatch{}, errors.New("credential batch id is required")
+ }
+ jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID)
+ if err != nil {
+ return WorksmobileCredentialBatch{}, err
+ }
+ if len(jobs) == 0 {
+ return WorksmobileCredentialBatch{}, errors.New("credential batch not found")
+ }
+ deletedAt := time.Now().UTC().Format(time.RFC3339)
+ for i := range jobs {
+ nextPayload := scrubWorksmobileCredentialPayload(jobs[i].Payload, deletedAt)
+ if err := s.outboxRepo.UpdatePayload(ctx, jobs[i].ID, nextPayload); err != nil {
+ return WorksmobileCredentialBatch{}, err
+ }
+ jobs[i].Payload = nextPayload
+ }
+ batches := aggregateWorksmobileCredentialBatches(jobs)
+ if len(batches) == 0 {
+ return WorksmobileCredentialBatch{}, errors.New("credential batch not found")
+ }
+ return batches[0], nil
+}
+
func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
if _, err := s.hanmacRoot(ctx, tenantID); err != nil {
return nil, err
@@ -663,7 +834,7 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context
if existingUser.ID == user.ID {
continue
}
- addWorksmobileLocalPart(existing, existingUser.Email, existingUser.ID)
+ addWorksmobileEmail(existing, existingUser.Email, existingUser.ID)
if existingUser.TenantID == nil {
continue
}
@@ -672,16 +843,16 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context
continue
}
for _, alias := range BuildWorksmobileAliasEmails(existingUser, tenant) {
- addWorksmobileLocalPart(existing, alias, existingUser.ID)
+ addWorksmobileEmail(existing, alias, existingUser.ID)
}
}
- return ValidateWorksmobileAliasLocalParts(payload.Email, payload.AliasEmails, existing)
+ return ValidateWorksmobileAliasEmails(payload.Email, payload.AliasEmails, existing)
}
-func addWorksmobileLocalPart(target map[string]string, email string, owner string) {
- localPart, err := domain.ExtractNormalizedEmailLocalPart(email)
- if err == nil && localPart != "" {
- target[localPart] = owner
+func addWorksmobileEmail(target map[string]string, email string, owner string) {
+ normalized := strings.ToLower(strings.TrimSpace(email))
+ if _, err := mail.ParseAddress(normalized); err == nil && normalized != "" {
+ target[normalized] = owner
}
}
@@ -833,6 +1004,196 @@ func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload,
return outboxPayload
}
+func aggregateWorksmobileCredentialBatches(jobs []domain.WorksmobileOutbox) []WorksmobileCredentialBatch {
+ byBatchID := map[string]*WorksmobileCredentialBatch{}
+ for _, job := range jobs {
+ batchID := stringValue(job.Payload["credentialBatchId"])
+ if batchID == "" {
+ continue
+ }
+ batch, ok := byBatchID[batchID]
+ if !ok {
+ createdAt := worksmobileCredentialBatchCreatedAt(job)
+ batch = &WorksmobileCredentialBatch{
+ BatchID: batchID,
+ Operation: stringValue(job.Payload["credentialOperation"]),
+ CreatedAt: createdAt,
+ UpdatedAt: job.UpdatedAt,
+ }
+ byBatchID[batchID] = batch
+ }
+ batch.UserCount++
+ if batch.Operation == "" {
+ batch.Operation = stringValue(job.Payload["credentialOperation"])
+ }
+ jobBatchCreatedAt := worksmobileCredentialBatchCreatedAt(job)
+ if jobBatchCreatedAt.Before(batch.CreatedAt) || batch.CreatedAt.IsZero() {
+ batch.CreatedAt = jobBatchCreatedAt
+ }
+ if job.UpdatedAt.After(batch.UpdatedAt) {
+ batch.UpdatedAt = job.UpdatedAt
+ }
+ switch job.Status {
+ case domain.WorksmobileOutboxStatusPending:
+ batch.PendingCount++
+ case domain.WorksmobileOutboxStatusProcessing:
+ batch.ProcessingCount++
+ case domain.WorksmobileOutboxStatusProcessed:
+ batch.ProcessedCount++
+ case domain.WorksmobileOutboxStatusFailed:
+ batch.FailedCount++
+ batch.Failures = append(batch.Failures, WorksmobileCredentialBatchFailure{
+ UserID: job.ResourceID,
+ Email: worksmobileCredentialJobEmail(job),
+ Status: job.Status,
+ RetryCount: job.RetryCount,
+ LastError: strings.TrimSpace(job.LastError),
+ UpdatedAt: job.UpdatedAt.Format(time.RFC3339),
+ })
+ }
+ if worksmobilePayloadHasPassword(job.Payload) {
+ batch.HasPasswords = true
+ }
+ if deletedAt := stringValue(job.Payload["credentialDeletedAt"]); deletedAt != "" {
+ batch.DeletedAt = deletedAt
+ }
+ }
+ batches := make([]WorksmobileCredentialBatch, 0, len(byBatchID))
+ for _, batch := range byBatchID {
+ batches = append(batches, *batch)
+ }
+ sort.Slice(batches, func(i, j int) bool {
+ return batches[i].CreatedAt.After(batches[j].CreatedAt)
+ })
+ return batches
+}
+
+func worksmobileCredentialBatchCreatedAt(job domain.WorksmobileOutbox) time.Time {
+ if value := stringValue(job.Payload["credentialBatchCreatedAt"]); value != "" {
+ if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
+ return parsed.UTC()
+ }
+ if parsed, err := time.Parse(time.RFC3339, value); err == nil {
+ return parsed.UTC()
+ }
+ }
+ if !job.UpdatedAt.IsZero() && !job.CreatedAt.IsZero() && job.UpdatedAt.After(job.CreatedAt) {
+ return job.UpdatedAt.UTC()
+ }
+ return job.CreatedAt.UTC()
+}
+
+func worksmobileCredentialJobEmail(job domain.WorksmobileOutbox) string {
+ if email := stringValue(job.Payload["loginEmail"]); email != "" {
+ return email
+ }
+ switch request := job.Payload["request"].(type) {
+ case WorksmobileUserPayload:
+ return strings.TrimSpace(request.Email)
+ case WorksmobilePasswordResetPayload:
+ return strings.TrimSpace(request.Email)
+ case map[string]any:
+ return stringValue(request["email"])
+ case domain.JSONMap:
+ return stringValue(request["email"])
+ default:
+ return ""
+ }
+}
+
+func scrubWorksmobileCredentialPayload(payload domain.JSONMap, deletedAt string) domain.JSONMap {
+ nextPayload := make(domain.JSONMap, len(payload)+1)
+ for key, value := range payload {
+ nextPayload[key] = value
+ }
+ delete(nextPayload, "initialPassword")
+ nextPayload["credentialDeletedAt"] = deletedAt
+ nextPayload["request"] = scrubWorksmobileRequestPassword(nextPayload["request"])
+ return nextPayload
+}
+
+func scrubWorksmobileRequestPassword(request any) any {
+ switch v := request.(type) {
+ case WorksmobileUserPayload:
+ v.PasswordConfig.Password = ""
+ return v
+ case WorksmobilePasswordResetPayload:
+ v.PasswordConfig.Password = ""
+ return v
+ case map[string]any:
+ next := make(map[string]any, len(v))
+ for key, value := range v {
+ next[key] = value
+ }
+ next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"])
+ return next
+ case domain.JSONMap:
+ next := make(domain.JSONMap, len(v))
+ for key, value := range v {
+ next[key] = value
+ }
+ next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"])
+ return next
+ default:
+ return request
+ }
+}
+
+func scrubWorksmobilePasswordConfig(config any) any {
+ switch v := config.(type) {
+ case WorksmobilePasswordConfig:
+ v.Password = ""
+ return v
+ case map[string]any:
+ next := make(map[string]any, len(v))
+ for key, value := range v {
+ next[key] = value
+ }
+ next["password"] = ""
+ return next
+ case domain.JSONMap:
+ next := make(domain.JSONMap, len(v))
+ for key, value := range v {
+ next[key] = value
+ }
+ next["password"] = ""
+ return next
+ default:
+ return config
+ }
+}
+
+func worksmobilePayloadHasPassword(payload domain.JSONMap) bool {
+ if stringValue(payload["initialPassword"]) != "" {
+ return true
+ }
+ switch request := payload["request"].(type) {
+ case WorksmobileUserPayload:
+ return strings.TrimSpace(request.PasswordConfig.Password) != ""
+ case WorksmobilePasswordResetPayload:
+ return strings.TrimSpace(request.PasswordConfig.Password) != ""
+ case map[string]any:
+ return worksmobilePasswordConfigHasPassword(request["passwordConfig"])
+ case domain.JSONMap:
+ return worksmobilePasswordConfigHasPassword(request["passwordConfig"])
+ default:
+ return false
+ }
+}
+
+func worksmobilePasswordConfigHasPassword(config any) bool {
+ switch v := config.(type) {
+ case WorksmobilePasswordConfig:
+ return strings.TrimSpace(v.Password) != ""
+ case map[string]any:
+ return stringValue(v["password"]) != ""
+ case domain.JSONMap:
+ return stringValue(v["password"]) != ""
+ default:
+ return false
+ }
+}
+
func stringValue(value any) string {
switch v := value.(type) {
case string:
@@ -842,7 +1203,40 @@ func stringValue(value any) string {
}
}
-func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant) []WorksmobileComparisonItem {
+type worksmobileUserJobSummary struct {
+ Status string
+ RetryCount int
+ LastError string
+ LastAttemptAt string
+}
+
+func worksmobileUserJobSummaries(jobs []domain.WorksmobileOutbox) map[string]worksmobileUserJobSummary {
+ result := map[string]worksmobileUserJobSummary{}
+ for _, job := range jobs {
+ if job.ResourceType != domain.WorksmobileResourceUser {
+ continue
+ }
+ if job.ResourceID == "" {
+ continue
+ }
+ if _, exists := result[job.ResourceID]; exists {
+ continue
+ }
+ result[job.ResourceID] = worksmobileUserJobSummary{
+ Status: job.Status,
+ RetryCount: job.RetryCount,
+ LastError: strings.TrimSpace(job.LastError),
+ LastAttemptAt: job.UpdatedAt.Format(time.RFC3339),
+ }
+ }
+ return result
+}
+
+func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
+ jobSummaryByUserID := map[string]worksmobileUserJobSummary{}
+ if len(jobSummaries) > 0 && jobSummaries[0] != nil {
+ jobSummaryByUserID = jobSummaries[0]
+ }
remoteByExternalID := map[string]WorksmobileRemoteUser{}
remoteByEmail := map[string]WorksmobileRemoteUser{}
for _, remote := range remoteUsers {
@@ -872,7 +1266,8 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
if !matched {
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
}
- if matched && !includeMatched {
+ needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote)
+ if matched && !includeMatched && !needsUpdate {
matchedRemoteIDs[remote.ID] = true
continue
}
@@ -886,8 +1281,19 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
Status: "missing_in_worksmobile",
}
+ if summary, ok := jobSummaryByUserID[user.ID]; ok {
+ item.WorksmobileJobStatus = summary.Status
+ item.WorksmobileJobRetryCount = summary.RetryCount
+ item.WorksmobileLastAttemptAt = summary.LastAttemptAt
+ if summary.Status == domain.WorksmobileOutboxStatusFailed {
+ item.WorksmobileLastError = summary.LastError
+ }
+ }
if matched {
item.Status = "matched"
+ if needsUpdate {
+ item.Status = "needs_update"
+ }
item.WorksmobileID = remote.ID
item.ExternalKey = remote.ExternalID
item.WorksmobileName = remote.DisplayName
@@ -958,6 +1364,62 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
return result
}
+func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
+ if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) {
+ return true
+ }
+ if strings.TrimSpace(remote.DisplayName) != strings.TrimSpace(user.Name) {
+ return true
+ }
+ if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
+ return true
+ }
+ if worksmobileUserManagerNeedsUpdate(user, remote) {
+ return true
+ }
+ return false
+}
+
+func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
+ localManagers := worksmobileUserExplicitOrgUnitManagers(user)
+ if len(localManagers) == 0 {
+ return false
+ }
+ remoteManagers := remote.OrgUnitManagers
+ if len(remoteManagers) == 0 && remote.PrimaryOrgUnitID != "" {
+ remoteManagers = map[string]*bool{remote.PrimaryOrgUnitID: remote.PrimaryOrgUnitIsManager}
+ }
+ for remoteOrgUnitID, remoteManager := range remoteManagers {
+ if remoteManager == nil {
+ continue
+ }
+ localManager, ok := localManagers[worksmobileOrgUnitLocalExternalKey(remoteOrgUnitID)]
+ if ok && localManager != *remoteManager {
+ return true
+ }
+ }
+ return false
+}
+
+func worksmobileUserExplicitOrgUnitManagers(user domain.User) map[string]bool {
+ managers := map[string]bool{}
+ for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
+ if appointment.TenantID == "" || !appointment.HasManager {
+ continue
+ }
+ managers[appointment.TenantID] = appointment.IsManager
+ }
+ return managers
+}
+
+func worksmobileOrgUnitLocalExternalKey(orgUnitID string) string {
+ normalized := strings.TrimSpace(orgUnitID)
+ if after, ok := strings.CutPrefix(normalized, "externalKey:"); ok {
+ return strings.TrimSpace(after)
+ }
+ return normalized
+}
+
func worksmobileUserPrimaryOrgID(user domain.User) string {
if user.TenantID == nil {
return ""
diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go
index 16e85fc2..7db689a0 100644
--- a/backend/internal/service/worksmobile_sync_service_test.go
+++ b/backend/internal/service/worksmobile_sync_service_test.go
@@ -4,11 +4,12 @@ import (
"baron-sso-backend/internal/domain"
"context"
"testing"
+ "time"
"github.com/stretchr/testify/require"
)
-func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) {
+func TestWorksmobileSyncServiceRejectsAliasEmailAlreadyUsedByOtherUser(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
@@ -36,7 +37,7 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
}
existing := domain.User{
ID: "existing-user",
- Email: "used@samaneng.com",
+ Email: "used@hanmaceng.co.kr",
Name: "Existing",
TenantID: &tenantID,
}
@@ -48,7 +49,7 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
nil,
)
- item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
+ item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
require.Nil(t, item)
require.Error(t, err)
@@ -88,7 +89,7 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
nil,
)
- item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
+ item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
require.NoError(t, err)
require.NotNil(t, item)
@@ -101,6 +102,253 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
}
+func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ rootID := "root-tenant"
+ tenantID := "saman-tenant"
+ root := domain.Tenant{
+ ID: rootID,
+ Slug: HanmacFamilyTenantSlug,
+ Name: "Hanmac Family",
+ }
+ tenant := domain.Tenant{
+ ID: tenantID,
+ Slug: "saman",
+ Name: "Saman",
+ Type: domain.TenantTypeCompany,
+ ParentID: &rootID,
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ }
+ target := domain.User{
+ ID: "target-user",
+ Email: "target@samaneng.com",
+ Name: "Target",
+ Status: domain.UserStatusActive,
+ TenantID: &tenantID,
+ }
+ outboxRepo := &fakeWorksmobileOutboxRepo{}
+ service := NewWorksmobileSyncService(
+ &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
+ &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
+ outboxRepo,
+ nil,
+ )
+
+ item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1")
+
+ require.NoError(t, err)
+ require.NotNil(t, item)
+ require.Len(t, outboxRepo.created, 1)
+ require.Equal(t, "batch-1", outboxRepo.created[0].Payload["credentialBatchId"])
+ require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"])
+ require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
+ require.Equal(t, "Saman", outboxRepo.created[0].Payload["primaryLeafOrgName"])
+}
+
+func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ rootID := "root-tenant"
+ tenantID := "saman-leaf"
+ root := domain.Tenant{
+ ID: rootID,
+ Slug: HanmacFamilyTenantSlug,
+ Name: "Hanmac Family",
+ }
+ tenant := domain.Tenant{
+ ID: tenantID,
+ Slug: "people-growth",
+ Name: "인재성장",
+ Type: domain.TenantTypeOrganization,
+ ParentID: &rootID,
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ }
+ target := domain.User{
+ ID: "target-user",
+ Email: "target@samaneng.com",
+ Name: "Target",
+ Status: domain.UserStatusActive,
+ TenantID: &tenantID,
+ }
+ outboxRepo := &fakeWorksmobileOutboxRepo{}
+ service := NewWorksmobileSyncService(
+ &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
+ &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
+ outboxRepo,
+ nil,
+ )
+
+ item, err := service.EnqueueUserPasswordReset(context.Background(), rootID, target.ID, "reset-batch-1")
+
+ require.NoError(t, err)
+ require.NotNil(t, item)
+ require.Len(t, outboxRepo.created, 1)
+ require.Equal(t, domain.WorksmobileActionPasswordReset, outboxRepo.created[0].Action)
+ require.Equal(t, "reset-batch-1", outboxRepo.created[0].Payload["credentialBatchId"])
+ require.Equal(t, "worksmobile_password_reset", outboxRepo.created[0].Payload["credentialOperation"])
+ require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"])
+ require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
+ require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
+ require.Equal(t, "인재성장", outboxRepo.created[0].Payload["primaryLeafOrgName"])
+ require.NotEmpty(t, outboxRepo.created[0].Payload["initialPassword"])
+}
+
+func TestWorksmobileSyncServiceFiltersInitialPasswordsByCredentialBatchID(t *testing.T) {
+ rootID := "root-tenant"
+ root := domain.Tenant{
+ ID: rootID,
+ Slug: HanmacFamilyTenantSlug,
+ Name: "Hanmac Family",
+ }
+ outboxRepo := &fakeWorksmobileOutboxRepo{
+ credentialBatchJobs: []domain.WorksmobileOutbox{
+ {
+ ResourceType: domain.WorksmobileResourceUser,
+ Status: domain.WorksmobileOutboxStatusProcessed,
+ Payload: domain.JSONMap{
+ "tenantRootId": rootID,
+ "loginEmail": "batch-user@samaneng.com",
+ "displayName": "Batch User",
+ "primaryLeafOrgName": "인재성장",
+ "initialPassword": "BatchPass1!",
+ "credentialBatchId": "batch-1",
+ },
+ },
+ {
+ ResourceType: domain.WorksmobileResourceUser,
+ Status: domain.WorksmobileOutboxStatusProcessed,
+ Payload: domain.JSONMap{
+ "tenantRootId": rootID,
+ "loginEmail": "other-user@samaneng.com",
+ "initialPassword": "OtherPass1!",
+ "credentialBatchId": "batch-2",
+ },
+ },
+ {
+ ResourceType: domain.WorksmobileResourceUser,
+ Status: domain.WorksmobileOutboxStatusProcessed,
+ Payload: domain.JSONMap{
+ "tenantRootId": rootID,
+ "loginEmail": "legacy-user@samaneng.com",
+ "initialPassword": "LegacyPass1!",
+ },
+ },
+ },
+ }
+ service := NewWorksmobileSyncService(
+ &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
+ &fakeWorksmobileUserRepo{},
+ outboxRepo,
+ nil,
+ )
+
+ credentials, err := service.ListInitialPasswordCredentials(context.Background(), rootID, "batch-1")
+
+ require.NoError(t, err)
+ require.Equal(t, []WorksmobileInitialPasswordCredential{
+ {
+ Email: "batch-user@samaneng.com",
+ Name: "Batch User",
+ PrimaryLeafOrgName: "인재성장",
+ InitialPassword: "BatchPass1!",
+ Status: domain.WorksmobileOutboxStatusProcessed,
+ },
+ }, credentials)
+}
+
+func TestWorksmobileSyncServiceDeletesCredentialBatchPasswordsButKeepsHistory(t *testing.T) {
+ rootID := "root-tenant"
+ root := domain.Tenant{
+ ID: rootID,
+ Slug: HanmacFamilyTenantSlug,
+ Name: "Hanmac Family",
+ }
+ outboxRepo := &fakeWorksmobileOutboxRepo{
+ credentialBatchJobs: []domain.WorksmobileOutbox{
+ {
+ ID: "job-1",
+ ResourceType: domain.WorksmobileResourceUser,
+ Status: domain.WorksmobileOutboxStatusProcessed,
+ Payload: domain.JSONMap{
+ "tenantRootId": rootID,
+ "loginEmail": "batch-user@samaneng.com",
+ "initialPassword": "BatchPass1!",
+ "credentialBatchId": "batch-1",
+ "credentialOperation": "worksmobile_user_sync",
+ "request": map[string]any{"passwordConfig": map[string]any{"password": "BatchPass1!"}},
+ },
+ },
+ {
+ ID: "job-2",
+ ResourceID: "failed-user",
+ ResourceType: domain.WorksmobileResourceUser,
+ Status: domain.WorksmobileOutboxStatusFailed,
+ RetryCount: 2,
+ LastError: "worksmobile api failed",
+ Payload: domain.JSONMap{
+ "tenantRootId": rootID,
+ "loginEmail": "failed-user@samaneng.com",
+ "initialPassword": "FailedPass1!",
+ "credentialBatchId": "batch-1",
+ "credentialOperation": "worksmobile_user_sync",
+ "request": map[string]any{"passwordConfig": map[string]any{"password": "FailedPass1!"}},
+ },
+ },
+ },
+ }
+ service := NewWorksmobileSyncService(
+ &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
+ &fakeWorksmobileUserRepo{},
+ outboxRepo,
+ nil,
+ )
+
+ before, err := service.ListCredentialBatches(context.Background(), rootID)
+ require.NoError(t, err)
+ require.Len(t, before, 1)
+ require.True(t, before[0].HasPasswords)
+ require.Equal(t, 1, before[0].FailedCount)
+ require.Len(t, before[0].Failures, 1)
+ require.Equal(t, "failed-user", before[0].Failures[0].UserID)
+ require.Equal(t, "failed-user@samaneng.com", before[0].Failures[0].Email)
+ require.Equal(t, "worksmobile api failed", before[0].Failures[0].LastError)
+
+ after, err := service.DeleteCredentialBatchPasswords(context.Background(), rootID, "batch-1")
+
+ require.NoError(t, err)
+ require.Equal(t, "batch-1", after.BatchID)
+ require.False(t, after.HasPasswords)
+ require.Equal(t, 2, after.UserCount)
+ require.NotEmpty(t, after.DeletedAt)
+ require.Len(t, outboxRepo.payloadUpdates, 2)
+ require.Empty(t, stringValue(outboxRepo.payloadUpdates[0]["initialPassword"]))
+ require.Empty(t, stringValue(outboxRepo.payloadUpdates[1]["initialPassword"]))
+ request := outboxRepo.payloadUpdates[0]["request"].(map[string]any)
+ passwordConfig := request["passwordConfig"].(map[string]any)
+ require.Empty(t, stringValue(passwordConfig["password"]))
+}
+
+func TestAggregateWorksmobileCredentialBatchesUsesCredentialBatchCreatedAt(t *testing.T) {
+ oldCreatedAt := time.Date(2026, 5, 29, 1, 4, 15, 0, time.UTC)
+ batchCreatedAt := time.Date(2026, 6, 1, 7, 20, 0, 0, time.UTC)
+
+ batches := aggregateWorksmobileCredentialBatches([]domain.WorksmobileOutbox{
+ {
+ ID: "job-1",
+ CreatedAt: oldCreatedAt,
+ UpdatedAt: batchCreatedAt.Add(time.Minute),
+ Status: domain.WorksmobileOutboxStatusPending,
+ Payload: domain.JSONMap{
+ "credentialBatchId": "batch-1",
+ "credentialOperation": "worksmobile_user_sync",
+ "credentialBatchCreatedAt": batchCreatedAt.Format(time.RFC3339),
+ },
+ },
+ })
+
+ require.Len(t, batches, 1)
+ require.Equal(t, batchCreatedAt, batches[0].CreatedAt)
+}
+
func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
@@ -133,7 +381,7 @@ func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
nil,
)
- item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
+ item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
require.NoError(t, err)
require.NotNil(t, item)
@@ -1139,6 +1387,95 @@ func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) {
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
}
+func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
+ tenantID := "tenant-leaf"
+ user := domain.User{
+ ID: "user-manager",
+ Email: "manager@samaneng.com",
+ Name: "Manager User",
+ TenantID: &tenantID,
+ Status: domain.UserStatusActive,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": tenantID,
+ "isPrimary": true,
+ "isManager": true,
+ },
+ },
+ },
+ }
+ remoteManager := false
+ items := compareWorksmobileUsers(
+ []domain.User{user},
+ []WorksmobileRemoteUser{{
+ ID: "works-user-manager",
+ ExternalID: user.ID,
+ Email: user.Email,
+ DisplayName: user.Name,
+ PrimaryOrgUnitID: "externalKey:" + tenantID,
+ PrimaryOrgUnitIsManager: &remoteManager,
+ }},
+ true,
+ map[string]domain.Tenant{
+ tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization},
+ },
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, "needs_update", items[0].Status)
+}
+
+func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
+ primaryTenantID := "tenant-company"
+ secondaryTenantID := "tenant-gpdtdc-leaf"
+ user := domain.User{
+ ID: "user-secondary-manager",
+ Email: "secondary-manager@samaneng.com",
+ Name: "Secondary Manager User",
+ TenantID: &secondaryTenantID,
+ Status: domain.UserStatusActive,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": primaryTenantID,
+ "isPrimary": true,
+ },
+ map[string]any{
+ "tenantId": secondaryTenantID,
+ "isPrimary": false,
+ "isManager": true,
+ },
+ },
+ },
+ }
+ remotePrimaryManager := false
+ remoteSecondaryManager := false
+ items := compareWorksmobileUsers(
+ []domain.User{user},
+ []WorksmobileRemoteUser{{
+ ID: "works-user-secondary-manager",
+ ExternalID: user.ID,
+ Email: user.Email,
+ DisplayName: user.Name,
+ PrimaryOrgUnitID: "externalKey:" + primaryTenantID,
+ PrimaryOrgUnitIsManager: &remotePrimaryManager,
+ OrgUnitManagers: map[string]*bool{
+ "externalKey:" + primaryTenantID: &remotePrimaryManager,
+ "externalKey:" + secondaryTenantID: &remoteSecondaryManager,
+ },
+ }},
+ true,
+ map[string]domain.Tenant{
+ primaryTenantID: {ID: primaryTenantID, Name: "Company", Type: domain.TenantTypeCompany},
+ secondaryTenantID: {ID: secondaryTenantID, Name: "GPDTDC Leaf", Type: domain.TenantTypeOrganization},
+ },
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, "needs_update", items[0].Status)
+}
+
type fakeWorksmobileTenantService struct {
tenants map[string]domain.Tenant
list []domain.Tenant
diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml
index 2771c452..fcbdf6fa 100644
--- a/common/pnpm-lock.yaml
+++ b/common/pnpm-lock.yaml
@@ -63,8 +63,8 @@ importers:
specifier: ^3.3.0
version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.6)
react-router-dom:
- specifier: ^6.28.2
- version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ specifier: ^7.15.1
+ version: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge:
specifier: ^3.4.0
version: 3.6.0
@@ -469,7 +469,7 @@ importers:
specifier: ^8.0.14
version: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
vitest:
- specifier: ^4.1.6
+ specifier: 4.1.6
version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))
packages:
@@ -549,28 +549,24 @@ packages:
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@biomejs/cli-linux-arm64@2.4.16':
resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.4.16':
resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@biomejs/cli-linux-x64@2.4.16':
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@biomejs/cli-win32-arm64@2.4.16':
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
@@ -1095,10 +1091,6 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
- '@remix-run/router@1.23.2':
- resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
- engines: {node: '>=14.0.0'}
-
'@rolldown/binding-android-arm64@1.0.0':
resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1164,84 +1156,72 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-arm64-gnu@1.0.2':
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0':
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rolldown/binding-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0':
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0':
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.2':
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0':
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.2':
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0':
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rolldown/binding-linux-x64-musl@1.0.2':
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0':
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
@@ -1940,28 +1920,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -2219,13 +2195,6 @@ packages:
'@types/react':
optional: true
- react-router-dom@6.30.3:
- resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==}
- engines: {node: '>=14.0.0'}
- peerDependencies:
- react: '>=16.8'
- react-dom: '>=16.8'
-
react-router-dom@7.15.0:
resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==}
engines: {node: '>=20.0.0'}
@@ -2233,11 +2202,12 @@ packages:
react: '>=18'
react-dom: '>=18'
- react-router@6.30.3:
- resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
- engines: {node: '>=14.0.0'}
+ react-router-dom@7.16.0:
+ resolution: {integrity: sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==}
+ engines: {node: '>=20.0.0'}
peerDependencies:
- react: '>=16.8'
+ react: '>=18'
+ react-dom: '>=18'
react-router@7.15.0:
resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==}
@@ -2249,6 +2219,16 @@ packages:
react-dom:
optional: true
+ react-router@7.16.0:
+ resolution: {integrity: sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -3210,8 +3190,6 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
- '@remix-run/router@1.23.2': {}
-
'@rolldown/binding-android-arm64@1.0.0':
optional: true
@@ -4199,23 +4177,17 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
- react-router-dom@6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
- dependencies:
- '@remix-run/router': 1.23.2
- react: 19.2.6
- react-dom: 19.2.6(react@19.2.6)
- react-router: 6.30.3(react@19.2.6)
-
react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- react-router@6.30.3(react@19.2.6):
+ react-router-dom@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
- '@remix-run/router': 1.23.2
react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ react-router: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
@@ -4225,6 +4197,14 @@ snapshots:
optionalDependencies:
react-dom: 19.2.6(react@19.2.6)
+ react-router@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
+ dependencies:
+ cookie: 1.1.1
+ react: 19.2.6
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 19.2.6(react@19.2.6)
+
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6):
dependencies:
get-nonce: 1.0.1
diff --git a/devfront/scripts/runtime-mode.sh b/devfront/scripts/runtime-mode.sh
index d07c9053..5374b8ff 100644
--- a/devfront/scripts/runtime-mode.sh
+++ b/devfront/scripts/runtime-mode.sh
@@ -106,7 +106,7 @@ ensure_frontend_dependencies() {
return 0
fi
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
- (cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --no-frozen-lockfile --ignore-scripts)
+ (cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
else
npm ci
fi
diff --git a/docs/backup-restore-design.md b/docs/backup-restore-design.md
new file mode 100644
index 00000000..06341a94
--- /dev/null
+++ b/docs/backup-restore-design.md
@@ -0,0 +1,349 @@
+# Baron SSO 전체 시스템 백업 및 복구 설계
+
+## 목적
+
+Baron SSO의 전체 시스템 백업/복구는 CSV export/import의 확장판이 아니라, 서비스 저장소 전체를 일관된 시점으로 보존하고 복원하는 재해 복구 기능이다.
+
+핵심 목표는 다음과 같다.
+
+- 사용자, 조직, 권한, RP, WORKS 연동 참조에 쓰이는 UUID를 그대로 보존한다.
+- Kratos identity subject와 Baron local user ID가 어긋나지 않게 복구한다.
+- Hydra/Keto/Oathkeeper 기반 인증/인가 상태를 서비스 가능한 수준으로 복원한다.
+- 복구 후 WORKS externalKey 기반 비교/동기화가 기존 연동과 이어지도록 한다.
+- 백업 산출물의 무결성, 보안, 복구 가능성을 검증 가능한 절차로 만든다.
+
+## 배경과 결론
+
+사용자 CSV는 `user_id`를 포함해 내보낼 수 있지만, 실제 사용자 계정의 주체 ID는 Kratos identity ID다. Kratos Admin API의 identity 생성 요청은 `id` 필드를 받지 않으므로, CSV import만으로 기존 사용자 UUID를 보장할 수 없다.
+
+따라서 사용자 UUID 보존이 필요한 복구는 반드시 Kratos DB까지 포함한 full backup/restore로 처리해야 한다. CSV import는 운영 편의 기능으로 유지하되, 재해 복구나 WORKS 연동 보존 목적의 원장 복구 수단으로 간주하지 않는다.
+
+## 백업 대상
+
+### 필수 저장소
+
+| 대상 | 저장소 | 포함 이유 | 복구 필수 여부 |
+| --- | --- | --- | --- |
+| Baron 애플리케이션 DB | `baron_postgres` | users, tenants, user_login_ids, user_groups, api_keys, outbox, WORKS mapping, RP metadata 등 | 필수 |
+| Kratos DB | `ory_postgres`의 `ory_kratos` | identity UUID, credentials, verifiable/recovery addresses, sessions | 필수 |
+| Hydra DB | `ory_postgres`의 `ory_hydra` | OAuth2 clients, consent, token/session 관련 상태 | 필수 |
+| Keto DB | `ory_postgres`의 `ory_keto` | ReBAC relation tuple 원장 | 필수 |
+| Baron ClickHouse | `baron_clickhouse` | 감사 로그, 운영 추적 데이터 | 운영 정책상 필수 |
+| Ory ClickHouse | `ory_clickhouse` | Ory/Oathkeeper/Vector 계열 로그 | 운영 정책상 필수 |
+| 설정/비밀값 | `.env`, generated Ory config, WORKS private key, gateway/Oathkeeper config | 동일 환경 재기동과 외부 연동에 필요 | 필수 |
+
+### 선택 저장소
+
+| 대상 | 처리 원칙 |
+| --- | --- |
+| Redis | 로그인 pending state, cache, short code 등 휘발성 데이터다. full restore에서는 원칙적으로 제외하고 재시작 시 비운다. 무중단 이전 시나리오에서만 snapshot을 검토한다. |
+| 프론트 빌드 산출물 | 소스/이미지 태그로 재생성한다. 별도 보관은 배포 재현성을 위한 선택 항목이다. |
+| 로컬 개발 산출물 | reports, coverage, test-results 등은 백업 대상에서 제외한다. |
+
+## 백업 산출물 형식
+
+백업 단위는 압축된 디렉터리 또는 object storage prefix로 관리한다.
+
+```text
+baron-sso-backup-YYYYMMDD-HHMMSSZ/
+ manifest.json
+ checksums.sha256
+ postgres/
+ baron.dump
+ ory_kratos.dump
+ ory_hydra.dump
+ ory_keto.dump
+ globals.sql
+ clickhouse/
+ baron_clickhouse/
+ ory_clickhouse/
+ config/
+ env.redacted
+ env.encrypted
+ ory/
+ gateway/
+ reports/
+ row-counts.json
+ restore-readiness.json
+```
+
+`manifest.json`에는 최소한 다음 정보를 기록한다.
+
+- backup format version
+- 생성 시각, git commit, 이미지 태그
+- 서비스별 DB schema/migration version
+- 각 dump 파일의 checksum, 크기, 생성 명령 버전
+- 백업 모드: `offline`, `maintenance`, `online-best-effort`
+- 암호화 방식과 key id
+- 복구 대상 환경 제한: `same-env-only`, `staging-rehearsal`, `cross-env`
+
+## 백업 모드
+
+### 1. Offline backup
+
+가장 안전한 모드다. 모든 writer를 중지한 뒤 dump한다.
+
+순서:
+
+1. gateway 또는 Oathkeeper에서 maintenance mode 활성화
+2. backend, relay worker, vector 등 write producer 중지
+3. Kratos/Hydra/Keto public/admin 요청 차단 또는 컨테이너 중지
+4. Baron Postgres dump
+5. Ory Postgres의 Kratos/Hydra/Keto DB dump
+6. ClickHouse backup
+7. 설정/비밀값 백업
+8. checksum과 row count 생성
+9. 서비스 재개
+
+이 모드는 사용자 UUID, WORKS mapping, Keto relation, OAuth consent 상태의 일관성이 가장 좋다.
+
+### 2. Maintenance backup
+
+짧은 점검 모드에서 writer만 막고 read는 제한적으로 허용한다. 운영 기본 모드로 권장한다.
+
+필수 조건:
+
+- 사용자 생성/삭제/수정 차단
+- 테넌트/조직 변경 차단
+- WORKS outbox relay 중지
+- Keto outbox relay 중지
+- OAuth client 변경 차단
+
+### 3. Online best-effort backup
+
+무중단 스냅샷이다. 저장소가 여러 개라 cross-store 일관성을 보장할 수 없다. 감사 로그나 분석용 백업에는 가능하지만, 재해 복구용 원본으로는 사용하지 않는다.
+
+## Postgres 백업 전략
+
+Postgres는 논리 dump를 기본으로 한다.
+
+- `pg_dump -Fc` 형식 사용
+- DB별 dump 파일 분리
+- `pg_dumpall --globals-only`로 role/extension/권한 정보 별도 보관
+- 백업 전후 row count와 핵심 UUID sample 기록
+
+대상 DB:
+
+- Baron DB: `DB_NAME`
+- Kratos DB: `ory_kratos`
+- Hydra DB: `ory_hydra`
+- Keto DB: `ory_keto`
+
+복구는 빈 DB에 `pg_restore --clean --if-exists`로 수행한다. 기존 운영 DB에 덮어쓰는 in-place restore는 금지한다.
+
+## ClickHouse 백업 전략
+
+ClickHouse는 감사 로그 성격이 강하므로 정책을 분리한다.
+
+- 재해 복구: ClickHouse native backup 또는 volume snapshot 사용
+- 장기 보관: 파티션 단위 export 또는 object storage backup
+- 복구 검증: 날짜 파티션별 row count와 min/max timestamp 비교
+
+ClickHouse 백업 실패가 인증 기능 복구를 막지는 않지만, 감사 로그 보존 정책상 별도 실패로 취급한다.
+
+## Redis 처리 원칙
+
+Redis는 기본적으로 백업하지 않는다.
+
+복구 후 영향:
+
+- 로그인 pending flow 만료
+- short code/link login flow 재시작 필요
+- headless JWKS cache 재생성
+- 세션 cache miss 발생 가능
+
+Kratos/Hydra 자체 session/token 원장은 Postgres 쪽에 있으므로 Redis가 비어 있어도 서비스는 재수렴해야 한다.
+
+## 설정과 비밀값
+
+DB dump만으로는 복구가 불완전하다. 다음 항목은 암호화해서 함께 보관한다.
+
+- `.env` 또는 배포 환경 변수
+- Ory generated config
+- Hydra system secret, cookie secret
+- Kratos courier/config secret
+- Keto config
+- Oathkeeper rules/config
+- WORKS Admin OAuth client private key
+- API gateway 설정
+- object storage backup key id
+
+`env.redacted`는 검토용이고, 실제 복구에는 `env.encrypted`만 사용한다.
+
+## 복구 절차
+
+### Full restore 기본 절차
+
+1. 대상 환경을 새로 준비한다.
+2. 모든 애플리케이션 서비스를 중지한다.
+3. 기존 DB가 있으면 별도 보관 후 빈 DB를 만든다.
+4. Postgres globals를 복구한다.
+5. Baron DB를 복구한다.
+6. Kratos/Hydra/Keto DB를 복구한다.
+7. ClickHouse를 복구한다.
+8. 설정/비밀값을 복호화해 배치한다.
+9. migration은 자동 실행하지 않고 현재 dump의 schema version을 확인한다.
+10. Ory Stack을 기동한다.
+11. backend를 기동한다.
+12. relay worker는 아직 켜지 않는다.
+13. post-restore verification을 수행한다.
+14. WORKS comparison dry-run을 수행한다.
+15. 문제가 없을 때 relay worker와 외부 동기화를 재개한다.
+
+### Post-restore verification
+
+필수 검증:
+
+- Kratos identity 수와 Baron users 수 비교
+- Baron `users.id`가 Kratos `identities.id`에 존재하는지 확인
+- tenant parent tree 참조 무결성 확인
+- `user_login_ids.user_id`, `user_login_ids.tenant_id` 참조 무결성 확인
+- Keto relation subject/object가 복구된 사용자/테넌트를 참조하는지 확인
+- Hydra client와 Baron RP metadata 참조 확인
+- WORKS mapping의 BaronResourceID가 복구된 사용자/테넌트를 참조하는지 확인
+- super admin 로그인 확인
+- 일반 사용자 로그인 확인
+- 대표 RP OIDC login/consent 확인
+- WORKS comparison dry-run에서 externalKey 기준 대량 삭제/생성 후보가 없는지 확인
+
+## WORKS 연동 복구 정책
+
+WORKS 자체 데이터는 Baron 백업으로 복구하지 않는다. Baron이 보존해야 하는 것은 WORKS와 연결되는 참조 키다.
+
+필수 보존:
+
+- 사용자 UUID
+- 테넌트 UUID
+- WORKS resource mapping
+- WORKS outbox 처리 상태
+- WORKS domain mapping/config
+
+복구 직후 정책:
+
+- relay worker 자동 실행 금지
+- comparison dry-run 먼저 실행
+- externalKey 기준으로 Baron/WORKS가 매칭되는지 확인
+- 대량 delete/upsert가 감지되면 동기화 중단
+- 확인 후 필요한 사용자/조직만 재동기화
+
+outbox는 복구 모드에 따라 처리한다.
+
+| 복구 모드 | outbox 정책 |
+| --- | --- |
+| 같은 운영 환경 재해 복구 | outbox 상태 보존, 단 relay 재개 전 dry-run 필수 |
+| staging rehearsal | outbox relay 비활성화, 외부 WORKS 호출 금지 |
+| cross-env migration | outbox는 보존하되 실행하지 않고 별도 remap 정책 필요 |
+
+## CSV export/import의 위치
+
+CSV는 다음 목적으로만 사용한다.
+
+- 운영자가 사용자/조직을 일괄 등록하거나 보정
+- 일부 필드를 검토하기 위한 추출
+- dry-run 입력 보조 자료
+
+CSV는 다음 목적에 사용하지 않는다.
+
+- Kratos identity UUID 보존 복구
+- 비밀번호/credential 복구
+- OAuth consent/token/session 복구
+- Keto relation 원장 복구
+- WORKS mapping 원장 복구
+
+## 구현 계획
+
+### Phase 1: 백업/복구 스크립트와 문서화
+
+Estimate Time: 3~5d
+
+- `scripts/backup/full-backup.sh`
+- `scripts/backup/full-restore.sh`
+- `scripts/backup/verify-backup.sh`
+- `scripts/backup/verify-restore.sh`
+- `manifest.json` 생성기
+- checksum 생성
+- row count report 생성
+
+### Phase 2: staging restore rehearsal
+
+Estimate Time: 3~5d
+
+- 백업 파일로 격리된 staging stack 복구
+- Ory/Baron/Postgres/ClickHouse 복구 검증
+- 로그인/OIDC/관리자 화면 smoke test
+- WORKS 외부 호출 차단 상태에서 comparison dry-run
+
+### Phase 3: 운영 자동화
+
+Estimate Time: 5~8d
+
+- 정기 백업 스케줄링
+- 암호화 및 object storage 업로드
+- retention 정책
+- 실패 알림
+- restore rehearsal 주기화
+
+### Phase 4: 관리 UI
+
+Estimate Time: 5~8d
+
+- backup 목록 조회
+- backup 생성 요청
+- restore readiness report 조회
+- staging rehearsal 결과 조회
+- 운영 restore는 UI에서 직접 실행하지 않고 승인 절차와 runbook으로 제한
+
+## 테스트 전략
+
+### 단위 테스트
+
+- manifest 생성 검증
+- checksum 검증
+- row count report 비교
+- restore readiness parser 검증
+
+### 통합 테스트
+
+- fixture DB 생성
+- full backup 실행
+- 빈 DB로 restore
+- 핵심 테이블 row count 비교
+- 사용자/테넌트 UUID 동일성 비교
+- Kratos identity와 Baron user ID 일치 검증
+
+### E2E 테스트
+
+- super admin 로그인
+- 일반 사용자 로그인
+- AdminFront 사용자 목록 조회
+- UserFront 로그인
+- 대표 RP OIDC 로그인
+- WORKS comparison dry-run
+
+### 실패 테스트
+
+- 누락된 dump 파일
+- checksum 불일치
+- schema version 불일치
+- 일부 DB만 복구된 상태
+- Kratos identity는 있는데 Baron user가 없는 상태
+- Baron user는 있는데 Kratos identity가 없는 상태
+- WORKS mapping이 복구되지 않은 상태
+
+## 운영 정책
+
+- 백업은 암호화하지 않은 상태로 저장하지 않는다.
+- 운영 restore는 빈 환경 또는 새 볼륨에만 수행한다.
+- restore 전 현재 운영 DB는 별도 snapshot으로 보존한다.
+- restore 후 WORKS relay는 수동 승인 전까지 비활성화한다.
+- 월 1회 이상 staging restore rehearsal을 수행한다.
+- schema migration 직전 수동 backup을 강제한다.
+
+## 남은 결정 사항
+
+- RPO/RTO 목표값
+- 백업 저장 위치와 암호화 key 관리 방식
+- ClickHouse 장기 보관 기간
+- WORKS outbox replay 정책의 운영 기본값
+- 운영 restore 승인자와 절차
+- restore rehearsal 자동 실행 주기
diff --git a/orgfront/scripts/runtime-mode.sh b/orgfront/scripts/runtime-mode.sh
index 5dbd8eb6..4c3bb88a 100644
--- a/orgfront/scripts/runtime-mode.sh
+++ b/orgfront/scripts/runtime-mode.sh
@@ -106,7 +106,7 @@ ensure_frontend_dependencies() {
return 0
fi
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
- (cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --no-frozen-lockfile --ignore-scripts)
+ (cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
else
npm ci
fi
diff --git a/user_bulk_gpdtdc.CSV b/user_bulk_gpdtdc.CSV
index 1391a0da..eace57f3 100644
--- a/user_bulk_gpdtdc.CSV
+++ b/user_bulk_gpdtdc.CSV
@@ -69,7 +69,7 @@ tskim@samaneng.com,김태식A,010-9965-9940,user,rnd-saman,,,,,222182,design-pla
jhkang@samaneng.com,강정훈,010-9891-8798,user,rnd-saman,,,,,222212,strana,,연구원,,,B22048,b22048@hanmaceng.co.kr
jhkim14@samaneng.com,김재현,010-2534-7837,user,rnd-saman,,,,,222231,watch-bim,,수석연구원,,,B22051,b22051@hanmaceng.co.kr
yjchoi1@samaneng.com,최윤진,010-2349-6687,user,rnd-saman,,,,,222240,way-draw,,연구원,,,B22052,b22052@hanmaceng.co.kr
-wkkim@samaneng.com,김원기,010-4727-8530,user,rnd-saman,,,,,222242,infra-bim1,,책임연구원,,,B22057,kwongi79@hanmaceng.co.kr
+wkkim@samaneng.com,김원기,010-4727-8530,user,rnd-saman,,,,,222242,infra-bim1,,책임연구원,,,B22057,
jhlee@samaneng.com,이준호,010-2514-6898,user,rnd-saman,,,,,223046,structural-software,,연구원,,,B23003,b23003@hanmaceng.co.kr
jhchoi3@samaneng.com,최진헌,010-8638-8079,user,rnd-saman,,,,,222272,strana,,선임연구원,,,B22063,b22063@hanmaceng.co.kr
hulee1@samaneng.com,이한울,010-9271-8997,user,rnd-saman,,,,,222294,web-design,,연구원,,,B22069,b22069@hanmaceng.co.kr
@@ -94,7 +94,7 @@ hmin@samaneng.com,민홍,010-8654-5461,user,rnd-saman,,,,,223313,gsim,,선임연
hwan@samaneng.com,안효원,010-3358-4260,user,rnd-saman,,,,,223228,infra-bim1,,선임연구원,,,B23040,b23040@hanmaceng.co.kr
sihan@samaneng.com,한성일,010-4322-1100,user,rnd-saman,,,,,223226,abut-control,,책임연구원,,,B23042,b23042@hanmaceng.co.kr
jhkim25@samaneng.com,김재환,010-8962-3743,user,rnd-saman,,,,,223229,structural-design,,책임연구원,,,B23041,b23041@hanmaceng.co.kr
-gy9411@naver.com,이가연,010-2430-5102,user,rnd-saman,,,,,223269,slope-structures,,연구원,,,B23047,b23047@hanmaceng.co.kr
+gylee1@samaneng.com,이가연,010-2430-5102,user,rnd-saman,,,,,223269,slope-structures,,연구원,,,B23047,b23047@hanmaceng.co.kr
yskim3@samaneng.com,김예서,010-9167-6132,user,rnd-saman,,,,,223280,land-map-cell,,연구원,,,B23051,b23051@hanmaceng.co.kr
jhpyo@samaneng.com,표재학,010-2522-4984,user,rnd-saman,,,,,223281,primal-plan,,연구원,,,B23052,b23052@hanmaceng.co.kr
sjkim6@samaneng.com,김신지,010-7667-8256,user,rnd-saman,,,,,223361,tech-planning,,연구원,,,B23064,b23064@hanmaceng.co.kr
@@ -140,7 +140,7 @@ hrlee1@samaneng.com,이해랑,010-8628-0094,user,rnd-saman,,,,,225175,modeler,,
jhsim@samaneng.com,심재훈,010-6633-3366,user,rnd-saman,,,,,225183,tunnel,,수석연구원,,,B25025,b25025@hanmaceng.co.kr
shkim4@samaneng.com,김수현,010-5645-5153,user,rnd-saman,,,,,225215,design-planning,,선임연구원,,,B25027,b25027@hanmaceng.co.kr
smbaek@samaneng.com,백승민,010-7156-8542,user,rnd-saman,,,,,225319,hmeg,,책임연구원,,,B25035,b25035@hanmaceng.co.kr
-swpark3@saman.com,박상원,010-4794-0148,user,rnd-saman,,,,,225336,cm-planning,,연구원,,,B25036,b25036@hanmaceng.co.kr
+swpark3@samaneng.com,박상원,010-4794-0148,user,rnd-saman,,,,,225336,cm-planning,,연구원,,,B25036,b25036@hanmaceng.co.kr
smyoun@samaneng.com,윤석무,010-9780-8901,user,rnd-saman,,,,,226049,solution-dev,,연구원,,,B26002,b26002@hanmaceng.co.kr
jhpark4@samaneng.com,박종혁,010-4211-2090,user,rnd-saman,,,,,226072,infra-bim2,,연구원,,,B26003,b26003@hanmaceng.co.kr
dhhong@samaneng.com,홍덕현,010-5360-7314,user,rnd-saman,,,,,226073,structural-design,,연구원,,,B26004,b26004@hanmaceng.co.kr
diff --git a/userfront/scripts/dev-server.sh b/userfront/scripts/dev-server.sh
index a17fe2d4..75a5d7fc 100644
--- a/userfront/scripts/dev-server.sh
+++ b/userfront/scripts/dev-server.sh
@@ -27,11 +27,71 @@ warm_get() {
"http://127.0.0.1:${USERFRONT_INTERNAL_PORT}${path}" >/dev/null 2>&1
}
+wait_for_userfront_build() {
+ flutter_pid="$1"
+ attempt=1
+
+ while [ "$attempt" -le "$USERFRONT_BOOT_WARMUP_ATTEMPTS" ]; do
+ if [ -f "build/web/index.html" ]; then
+ return 0
+ fi
+ if ! kill -0 "$flutter_pid" 2>/dev/null; then
+ echo "[userfront-boot] warmup skipped because flutter exited before build/web/index.html was ready" >&2
+ return 1
+ fi
+ attempt=$((attempt + 1))
+ sleep "$USERFRONT_BOOT_WARMUP_INTERVAL_SECONDS"
+ done
+
+ echo "[userfront-boot] warmup skipped after ${USERFRONT_BOOT_WARMUP_ATTEMPTS} build readiness attempts" >&2
+ return 1
+}
+
+reset_userfront_service_worker() {
+ cat > build/web/flutter_service_worker.js <<'EOF'
+self.addEventListener("install", (event) => {
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ (async () => {
+ if (self.caches) {
+ const keys = await self.caches.keys();
+ await Promise.all(
+ keys
+ .filter(
+ (key) =>
+ key.indexOf("baron-userfront-") === 0 ||
+ key.indexOf("flutter-app-cache") === 0,
+ )
+ .map((key) => self.caches.delete(key)),
+ );
+ }
+
+ await self.registration.unregister();
+
+ const clients = await self.clients.matchAll({
+ type: "window",
+ includeUncontrolled: true,
+ });
+ await Promise.all(clients.map((client) => client.navigate(client.url)));
+ })(),
+ );
+});
+EOF
+}
+
warm_userfront_once() {
flutter_pid="$1"
attempt=1
started_at="$(date +%s)"
+ if ! wait_for_userfront_build "$flutter_pid"; then
+ return 0
+ fi
+ reset_userfront_service_worker
+
while [ "$attempt" -le "$USERFRONT_BOOT_WARMUP_ATTEMPTS" ]; do
if wget -qO- "http://127.0.0.1:${USERFRONT_INTERNAL_PORT}/flutter_bootstrap.js" >/dev/null 2>&1; then
break
|