1
0
forked from baron/baron-sso

feat: update worksmobile sync and restore planning

This commit is contained in:
2026-06-01 17:01:53 +09:00
parent 6574fb54b9
commit 5c8a338085
36 changed files with 3922 additions and 243 deletions

View File

@@ -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 ""