fix(developers): check username on blur instead of keystroke to avoid rate limiting
All checks were successful
Build and Deploy / deploy (push) Successful in 20s

SilverDESK rate-limits /api/auth/check-username after ~2 requests with a
5-minute cooldown. The old 500ms debounce per keystroke quickly exhausted
this limit, breaking the form. Now checks only on field blur, validates
format client-side while typing, and caches results to skip redundant calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 18:10:04 +00:00
parent 33b21959d8
commit e5eacd8725
2 changed files with 58 additions and 31 deletions

View File

@@ -92,10 +92,14 @@
<div class="form-group">
<label for="username">Desired Username</label>
<InputText id="username" @bind-Value="_application.DesiredUsername" class="form-input" placeholder="janedoe"
@oninput="OnUsernameInput" />
<span class="form-hint">This will be your handle across SilverLabs services</span>
@if (_usernameCheckState == UsernameCheckState.Checking)
<input id="username" class="form-input" placeholder="janedoe" value="@_application.DesiredUsername"
@oninput="OnUsernameInput" @onfocusout="OnUsernameBlur" autocomplete="off" />
<span class="form-hint">330 characters: letters, numbers, hyphens and underscores</span>
@if (!string.IsNullOrEmpty(_usernameFormatError))
{
<span class="username-status username-format-error">@_usernameFormatError</span>
}
else if (_usernameCheckState == UsernameCheckState.Checking)
{
<span class="username-status username-checking">Checking availability...</span>
}
@@ -213,7 +217,7 @@
{
<div class="dev-error">@_errorMessage</div>
}
<button type="submit" class="dev-btn dev-btn-primary" disabled="@(_submitting || _usernameCheckState == UsernameCheckState.Taken)">
<button type="submit" class="dev-btn dev-btn-primary" disabled="@IsSubmitDisabled">
@if (_submitting)
{
<span class="btn-spinner"></span>
@@ -240,12 +244,19 @@
private string? _errorMessage;
private UsernameCheckState _usernameCheckState = UsernameCheckState.None;
private CancellationTokenSource? _usernameCheckCts;
private string? _usernameFormatError;
private string? _lastCheckedUsername;
private readonly string[] _availablePlatforms = { "Windows", "macOS", "Linux", "Android", "iOS", "Other" };
private static readonly System.Text.RegularExpressions.Regex UsernamePattern =
new(@"^[a-zA-Z0-9_-]{3,30}$", System.Text.RegularExpressions.RegexOptions.Compiled);
private enum UsernameCheckState { None, Checking, Available, Taken, Error }
private bool IsSubmitDisabled =>
_submitting || _usernameCheckState == UsernameCheckState.Taken || _usernameCheckState == UsernameCheckState.Checking;
private void SelectRole(ApplicationRole role)
{
_application.Role = role;
@@ -259,43 +270,55 @@
_application.Platforms.Add(platform);
}
private async Task OnUsernameInput(ChangeEventArgs e)
private void OnUsernameInput(ChangeEventArgs e)
{
var username = e.Value?.ToString() ?? "";
_application.DesiredUsername = username;
_usernameCheckCts?.Cancel();
// Reset API check state while typing — we'll check on blur
_usernameCheckState = UsernameCheckState.None;
_usernameFormatError = null;
if (username.Length < 3)
// Show inline format feedback as they type
if (username.Length > 0 && username.Length < 3)
{
_usernameCheckState = UsernameCheckState.None;
return;
_usernameFormatError = "Username must be at least 3 characters";
}
else if (username.Length > 30)
{
_usernameFormatError = "Username must be 30 characters or fewer";
}
else if (username.Length >= 3 && !UsernamePattern.IsMatch(username))
{
_usernameFormatError = "Only letters, numbers, hyphens and underscores allowed";
}
}
private async Task OnUsernameBlur()
{
var username = _application.DesiredUsername?.Trim() ?? "";
// Don't check if empty, invalid format, or already checked this exact username
if (string.IsNullOrEmpty(username) || !UsernamePattern.IsMatch(username))
return;
if (username == _lastCheckedUsername && _usernameCheckState != UsernameCheckState.Error)
return;
_usernameCheckState = UsernameCheckState.Checking;
_usernameCheckCts = new CancellationTokenSource();
var token = _usernameCheckCts.Token;
StateHasChanged();
try
{
await Task.Delay(500, token);
var available = await ApplicationService.CheckUsernameAsync(username);
var available = await ApplicationService.CheckUsernameAsync(username);
_lastCheckedUsername = username;
if (!token.IsCancellationRequested)
{
_usernameCheckState = available switch
{
true => UsernameCheckState.Available,
false => UsernameCheckState.Taken,
null => UsernameCheckState.Error
};
StateHasChanged();
}
}
catch (TaskCanceledException)
_usernameCheckState = available switch
{
// Debounce cancelled — newer keystroke took over
}
true => UsernameCheckState.Available,
false => UsernameCheckState.Taken,
null => UsernameCheckState.Error
};
StateHasChanged();
}
private async Task HandleSubmit()

View File

@@ -209,6 +209,10 @@
color: #fbbf24;
}
.username-format-error {
color: #fb923c;
}
/* Validation messages */
.validation-message {
color: #f87171;