feat(developers): overhaul signup to auto-register SilverDESK accounts
All checks were successful
Build and Deploy / deploy (push) Successful in 41s
All checks were successful
Build and Deploy / deploy (push) Successful in 41s
Users now pick a password and get a SilverDESK account immediately on submit. The form includes debounced username availability checking, password fields with validation, and a post-submit link to SilverDESK. The approval flow no longer creates a SilverDESK user (already exists) and only provisions Mattermost + Mailcow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,8 +28,12 @@
|
||||
</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
|
||||
{
|
||||
@@ -88,8 +92,21 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Desired Username</label>
|
||||
<InputText id="username" @bind-Value="_application.DesiredUsername" class="form-input" placeholder="janedoe" />
|
||||
<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)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
<ValidationMessage For="() => _application.DesiredUsername" />
|
||||
</div>
|
||||
|
||||
@@ -101,6 +118,26 @@
|
||||
</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>
|
||||
@@ -198,8 +235,13 @@
|
||||
private string? _resultMessage;
|
||||
private string? _errorMessage;
|
||||
|
||||
private UsernameCheckState _usernameCheckState = UsernameCheckState.None;
|
||||
private CancellationTokenSource? _usernameCheckCts;
|
||||
|
||||
private readonly string[] _availablePlatforms = { "Windows", "macOS", "Linux", "Android", "iOS", "Other" };
|
||||
|
||||
private enum UsernameCheckState { None, Checking, Available, Taken }
|
||||
|
||||
private void SelectRole(ApplicationRole role)
|
||||
{
|
||||
_application.Role = role;
|
||||
@@ -213,6 +255,40 @@
|
||||
_application.Platforms.Add(platform);
|
||||
}
|
||||
|
||||
private async Task OnUsernameInput(ChangeEventArgs e)
|
||||
{
|
||||
var username = e.Value?.ToString() ?? "";
|
||||
_application.DesiredUsername = username;
|
||||
|
||||
_usernameCheckCts?.Cancel();
|
||||
|
||||
if (username.Length < 3)
|
||||
{
|
||||
_usernameCheckState = UsernameCheckState.None;
|
||||
return;
|
||||
}
|
||||
|
||||
_usernameCheckState = UsernameCheckState.Checking;
|
||||
_usernameCheckCts = new CancellationTokenSource();
|
||||
var token = _usernameCheckCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(500, token);
|
||||
var available = await ApplicationService.CheckUsernameAsync(username);
|
||||
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
_usernameCheckState = available ? UsernameCheckState.Available : UsernameCheckState.Taken;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Debounce cancelled — newer keystroke took over
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
_errorMessage = null;
|
||||
@@ -220,7 +296,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
var (success, message) = await ApplicationService.SubmitApplicationAsync(_application);
|
||||
var (success, message, token) = await ApplicationService.SubmitApplicationAsync(_application);
|
||||
|
||||
if (success)
|
||||
{
|
||||
|
||||
@@ -9,11 +9,17 @@ public static class DeveloperEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/developers");
|
||||
|
||||
group.MapGet("/check-username/{username}", async (string username, DeveloperApplicationService service) =>
|
||||
{
|
||||
var available = await service.CheckUsernameAsync(username);
|
||||
return Results.Ok(new { available });
|
||||
});
|
||||
|
||||
group.MapPost("/apply", async (DeveloperApplication application, DeveloperApplicationService service) =>
|
||||
{
|
||||
var (success, message) = await service.SubmitApplicationAsync(application);
|
||||
var (success, message, token) = await service.SubmitApplicationAsync(application);
|
||||
return success
|
||||
? Results.Ok(new { message })
|
||||
? Results.Ok(new { message, token })
|
||||
: Results.Problem(message, statusCode: 502);
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ public class DeveloperApplication
|
||||
|
||||
public string? Skills { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Password is required")]
|
||||
[StringLength(100, MinimumLength = 8, ErrorMessage = "Password must be at least 8 characters")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Please confirm your password")]
|
||||
[Compare("Password", ErrorMessage = "Passwords do not match")]
|
||||
public string ConfirmPassword { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Please tell us why you want to join")]
|
||||
[StringLength(2000, MinimumLength = 20, ErrorMessage = "Motivation must be between 20 and 2000 characters")]
|
||||
public string Motivation { get; set; } = string.Empty;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SilverLabs.Website.Models;
|
||||
@@ -15,13 +17,54 @@ public class DeveloperApplicationService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> SubmitApplicationAsync(DeveloperApplication application)
|
||||
public async Task<bool> CheckUsernameAsync(string username)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ticketBody = FormatTicketBody(application);
|
||||
var response = await _httpClient.GetAsync($"/api/auth/check-username/{Uri.EscapeDataString(username)}");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return false;
|
||||
|
||||
var payload = new
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return result.TryGetProperty("available", out var available) && available.GetBoolean();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking username availability for {Username}", username);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message, string? Token)> SubmitApplicationAsync(DeveloperApplication application)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Register user on SilverDESK
|
||||
var registerPayload = new
|
||||
{
|
||||
username = application.DesiredUsername,
|
||||
email = application.Email,
|
||||
password = application.Password,
|
||||
fullName = application.FullName
|
||||
};
|
||||
|
||||
var registerResponse = await _httpClient.PostAsJsonAsync("/api/auth/register", registerPayload);
|
||||
|
||||
if (!registerResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await registerResponse.Content.ReadAsStringAsync();
|
||||
_logger.LogError("SilverDESK registration failed: {StatusCode} - {Body}", registerResponse.StatusCode, errorBody);
|
||||
|
||||
var friendlyMessage = ParseRegistrationError(errorBody);
|
||||
return (false, friendlyMessage, null);
|
||||
}
|
||||
|
||||
var authResult = await registerResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var token = authResult.GetProperty("token").GetString();
|
||||
|
||||
// 2. Create ticket using the user's own JWT
|
||||
var ticketBody = FormatTicketBody(application);
|
||||
var ticketPayload = new
|
||||
{
|
||||
Subject = $"[Developer Program] {application.Role} Application - {application.FullName}",
|
||||
Description = ticketBody,
|
||||
@@ -29,28 +72,54 @@ public class DeveloperApplicationService
|
||||
Category = "Developer Program"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var ticketRequest = new HttpRequestMessage(HttpMethod.Post, "/api/tickets");
|
||||
ticketRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
ticketRequest.Content = JsonContent.Create(ticketPayload);
|
||||
|
||||
var response = await _httpClient.PostAsync("/api/tickets", content);
|
||||
var ticketResponse = await _httpClient.SendAsync(ticketRequest);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
if (!ticketResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Developer application submitted for {Email} as {Role}", application.Email, application.Role);
|
||||
return (true, "Application submitted successfully! We'll review it and get back to you soon.");
|
||||
var errorBody = await ticketResponse.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to create ticket: {StatusCode} - {Body}", ticketResponse.StatusCode, errorBody);
|
||||
// User was created but ticket failed — still return success with a note
|
||||
return (true, "Your account has been created, but we had trouble submitting your application ticket. Please log in to SilverDESK and create a support ticket.", token);
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to create ticket: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return (false, "Something went wrong submitting your application. Please try again later.");
|
||||
_logger.LogInformation("Developer application submitted for {Email} as {Role} — user registered and ticket created",
|
||||
application.Email, application.Role);
|
||||
|
||||
return (true, "Application submitted successfully! Your SilverDESK account has been created.", token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error submitting developer application for {Email}", application.Email);
|
||||
return (false, "Unable to connect to the application service. Please try again later.");
|
||||
return (false, "Unable to connect to the application service. Please try again later.", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ParseRegistrationError(string errorBody)
|
||||
{
|
||||
try
|
||||
{
|
||||
var error = JsonSerializer.Deserialize<JsonElement>(errorBody);
|
||||
if (error.TryGetProperty("message", out var message))
|
||||
{
|
||||
var msg = message.GetString() ?? "";
|
||||
if (msg.Contains("Username already exists", StringComparison.OrdinalIgnoreCase))
|
||||
return "That username is already taken. Please choose a different one.";
|
||||
if (msg.Contains("Email already exists", StringComparison.OrdinalIgnoreCase))
|
||||
return "An account with that email already exists.";
|
||||
if (msg.Contains("Password", StringComparison.OrdinalIgnoreCase))
|
||||
return msg;
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return "Something went wrong creating your account. Please try again later.";
|
||||
}
|
||||
|
||||
private static string FormatTicketBody(DeveloperApplication app)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
@@ -20,17 +20,15 @@ public class ProvisioningService
|
||||
var results = new List<string>();
|
||||
var allSuccess = true;
|
||||
|
||||
// 1. Create SilverDESK user
|
||||
var (deskOk, deskMsg) = await CreateSilverDeskUserAsync(username, email, fullName);
|
||||
results.Add($"SilverDESK: {deskMsg}");
|
||||
if (!deskOk) allSuccess = false;
|
||||
// SilverDESK user already exists (created at application time)
|
||||
// Only provision external services
|
||||
|
||||
// 2. Create Mattermost user
|
||||
// 1. Create Mattermost user
|
||||
var (mmOk, mmMsg) = await CreateMattermostUserAsync(username, email, fullName);
|
||||
results.Add($"Mattermost: {mmMsg}");
|
||||
if (!mmOk) allSuccess = false;
|
||||
|
||||
// 3. Create Mailcow mailbox
|
||||
// 2. Create Mailcow mailbox
|
||||
var (mailOk, mailMsg) = await CreateMailcowMailboxAsync(username, fullName);
|
||||
results.Add($"Mailcow: {mailMsg}");
|
||||
if (!mailOk) allSuccess = false;
|
||||
|
||||
Reference in New Issue
Block a user