From d0785e04e1f20deaff44b30a13f9821abf5fe670 Mon Sep 17 00:00:00 2001 From: SysAdmin Date: Sun, 22 Feb 2026 11:03:16 +0000 Subject: [PATCH] feat(developers): overhaul signup to auto-register SilverDESK accounts 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 --- BlazorApp/Components/Pages/Developers.razor | 82 +++++++++++++++- BlazorApp/Endpoints/DeveloperEndpoints.cs | 10 +- BlazorApp/Models/DeveloperApplication.cs | 8 ++ .../Services/DeveloperApplicationService.cs | 95 ++++++++++++++++--- BlazorApp/Services/ProvisioningService.cs | 10 +- 5 files changed, 181 insertions(+), 24 deletions(-) diff --git a/BlazorApp/Components/Pages/Developers.razor b/BlazorApp/Components/Pages/Developers.razor index 67f100c..9f69b71 100644 --- a/BlazorApp/Components/Pages/Developers.razor +++ b/BlazorApp/Components/Pages/Developers.razor @@ -28,7 +28,11 @@

Application Submitted

@_resultMessage

- Back to Home + + } else @@ -88,8 +92,21 @@
- + This will be your handle across SilverLabs services + @if (_usernameCheckState == UsernameCheckState.Checking) + { + Checking availability... + } + else if (_usernameCheckState == UsernameCheckState.Available) + { + ✓ Username is available + } + else if (_usernameCheckState == UsernameCheckState.Taken) + { + ✗ Username is already taken + }
@@ -101,6 +118,26 @@ + +
+

Create Your Password

+

This will be your password for SilverDESK and associated services.

+
+
+ + + Must include uppercase, lowercase, and a number + +
+ +
+ + + +
+
+
+

Devices & Platforms

@@ -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) { diff --git a/BlazorApp/Endpoints/DeveloperEndpoints.cs b/BlazorApp/Endpoints/DeveloperEndpoints.cs index 655b103..46c6fdc 100644 --- a/BlazorApp/Endpoints/DeveloperEndpoints.cs +++ b/BlazorApp/Endpoints/DeveloperEndpoints.cs @@ -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); }); diff --git a/BlazorApp/Models/DeveloperApplication.cs b/BlazorApp/Models/DeveloperApplication.cs index accdb29..a254fee 100644 --- a/BlazorApp/Models/DeveloperApplication.cs +++ b/BlazorApp/Models/DeveloperApplication.cs @@ -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; diff --git a/BlazorApp/Services/DeveloperApplicationService.cs b/BlazorApp/Services/DeveloperApplicationService.cs index 046ccdc..40f8ce3 100644 --- a/BlazorApp/Services/DeveloperApplicationService.cs +++ b/BlazorApp/Services/DeveloperApplicationService.cs @@ -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 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(); + 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(); + 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(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(); diff --git a/BlazorApp/Services/ProvisioningService.cs b/BlazorApp/Services/ProvisioningService.cs index 89df7c5..3f1c018 100644 --- a/BlazorApp/Services/ProvisioningService.cs +++ b/BlazorApp/Services/ProvisioningService.cs @@ -20,17 +20,15 @@ public class ProvisioningService var results = new List(); 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;