diff --git a/Jiten.Api/Controllers/AuthController.cs b/Jiten.Api/Controllers/AuthController.cs index 9530f3cd..26e0cdc4 100644 --- a/Jiten.Api/Controllers/AuthController.cs +++ b/Jiten.Api/Controllers/AuthController.cs @@ -5,6 +5,7 @@ using Google.Apis.Auth; using Jiten.Api.Dtos; using Jiten.Api.Dtos.Requests; +using Jiten.Api.Helpers; using Jiten.Api.Services; using Jiten.Core; using Jiten.Core.Data.Authentication; @@ -85,16 +86,16 @@ public async Task Register([FromBody] RegisterRequest model) var userName = model.Username.Trim(); var email = model.Email.Trim(); + var usernameError = UsernameValidator.Validate(userName); + if (usernameError != null) + return BadRequest(new { message = usernameError }); + var userExists = await _userManager.FindByNameAsync(userName); if (userExists != null) return Conflict(new { message = "Username already exists." }); var emailExists = await _userManager.FindByEmailAsync(email); if (emailExists != null) return Conflict(new { message = "Email already registered." }); - - if (userName.Length is < 3 or > 30) - return BadRequest(new { message = "Username must be between 3 and 30 characters." }); - var user = new User { UserName = userName, Email = email, SecurityStamp = Guid.NewGuid().ToString(), TosAcceptedAt = DateTime.UtcNow, @@ -512,25 +513,15 @@ public async Task> CompleteGoogleRegistration([FromB var username = request.Username.Trim(); - if (string.IsNullOrWhiteSpace(username)) + var usernameError = UsernameValidator.Validate(username); + if (usernameError != null) { - return BadRequest(new { message = "Username is required" }); - } - - if (username.Length < 3 || username.Length > 30) - { - return BadRequest(new { message = "Username must be between 3 and 30 characters" }); + return BadRequest(new { message = usernameError }); } var userExists = await _userManager.FindByNameAsync(username); if (userExists != null) return Conflict(new { message = "Username already exists." }); - var usernameExists = await _userManager.Users.AnyAsync(u => u.UserName == request.Username); - if (usernameExists) - { - return BadRequest(new { message = "Username is already taken" }); - } - var emailExists = await _userManager.Users.AnyAsync(u => u.Email == registrationData!.Email); if (emailExists) { @@ -540,7 +531,7 @@ public async Task> CompleteGoogleRegistration([FromB // Create the user var user = new User { - UserName = request.Username, Email = registrationData.Email, EmailConfirmed = true, TosAcceptedAt = DateTime.UtcNow, + UserName = username, Email = registrationData!.Email, EmailConfirmed = true, TosAcceptedAt = DateTime.UtcNow, ReceivesNewsletter = request.ReceiveNewsletter }; diff --git a/Jiten.Api/Dtos/Requests/CompleteGoogleRegistrationRequest.cs b/Jiten.Api/Dtos/Requests/CompleteGoogleRegistrationRequest.cs index 5338f329..a562d98b 100644 --- a/Jiten.Api/Dtos/Requests/CompleteGoogleRegistrationRequest.cs +++ b/Jiten.Api/Dtos/Requests/CompleteGoogleRegistrationRequest.cs @@ -4,7 +4,7 @@ namespace Jiten.Api.Dtos.Requests; public class CompleteGoogleRegistrationRequest { - [Required, MaxLength(30)] + [Required, MinLength(2), MaxLength(30)] public required string Username { get; set; } public required string TempToken { get; set; } diff --git a/Jiten.Api/Dtos/Requests/RegisterRequest.cs b/Jiten.Api/Dtos/Requests/RegisterRequest.cs index ba894f91..ad9adae8 100644 --- a/Jiten.Api/Dtos/Requests/RegisterRequest.cs +++ b/Jiten.Api/Dtos/Requests/RegisterRequest.cs @@ -4,7 +4,7 @@ namespace Jiten.Api.Dtos.Requests; public class RegisterRequest { - [Required, MaxLength(30)] + [Required, MinLength(2), MaxLength(30)] public required string Username { get; set; } [Required, EmailAddress, MaxLength(100)] diff --git a/Jiten.Api/Helpers/UsernameValidator.cs b/Jiten.Api/Helpers/UsernameValidator.cs new file mode 100644 index 00000000..688a8290 --- /dev/null +++ b/Jiten.Api/Helpers/UsernameValidator.cs @@ -0,0 +1,45 @@ +using System.Text.RegularExpressions; + +namespace Jiten.Api.Helpers; + +/// +/// Central username validation shared by the email/password and Google registration paths so both +/// reject the same inputs with the same messages. +/// The allowed set is a subset of ASP.NET Identity's default AllowedUserNameCharacters, so a +/// name that passes here is always accepted by UserManager.CreateAsync. +/// +public static partial class UsernameValidator +{ + public const int MinLength = 2; + public const int MaxLength = 30; + + // Latin letters/digits plus the punctuation Identity's default AllowedUserNameCharacters permits + // (email-style names like tony@aol.com are allowed). This set is a subset of that default, so a + // name accepted here always passes UserManager.CreateAsync. + [GeneratedRegex(@"^[A-Za-z0-9._@+-]+$")] + private static partial Regex AllowedPattern(); + + /// + /// Validates a username. Returns null when valid, otherwise a user-facing error message. + /// Callers should pass the already-trimmed username. + /// + public static string? Validate(string? username) + { + if (string.IsNullOrWhiteSpace(username)) + return "Username is required."; + + if (username.Length < MinLength) + return $"Username must be at least {MinLength} characters."; + + if (username.Length > MaxLength) + return $"Username must be at most {MaxLength} characters."; + + if (!AllowedPattern().IsMatch(username)) + return "Username can only contain Latin letters, digits and the characters . _ - @ +"; + + if (!username.Any(char.IsAsciiLetterOrDigit)) + return "Username must contain at least one letter or digit."; + + return null; + } +} diff --git a/Jiten.Tests/Integration/AccountTests.cs b/Jiten.Tests/Integration/AccountTests.cs index 3b5b223a..f8ea63ee 100644 --- a/Jiten.Tests/Integration/AccountTests.cs +++ b/Jiten.Tests/Integration/AccountTests.cs @@ -408,6 +408,71 @@ async Task SetNewsletter(bool value) (await GetNewsletter()).Should().BeFalse(); } + // ---- register username validation ---- + + private Task RegisterAsync(string username, string email) => + _client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/api/auth/register") + .WithJsonContent(new + { + username, + email, + password = DefaultPassword, + recaptchaResponse = "test", + tosAccepted = true, + receiveNewsletter = false + })); + + [Theory] + [InlineData("valid_user1", "reg_valid@test.dev")] + [InlineData("tony@aol.com", "reg_email_uname@test.dev")] // email-style usernames are allowed + [InlineData("Benjamin_", "reg_trailing@test.dev")] // trailing separator is allowed + [InlineData("ab", "reg_two_char@test.dev")] // 2-char names are allowed + public async Task Register_ValidUsername_Returns200_AndCreatesUser(string username, string email) + { + await EnsureUserRoleAsync(); // role seeding is skipped in the Testing environment + + var response = await RegisterAsync(username, email); + response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); + + using var scope = factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + (await userManager.FindByNameAsync(username)).Should().NotBeNull(); + } + + private async Task EnsureUserRoleAsync() + { + using var scope = factory.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var roleName = nameof(UserRole.User); + if (!await roleManager.RoleExistsAsync(roleName)) + await roleManager.CreateAsync(new IdentityRole(roleName)); + } + + [Theory] + [InlineData("a")] // too short (min 2) + [InlineData("たなか")] // non-latin (Japanese) + [InlineData("user name")] // space (disallowed char) + [InlineData("user#name")] // '#' disallowed + [InlineData("___")] // no letter or digit + [InlineData("...")] // no letter or digit + [InlineData("ааа")] // Cyrillic look-alikes + public async Task Register_InvalidUsername_Returns400_AndCreatesNoUser(string username) + { + var response = await RegisterAsync(username, "reg_invalid@test.dev"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, await response.Content.ReadAsStringAsync()); + + using var scope = factory.Services.CreateScope(); + var userDb = scope.ServiceProvider.GetRequiredService(); + (await userDb.Users.AnyAsync(u => u.Email == "reg_invalid@test.dev")).Should().BeFalse(); + } + + [Fact] + public async Task Register_TooLongUsername_Returns400() + { + var response = await RegisterAsync(new string('a', 31), "reg_long@test.dev"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, await response.Content.ReadAsStringAsync()); + } + // ---- revoke-token keepCurrent ---- [Fact] diff --git a/Jiten.Web/app/components/CustomMeaning.vue b/Jiten.Web/app/components/CustomMeaning.vue index 1fbe9615..1d99a33d 100644 --- a/Jiten.Web/app/components/CustomMeaning.vue +++ b/Jiten.Web/app/components/CustomMeaning.vue @@ -20,6 +20,7 @@ const draft = ref(''); const saving = ref(false); const deleting = ref(false); + const confirmingDelete = ref(false); const canSave = computed(() => { const t = draft.value.trim(); @@ -43,18 +44,22 @@ } } - watch(() => props.wordId, () => { - meaning.value = null; - loaded.value = false; - editing.value = false; - load(); - }); + watch( + () => props.wordId, + () => { + meaning.value = null; + loaded.value = false; + editing.value = false; + load(); + } + ); onMounted(load); function startEditing() { draft.value = meaning.value ?? ''; editing.value = true; + confirmingDelete.value = false; } async function save() { @@ -78,6 +83,7 @@ await $api(`user/custom-meanings/${props.wordId}`, { method: 'DELETE' }); meaning.value = null; editing.value = false; + confirmingDelete.value = false; } finally { deleting.value = false; } @@ -116,40 +122,30 @@
-
-