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>
353 lines
17 KiB
Plaintext
353 lines
17 KiB
Plaintext
@page "/developers"
|
||
@using SilverLabs.Website.Models
|
||
@using SilverLabs.Website.Services
|
||
@inject DeveloperApplicationService ApplicationService
|
||
@rendermode InteractiveServer
|
||
|
||
<PageTitle>Join the Team - SilverLabs</PageTitle>
|
||
|
||
<div class="main-content visible">
|
||
<header class="header">
|
||
<img src="logo.png" alt="SilverLabs Logo" class="logo">
|
||
</header>
|
||
|
||
<div class="dev-container">
|
||
<div class="dev-header">
|
||
<h1>Join the SilverLabs Team</h1>
|
||
<p class="dev-subtitle">Help us build privacy-first infrastructure. Whether you test our products or write code, there's a place for you.</p>
|
||
</div>
|
||
|
||
@if (_submitted)
|
||
{
|
||
<div class="dev-success-panel">
|
||
<div class="success-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||
</svg>
|
||
</div>
|
||
<h2>Application Submitted</h2>
|
||
<p>@_resultMessage</p>
|
||
<p class="dev-account-note">Your SilverDESK account has been created. You can log in with the credentials you just chose.</p>
|
||
<div class="dev-success-actions">
|
||
<a href="https://silverdesk.silverlabs.uk" target="_blank" class="dev-btn dev-btn-primary">Track Your Application on SilverDESK</a>
|
||
<a href="/" class="dev-btn dev-btn-secondary">Back to Home</a>
|
||
</div>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<EditForm Model="_application" OnValidSubmit="HandleSubmit" FormName="developer-application">
|
||
<DataAnnotationsValidator />
|
||
|
||
<!-- Role Selector -->
|
||
<div class="dev-section">
|
||
<h2 class="dev-section-title">Choose Your Role</h2>
|
||
<div class="role-selector">
|
||
<div class="role-card @(_application.Role == ApplicationRole.Tester ? "role-active" : "")"
|
||
@onclick="() => SelectRole(ApplicationRole.Tester)">
|
||
<div class="role-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||
<polyline points="14 2 14 8 20 8"></polyline>
|
||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||
<polyline points="10 9 9 9 8 9"></polyline>
|
||
</svg>
|
||
</div>
|
||
<h3>Product Tester</h3>
|
||
<p>Test our apps across devices, find bugs, and provide feedback that shapes our products.</p>
|
||
</div>
|
||
|
||
<div class="role-card @(_application.Role == ApplicationRole.Developer ? "role-active" : "")"
|
||
@onclick="() => SelectRole(ApplicationRole.Developer)">
|
||
<div class="role-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<polyline points="16 18 22 12 16 6"></polyline>
|
||
<polyline points="8 6 2 12 8 18"></polyline>
|
||
</svg>
|
||
</div>
|
||
<h3>Developer</h3>
|
||
<p>Contribute code, build modules, and help architect privacy-first solutions.</p>
|
||
</div>
|
||
</div>
|
||
<ValidationMessage For="() => _application.Role" />
|
||
</div>
|
||
|
||
<!-- Personal Details -->
|
||
<div class="dev-section">
|
||
<h2 class="dev-section-title">About You</h2>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="fullName">Full Name</label>
|
||
<InputText id="fullName" @bind-Value="_application.FullName" class="form-input" placeholder="Jane Doe" />
|
||
<ValidationMessage For="() => _application.FullName" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="email">Email Address</label>
|
||
<InputText id="email" @bind-Value="_application.Email" class="form-input" placeholder="jane@example.com" />
|
||
<ValidationMessage For="() => _application.Email" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="username">Desired Username</label>
|
||
<input id="username" class="form-input" placeholder="janedoe" value="@_application.DesiredUsername"
|
||
@oninput="OnUsernameInput" @onfocusout="OnUsernameBlur" autocomplete="off" />
|
||
<span class="form-hint">3–30 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>
|
||
}
|
||
else if (_usernameCheckState == UsernameCheckState.Available)
|
||
{
|
||
<span class="username-status username-available">✓ Username is available</span>
|
||
}
|
||
else if (_usernameCheckState == UsernameCheckState.Taken)
|
||
{
|
||
<span class="username-status username-taken">✗ Username is already taken</span>
|
||
}
|
||
else if (_usernameCheckState == UsernameCheckState.Error)
|
||
{
|
||
<span class="username-status username-error">⚠ Could not check availability — you can still submit</span>
|
||
}
|
||
<ValidationMessage For="() => _application.DesiredUsername" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="timezone">Location / Timezone</label>
|
||
<InputText id="timezone" @bind-Value="_application.Timezone" class="form-input" placeholder="e.g. Europe/London, US/Eastern" />
|
||
<ValidationMessage For="() => _application.Timezone" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Password -->
|
||
<div class="dev-section">
|
||
<h2 class="dev-section-title">Create Your Password</h2>
|
||
<p class="dev-section-desc">This will be your password for SilverDESK and associated services.</p>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="password">Password</label>
|
||
<InputText id="password" type="password" @bind-Value="_application.Password" class="form-input" placeholder="Min. 8 characters" />
|
||
<span class="form-hint">Must include uppercase, lowercase, and a number</span>
|
||
<ValidationMessage For="() => _application.Password" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="confirmPassword">Confirm Password</label>
|
||
<InputText id="confirmPassword" type="password" @bind-Value="_application.ConfirmPassword" class="form-input" placeholder="Re-enter your password" />
|
||
<ValidationMessage For="() => _application.ConfirmPassword" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Platforms -->
|
||
<div class="dev-section">
|
||
<h2 class="dev-section-title">Devices & Platforms</h2>
|
||
<p class="dev-section-desc">Which platforms do you use or have access to?</p>
|
||
<div class="platform-grid">
|
||
@foreach (var platform in _availablePlatforms)
|
||
{
|
||
var isChecked = _application.Platforms.Contains(platform);
|
||
<label class="platform-chip @(isChecked ? "platform-active" : "")">
|
||
<input type="checkbox" checked="@isChecked"
|
||
@onchange="() => TogglePlatform(platform)" />
|
||
<span>@platform</span>
|
||
</label>
|
||
}
|
||
</div>
|
||
<ValidationMessage For="() => _application.Platforms" />
|
||
</div>
|
||
|
||
<!-- Developer-only: Skills -->
|
||
@if (_application.Role == ApplicationRole.Developer)
|
||
{
|
||
<div class="dev-section dev-section-fade-in">
|
||
<h2 class="dev-section-title">Skills & Experience</h2>
|
||
<p class="dev-section-desc">Tell us about your technical background — languages, frameworks, and any open-source contributions.</p>
|
||
<div class="form-group">
|
||
<InputTextArea id="skills" @bind-Value="_application.Skills" class="form-input form-textarea"
|
||
placeholder="e.g. C#/.NET 5 years, Blazor, PostgreSQL, Docker, contributed to..." rows="5" />
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
<!-- Motivation -->
|
||
<div class="dev-section">
|
||
<h2 class="dev-section-title">Why SilverLabs?</h2>
|
||
<p class="dev-section-desc">What draws you to privacy-first development? What do you hope to contribute?</p>
|
||
<div class="form-group">
|
||
<InputTextArea id="motivation" @bind-Value="_application.Motivation" class="form-input form-textarea"
|
||
placeholder="Tell us what motivates you..." rows="5" />
|
||
<ValidationMessage For="() => _application.Motivation" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- What You Get -->
|
||
<div class="dev-section dev-perks">
|
||
<h2 class="dev-section-title">What You'll Get</h2>
|
||
<div class="perks-grid">
|
||
<div class="perk-item">
|
||
<strong>@@@("username")@@silverlabs.uk</strong>
|
||
<span>Your own SilverLabs email</span>
|
||
</div>
|
||
<div class="perk-item">
|
||
<strong>SilverDESK</strong>
|
||
<span>Project management & issue tracking</span>
|
||
</div>
|
||
<div class="perk-item">
|
||
<strong>Mattermost</strong>
|
||
<span>Team chat & collaboration</span>
|
||
</div>
|
||
<div class="perk-item">
|
||
<strong>Gitea Access</strong>
|
||
<span>Source code repositories</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Submit -->
|
||
<div class="dev-submit-area">
|
||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||
{
|
||
<div class="dev-error">@_errorMessage</div>
|
||
}
|
||
<button type="submit" class="dev-btn dev-btn-primary" disabled="@IsSubmitDisabled">
|
||
@if (_submitting)
|
||
{
|
||
<span class="btn-spinner"></span>
|
||
<span>Submitting...</span>
|
||
}
|
||
else
|
||
{
|
||
<span>Submit Application</span>
|
||
}
|
||
</button>
|
||
</div>
|
||
</EditForm>
|
||
}
|
||
|
||
<a href="/" class="back-link">← Back to SilverLabs Home</a>
|
||
</div>
|
||
</div>
|
||
|
||
@code {
|
||
private DeveloperApplication _application = new() { Role = ApplicationRole.Tester };
|
||
private bool _submitting;
|
||
private bool _submitted;
|
||
private string? _resultMessage;
|
||
private string? _errorMessage;
|
||
|
||
private UsernameCheckState _usernameCheckState = UsernameCheckState.None;
|
||
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;
|
||
}
|
||
|
||
private void TogglePlatform(string platform)
|
||
{
|
||
if (_application.Platforms.Contains(platform))
|
||
_application.Platforms.Remove(platform);
|
||
else
|
||
_application.Platforms.Add(platform);
|
||
}
|
||
|
||
private void OnUsernameInput(ChangeEventArgs e)
|
||
{
|
||
var username = e.Value?.ToString() ?? "";
|
||
_application.DesiredUsername = username;
|
||
|
||
// Reset API check state while typing — we'll check on blur
|
||
_usernameCheckState = UsernameCheckState.None;
|
||
_usernameFormatError = null;
|
||
|
||
// Show inline format feedback as they type
|
||
if (username.Length > 0 && username.Length < 3)
|
||
{
|
||
_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;
|
||
StateHasChanged();
|
||
|
||
var available = await ApplicationService.CheckUsernameAsync(username);
|
||
_lastCheckedUsername = username;
|
||
|
||
_usernameCheckState = available switch
|
||
{
|
||
true => UsernameCheckState.Available,
|
||
false => UsernameCheckState.Taken,
|
||
null => UsernameCheckState.Error
|
||
};
|
||
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task HandleSubmit()
|
||
{
|
||
_errorMessage = null;
|
||
_submitting = true;
|
||
|
||
try
|
||
{
|
||
var (success, message, token) = await ApplicationService.SubmitApplicationAsync(_application);
|
||
|
||
if (success)
|
||
{
|
||
_resultMessage = message;
|
||
_submitted = true;
|
||
}
|
||
else
|
||
{
|
||
_errorMessage = message;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
_errorMessage = "An unexpected error occurred. Please try again later.";
|
||
}
|
||
finally
|
||
{
|
||
_submitting = false;
|
||
}
|
||
}
|
||
}
|