Files
Website/BlazorApp/Components/Pages/Developers.razor
SysAdmin 9cbbd2d4f2
All checks were successful
Build and Deploy / deploy (push) Successful in 41s
feat(developers): add password-synced provisioning and deployment confirmation flow
Replace random unrecoverable passwords with a confirmation-based flow:
admin approval generates a secure token and sends a ticket reply with a
confirmation link; the developer clicks the link, enters their SilverDESK
password, and all services (Mattermost, Mailcow, Gitea) are provisioned
with that password. Adds password sync endpoint for SilverDESK resets and
updates the post-signup success panel to redirect to SilverDESK login with
the username pre-populated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:16:13 +00:00

365 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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. Log in with the password you just chose to track your application.</p>
<div class="dev-success-actions">
<a href="https://silverdesk.silverlabs.uk/login?username=@(Uri.EscapeDataString(_application.DesiredUsername ?? ""))" target="_blank" class="dev-btn dev-btn-primary">Log in to 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">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>
}
else if (_usernameCheckState == UsernameCheckState.Available)
{
<span class="username-status username-available">&#10003; Username is available</span>
}
else if (_usernameCheckState == UsernameCheckState.Taken)
{
<span class="username-status username-taken">&#10007; Username is already taken</span>
}
else if (_usernameCheckState == UsernameCheckState.Error)
{
<span class="username-status username-error">&#9888; Could not check availability — you can still submit</span>
}
<ValidationMessage For="() => _application.DesiredUsername" />
</div>
<div class="form-group">
<label for="timezone">Timezone</label>
<InputSelect id="timezone" @bind-Value="_application.Timezone" class="form-input">
<option value="">Select your timezone...</option>
@foreach (var tz in _timezones)
{
<option value="@tz.Id">@tz.Label</option>
}
</InputSelect>
<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>
<!-- Skills & What You Bring -->
<div class="dev-section">
<h2 class="dev-section-title">What You Bring</h2>
<p class="dev-section-desc">Tell us about your skills and what you'd bring to the team.</p>
@if (_application.Role == ApplicationRole.Developer)
{
<div class="form-group" style="margin-bottom: 1.25rem;">
<label for="skills">Technical Skills</label>
<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="4" />
<span class="form-hint">Languages, frameworks, tools, and any open-source contributions</span>
</div>
}
<div class="form-group">
<label for="motivation">How will you contribute?</label>
<InputTextArea id="motivation" @bind-Value="_application.Motivation" class="form-input form-textarea"
placeholder="@(_application.Role == ApplicationRole.Developer
? "What areas interest you? Architecture, frontend, backend, DevOps, security..."
: "What devices/platforms can you test on? What kind of testing experience do you have?")" rows="4" />
<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 List<(string Id, string Label)> _timezones = TimeZoneInfo.GetSystemTimeZones()
.OrderBy(tz => tz.BaseUtcOffset)
.Select(tz => (tz.Id, $"(UTC{(tz.BaseUtcOffset >= TimeSpan.Zero ? "+" : "")}{tz.BaseUtcOffset:hh\\:mm}) {tz.DisplayName}"))
.ToList();
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;
}
}
}