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
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:
@@ -92,10 +92,14 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">Desired Username</label>
|
<label for="username">Desired Username</label>
|
||||||
<InputText id="username" @bind-Value="_application.DesiredUsername" class="form-input" placeholder="janedoe"
|
<input id="username" class="form-input" placeholder="janedoe" value="@_application.DesiredUsername"
|
||||||
@oninput="OnUsernameInput" />
|
@oninput="OnUsernameInput" @onfocusout="OnUsernameBlur" autocomplete="off" />
|
||||||
<span class="form-hint">This will be your handle across SilverLabs services</span>
|
<span class="form-hint">3–30 characters: letters, numbers, hyphens and underscores</span>
|
||||||
@if (_usernameCheckState == UsernameCheckState.Checking)
|
@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>
|
<span class="username-status username-checking">Checking availability...</span>
|
||||||
}
|
}
|
||||||
@@ -213,7 +217,7 @@
|
|||||||
{
|
{
|
||||||
<div class="dev-error">@_errorMessage</div>
|
<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)
|
@if (_submitting)
|
||||||
{
|
{
|
||||||
<span class="btn-spinner"></span>
|
<span class="btn-spinner"></span>
|
||||||
@@ -240,12 +244,19 @@
|
|||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
|
|
||||||
private UsernameCheckState _usernameCheckState = UsernameCheckState.None;
|
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 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 enum UsernameCheckState { None, Checking, Available, Taken, Error }
|
||||||
|
|
||||||
|
private bool IsSubmitDisabled =>
|
||||||
|
_submitting || _usernameCheckState == UsernameCheckState.Taken || _usernameCheckState == UsernameCheckState.Checking;
|
||||||
|
|
||||||
private void SelectRole(ApplicationRole role)
|
private void SelectRole(ApplicationRole role)
|
||||||
{
|
{
|
||||||
_application.Role = role;
|
_application.Role = role;
|
||||||
@@ -259,43 +270,55 @@
|
|||||||
_application.Platforms.Add(platform);
|
_application.Platforms.Add(platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnUsernameInput(ChangeEventArgs e)
|
private void OnUsernameInput(ChangeEventArgs e)
|
||||||
{
|
{
|
||||||
var username = e.Value?.ToString() ?? "";
|
var username = e.Value?.ToString() ?? "";
|
||||||
_application.DesiredUsername = username;
|
_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;
|
_usernameFormatError = "Username must be at least 3 characters";
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
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;
|
_usernameCheckState = UsernameCheckState.Checking;
|
||||||
_usernameCheckCts = new CancellationTokenSource();
|
StateHasChanged();
|
||||||
var token = _usernameCheckCts.Token;
|
|
||||||
|
|
||||||
try
|
var available = await ApplicationService.CheckUsernameAsync(username);
|
||||||
{
|
_lastCheckedUsername = username;
|
||||||
await Task.Delay(500, token);
|
|
||||||
var available = await ApplicationService.CheckUsernameAsync(username);
|
|
||||||
|
|
||||||
if (!token.IsCancellationRequested)
|
_usernameCheckState = available switch
|
||||||
{
|
|
||||||
_usernameCheckState = available switch
|
|
||||||
{
|
|
||||||
true => UsernameCheckState.Available,
|
|
||||||
false => UsernameCheckState.Taken,
|
|
||||||
null => UsernameCheckState.Error
|
|
||||||
};
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
{
|
||||||
// Debounce cancelled — newer keystroke took over
|
true => UsernameCheckState.Available,
|
||||||
}
|
false => UsernameCheckState.Taken,
|
||||||
|
null => UsernameCheckState.Error
|
||||||
|
};
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleSubmit()
|
private async Task HandleSubmit()
|
||||||
|
|||||||
@@ -209,6 +209,10 @@
|
|||||||
color: #fbbf24;
|
color: #fbbf24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.username-format-error {
|
||||||
|
color: #fb923c;
|
||||||
|
}
|
||||||
|
|
||||||
/* Validation messages */
|
/* Validation messages */
|
||||||
.validation-message {
|
.validation-message {
|
||||||
color: #f87171;
|
color: #f87171;
|
||||||
|
|||||||
Reference in New Issue
Block a user