diff --git a/PostFundManagement.Application/Handlers/ClientCredentialsTokenHandler.cs b/PostFundManagement.Application/Handlers/ClientCredentialsTokenHandler.cs index b2ad9c0..04a37cf 100644 --- a/PostFundManagement.Application/Handlers/ClientCredentialsTokenHandler.cs +++ b/PostFundManagement.Application/Handlers/ClientCredentialsTokenHandler.cs @@ -40,8 +40,10 @@ public class ClientCredentialsTokenHandler(ILogger contextF if (await context.Users.AnyAsync(u => u.Email == email, cancellationToken)) return Result.Fail(ValidationError.Create("User with this email already exists.")); + // Normalize and sanitize request.UserName for ASP.NET Core Identity / Skoruba + // IdentityServer rejects user names containing whitespace or disallowed characters + var userName = request.UserName?.Trim(); + if (string.IsNullOrWhiteSpace(userName)) + { + userName = email; + } + else + { + // Replace whitespace with dots and filter to allowed Identity characters + userName = System.Text.RegularExpressions.Regex.Replace(userName, @"\s+", ".").ToLowerInvariant(); + userName = System.Text.RegularExpressions.Regex.Replace(userName, @"[^a-z0-9_.\-@+]", ""); + if (string.IsNullOrWhiteSpace(userName)) + { + userName = email; + } + } + request.UserName = userName; + request.Email = email; + var createUserResult = await skorubaService.CreateUserAsync(request, cancellationToken); if (createUserResult.IsFailed) - return Result.Fail(CreateError.Create("Failed to create user.")); + return Result.Fail(CreateError.Create(createUserResult.Errors.FirstOrDefault()?.Message ?? "Failed to create user.")); var newUserId = Guid.Parse(createUserResult.Value.Trim()); diff --git a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx index e288d7b..19fc106 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx @@ -123,9 +123,14 @@ export const CreateUserModal: React.FC = ({ } } + const rawEmail = values.email.trim().toLowerCase(); + const sanitizedUserName = values.userName?.trim() + ? values.userName.trim().toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '') + : rawEmail.split('@')[0]; + const payload: CreateUserRequest = { - email: values.email.trim(), - userName: values.userName?.trim() || values.email.split('@')[0], + email: rawEmail, + userName: sanitizedUserName || rawEmail, emailConfirmed: true, phoneNumber: values.phoneNumber?.trim() || null, phoneNumberConfirmed: false, diff --git a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx index f37c3f4..659996b 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx @@ -293,9 +293,14 @@ export const GovernancePage: React.FC = () => { setIsSubmittingQuickUser(true); try { const tenantId = getCurrentUserTenantId() || 1; + const rawEmail = quickUserEmail.trim().toLowerCase(); + const sanitizedUserName = quickUserName.trim() + ? quickUserName.trim().toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '') + : rawEmail.split('@')[0]; + const payload: CreateUserRequest = { - email: quickUserEmail.trim().toLowerCase(), - userName: quickUserName.trim() || quickUserEmail.split('@')[0], + email: rawEmail, + userName: sanitizedUserName || rawEmail, emailConfirmed: true, phoneNumber: quickUserPhone.trim() || null, phoneNumberConfirmed: false, @@ -1666,14 +1671,15 @@ export const GovernancePage: React.FC = () => {
- + setQuickUserName(e.target.value)} - placeholder="e.g. Sipho Ndlovu" + placeholder="e.g. Tony Ngcobo" className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" /> +

Will be normalized to valid login username (e.g. tony.ngcobo)

diff --git a/PostFundManagement.ConsoleUI/src/services/api.ts b/PostFundManagement.ConsoleUI/src/services/api.ts index f7481f3..6a3a3c7 100644 --- a/PostFundManagement.ConsoleUI/src/services/api.ts +++ b/PostFundManagement.ConsoleUI/src/services/api.ts @@ -1382,10 +1382,22 @@ export const createUser = async ( ): Promise => { const encodedTenant = encodeEntityId(tenantId) || String(tenantId); const encodedIdNumber = encodeIdNumberHex(idNumber); + + // Normalize and sanitize userName: Duende / Skoruba Identity Server rejects usernames with spaces + const rawEmail = payload.email?.trim().toLowerCase() || ''; + const rawUserName = payload.userName?.trim(); + let sanitizedUserName = rawUserName + ? rawUserName.toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '') + : rawEmail.split('@')[0]; + + if (!sanitizedUserName) { + sanitizedUserName = rawEmail; + } + await apiClient.post(`/v1/api/users/${encodeURIComponent(encodedTenant)}/${encodeURIComponent(encodedIdNumber)}`, { id: payload.id || null, - userName: payload.userName || null, - email: payload.email || null, + userName: sanitizedUserName, + email: rawEmail || null, emailConfirmed: payload.emailConfirmed ?? true, phoneNumber: payload.phoneNumber || null, phoneNumberConfirmed: payload.phoneNumberConfirmed ?? false, diff --git a/PostFundManagement.Infrastructure/Security/SkorubaUserService.cs b/PostFundManagement.Infrastructure/Security/SkorubaUserService.cs index 366477f..6eb173b 100644 --- a/PostFundManagement.Infrastructure/Security/SkorubaUserService.cs +++ b/PostFundManagement.Infrastructure/Security/SkorubaUserService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Models.Security; @@ -13,7 +13,11 @@ public class SkorubaUserService(IHttpClientFactory httpClientFactory) : ISkoruba var response = await httpClient.PostAsJsonAsync("api/users", request, cancellationToken); if (!response.IsSuccessStatusCode) - return Result.Fail(CreateError.Create(response.ReasonPhrase!)); + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + var errorMsg = !string.IsNullOrWhiteSpace(errorBody) ? errorBody : response.ReasonPhrase!; + return Result.Fail(CreateError.Create(errorMsg)); + } var created = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);