Merge pull request 'Fixed new user username formating' (#143) from finishline into main
Reviewed-on: #143
This commit was merged in pull request #143.
This commit is contained in:
@@ -40,8 +40,10 @@ public class ClientCredentialsTokenHandler(ILogger<ClientCredentialsTokenHandler
|
|||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
logger.LogWarning("HTTP request failed with status {StatusCode} for {Method} {RequestUri}",
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
response.StatusCode, request.Method, request.RequestUri);
|
logger.LogWarning("HTTP request failed with status {StatusCode} for {Method} {RequestUri}. Details: {Details}",
|
||||||
|
response.StatusCode, request.Method, request.RequestUri, content);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using PostFundManagement.Application.Abstractions.Services;
|
using PostFundManagement.Application.Abstractions.Services;
|
||||||
using PostFundManagement.Application.Services.Shared;
|
using PostFundManagement.Application.Services.Shared;
|
||||||
using PostFundManagement.Domain;
|
using PostFundManagement.Domain;
|
||||||
using PostFundManagement.Domain.Abstractions;
|
using PostFundManagement.Domain.Abstractions;
|
||||||
@@ -147,10 +147,30 @@ public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextF
|
|||||||
if (await context.Users.AnyAsync(u => u.Email == email, cancellationToken))
|
if (await context.Users.AnyAsync(u => u.Email == email, cancellationToken))
|
||||||
return Result.Fail(ValidationError.Create("User with this email already exists."));
|
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);
|
var createUserResult = await skorubaService.CreateUserAsync(request, cancellationToken);
|
||||||
|
|
||||||
if (createUserResult.IsFailed)
|
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());
|
var newUserId = Guid.Parse(createUserResult.Value.Trim());
|
||||||
|
|
||||||
|
|||||||
@@ -123,9 +123,14 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
const payload: CreateUserRequest = {
|
||||||
email: values.email.trim(),
|
email: rawEmail,
|
||||||
userName: values.userName?.trim() || values.email.split('@')[0],
|
userName: sanitizedUserName || rawEmail,
|
||||||
emailConfirmed: true,
|
emailConfirmed: true,
|
||||||
phoneNumber: values.phoneNumber?.trim() || null,
|
phoneNumber: values.phoneNumber?.trim() || null,
|
||||||
phoneNumberConfirmed: false,
|
phoneNumberConfirmed: false,
|
||||||
|
|||||||
@@ -293,9 +293,14 @@ export const GovernancePage: React.FC = () => {
|
|||||||
setIsSubmittingQuickUser(true);
|
setIsSubmittingQuickUser(true);
|
||||||
try {
|
try {
|
||||||
const tenantId = getCurrentUserTenantId() || 1;
|
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 = {
|
const payload: CreateUserRequest = {
|
||||||
email: quickUserEmail.trim().toLowerCase(),
|
email: rawEmail,
|
||||||
userName: quickUserName.trim() || quickUserEmail.split('@')[0],
|
userName: sanitizedUserName || rawEmail,
|
||||||
emailConfirmed: true,
|
emailConfirmed: true,
|
||||||
phoneNumber: quickUserPhone.trim() || null,
|
phoneNumber: quickUserPhone.trim() || null,
|
||||||
phoneNumberConfirmed: false,
|
phoneNumberConfirmed: false,
|
||||||
@@ -1666,14 +1671,15 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-medium text-slate-300 mb-1">Full / User Name</label>
|
<label className="block text-[11px] font-medium text-slate-300 mb-1">Full Name / Username</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={quickUserName}
|
value={quickUserName}
|
||||||
onChange={(e) => setQuickUserName(e.target.value)}
|
onChange={(e) => 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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-0.5">Will be normalized to valid login username (e.g. tony.ngcobo)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1382,10 +1382,22 @@ export const createUser = async (
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const encodedTenant = encodeEntityId(tenantId) || String(tenantId);
|
const encodedTenant = encodeEntityId(tenantId) || String(tenantId);
|
||||||
const encodedIdNumber = encodeIdNumberHex(idNumber);
|
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)}`, {
|
await apiClient.post(`/v1/api/users/${encodeURIComponent(encodedTenant)}/${encodeURIComponent(encodedIdNumber)}`, {
|
||||||
id: payload.id || null,
|
id: payload.id || null,
|
||||||
userName: payload.userName || null,
|
userName: sanitizedUserName,
|
||||||
email: payload.email || null,
|
email: rawEmail || null,
|
||||||
emailConfirmed: payload.emailConfirmed ?? true,
|
emailConfirmed: payload.emailConfirmed ?? true,
|
||||||
phoneNumber: payload.phoneNumber || null,
|
phoneNumber: payload.phoneNumber || null,
|
||||||
phoneNumberConfirmed: payload.phoneNumberConfirmed ?? false,
|
phoneNumberConfirmed: payload.phoneNumberConfirmed ?? false,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using PostFundManagement.Domain.Abstractions;
|
using PostFundManagement.Domain.Abstractions;
|
||||||
using PostFundManagement.Domain.Errors;
|
using PostFundManagement.Domain.Errors;
|
||||||
using PostFundManagement.Domain.Models.Security;
|
using PostFundManagement.Domain.Models.Security;
|
||||||
|
|
||||||
@@ -13,7 +13,11 @@ public class SkorubaUserService(IHttpClientFactory httpClientFactory) : ISkoruba
|
|||||||
var response = await httpClient.PostAsJsonAsync("api/users", request, cancellationToken);
|
var response = await httpClient.PostAsJsonAsync("api/users", request, cancellationToken);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
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<UserResponse>(cancellationToken: cancellationToken);
|
var created = await response.Content.ReadFromJsonAsync<UserResponse>(cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user