feat(developers): add password-synced provisioning and deployment confirmation flow
All checks were successful
Build and Deploy / deploy (push) Successful in 41s

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>
This commit is contained in:
2026-02-22 21:16:13 +00:00
parent c4febd7036
commit 9cbbd2d4f2
6 changed files with 749 additions and 25 deletions

View File

@@ -1,41 +1,140 @@
using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace SilverLabs.Website.Services;
public record PendingDeployment(
string Token,
string Username,
string Email,
string FullName,
string TicketId,
DateTime CreatedAt,
DateTime ExpiresAt);
public class ProvisioningService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ProvisioningService> _logger;
private readonly IConfiguration _configuration;
public ProvisioningService(IHttpClientFactory httpClientFactory, ILogger<ProvisioningService> logger)
private static readonly ConcurrentDictionary<string, PendingDeployment> _pendingDeployments = new();
public ProvisioningService(
IHttpClientFactory httpClientFactory,
ILogger<ProvisioningService> logger,
IConfiguration configuration)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_configuration = configuration;
}
public async Task<(bool Success, string Message)> ApproveApplicationAsync(
string ticketId, string username, string email, string fullName)
// --- Token management ---
public PendingDeployment CreatePendingDeployment(string username, string email, string fullName, string ticketId)
{
CleanupExpiredTokens();
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
var deployment = new PendingDeployment(
token, username, email, fullName, ticketId,
DateTime.UtcNow, DateTime.UtcNow.AddHours(48));
_pendingDeployments[token] = deployment;
_logger.LogInformation("Created pending deployment for {Username} (ticket {TicketId}), token expires {ExpiresAt}",
username, ticketId, deployment.ExpiresAt);
return deployment;
}
public PendingDeployment? GetPendingDeployment(string token)
{
CleanupExpiredTokens();
if (_pendingDeployments.TryGetValue(token, out var deployment))
{
if (deployment.ExpiresAt > DateTime.UtcNow)
return deployment;
_pendingDeployments.TryRemove(token, out _);
}
return null;
}
public void RemovePendingDeployment(string token)
{
_pendingDeployments.TryRemove(token, out _);
}
private void CleanupExpiredTokens()
{
var expired = _pendingDeployments
.Where(kvp => kvp.Value.ExpiresAt <= DateTime.UtcNow)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in expired)
_pendingDeployments.TryRemove(key, out _);
}
// --- Authentication ---
public async Task<bool> ValidateSilverDeskCredentialsAsync(string username, string password)
{
try
{
var client = _httpClientFactory.CreateClient("SilverDesk");
var payload = new { username, password };
var response = await client.PostAsJsonAsync("/api/auth/login", payload);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("SilverDESK credential validation succeeded for {Username}", username);
return true;
}
_logger.LogWarning("SilverDESK credential validation failed for {Username}: {Status}", username, response.StatusCode);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error validating SilverDESK credentials for {Username}", username);
return false;
}
}
// --- Full provisioning with password ---
public async Task<(bool Success, string Message)> ProvisionWithPasswordAsync(
string ticketId, string username, string email, string fullName, string password)
{
var results = new List<string>();
var allSuccess = true;
// SilverDESK user already exists (created at application time)
// Only provision external services
// 1. Create Mattermost user
var (mmOk, mmMsg) = await CreateMattermostUserAsync(username, email, fullName);
var (mmOk, mmMsg) = await CreateMattermostUserAsync(username, email, fullName, password);
results.Add($"Mattermost: {mmMsg}");
if (!mmOk) allSuccess = false;
// 2. Create Mailcow mailbox
var (mailOk, mailMsg) = await CreateMailcowMailboxAsync(username, fullName);
var (mailOk, mailMsg) = await CreateMailcowMailboxAsync(username, fullName, password);
results.Add($"Mailcow: {mailMsg}");
if (!mailOk) allSuccess = false;
// 3. Update the DeveloperApplication record in SilverDESK
var (updateOk, updateMsg) = await UpdateApplicationStatusAsync(ticketId, mmOk, mailOk);
// 3. Create Gitea user
var (giteaOk, giteaMsg) = await CreateGiteaUserAsync(username, email, fullName, password);
results.Add($"Gitea: {giteaMsg}");
if (!giteaOk) allSuccess = false;
// 4. Update the DeveloperApplication record in SilverDESK
var (updateOk, updateMsg) = await UpdateApplicationStatusAsync(ticketId, mmOk, mailOk, giteaOk);
results.Add($"Application record: {updateMsg}");
if (!updateOk) allSuccess = false;
@@ -45,14 +144,70 @@ public class ProvisioningService
return (allSuccess, summary);
}
// --- Ticket replies ---
public async Task<(bool Success, string Message)> SendTicketReplyAsync(string ticketId, string content, string action = "waitingcustomer")
{
try
{
var client = _httpClientFactory.CreateClient("SilverDesk");
var payload = new { content, action };
var response = await client.PostAsJsonAsync($"/api/tickets/{ticketId}/reply", payload);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Sent ticket reply to {TicketId} with action {Action}", ticketId, action);
return (true, "Reply sent");
}
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Failed to send ticket reply to {TicketId}: {Status} {Body}", ticketId, response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending ticket reply to {TicketId}", ticketId);
return (false, $"Error: {ex.Message}");
}
}
// --- Password sync ---
public async Task<(bool Success, string Message)> SyncPasswordAsync(string username, string newPassword)
{
var results = new List<string>();
var allSuccess = true;
// 1. Mattermost - need to look up user ID first
var (mmOk, mmMsg) = await UpdateMattermostPasswordAsync(username, newPassword);
results.Add($"Mattermost: {mmMsg}");
if (!mmOk) allSuccess = false;
// 2. Mailcow
var (mailOk, mailMsg) = await UpdateMailcowPasswordAsync(username, newPassword);
results.Add($"Mailcow: {mailMsg}");
if (!mailOk) allSuccess = false;
// 3. Gitea
var (giteaOk, giteaMsg) = await UpdateGiteaPasswordAsync(username, newPassword);
results.Add($"Gitea: {giteaMsg}");
if (!giteaOk) allSuccess = false;
var summary = string.Join("; ", results);
_logger.LogInformation("Password sync for {Username}: {Summary}", username, summary);
return (allSuccess, summary);
}
// --- Application status update ---
private async Task<(bool Success, string Message)> UpdateApplicationStatusAsync(
string ticketId, bool mattermostProvisioned, bool mailcowProvisioned)
string ticketId, bool mattermostProvisioned, bool mailcowProvisioned, bool giteaProvisioned = false)
{
try
{
var client = _httpClientFactory.CreateClient("SilverDesk");
// Look up the application by ticketId
var lookupResponse = await client.GetAsync($"/api/developer-program/applications?ticketId={ticketId}");
if (!lookupResponse.IsSuccessStatusCode)
{
@@ -71,12 +226,12 @@ public class ProvisioningService
var appId = apps[0].GetProperty("id").GetString();
// Update the application status to Approved with provisioning flags
var updatePayload = new
{
status = 1, // ApplicationStatus.Approved
mattermostProvisioned,
mailcowProvisioned
mailcowProvisioned,
giteaProvisioned
};
var updateResponse = await client.PutAsJsonAsync($"/api/developer-program/applications/{appId}", updatePayload);
@@ -98,6 +253,8 @@ public class ProvisioningService
}
}
// --- Service account creation ---
public async Task<(bool Success, string Message)> CreateSilverDeskUserAsync(string username, string email, string fullName)
{
try
@@ -128,7 +285,8 @@ public class ProvisioningService
}
}
public async Task<(bool Success, string Message)> CreateMattermostUserAsync(string username, string email, string fullName)
private async Task<(bool Success, string Message)> CreateMattermostUserAsync(
string username, string email, string fullName, string password)
{
try
{
@@ -140,7 +298,7 @@ public class ProvisioningService
username,
first_name = nameParts[0],
last_name = nameParts.Length > 1 ? nameParts[1] : "",
password = Guid.NewGuid().ToString("N")[..16] + "!A1" // Temporary password
password
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -160,22 +318,22 @@ public class ProvisioningService
}
}
public async Task<(bool Success, string Message)> CreateMailcowMailboxAsync(string username, string fullName)
private async Task<(bool Success, string Message)> CreateMailcowMailboxAsync(
string username, string fullName, string password)
{
try
{
var client = _httpClientFactory.CreateClient("Mailcow");
var tempPassword = Guid.NewGuid().ToString("N")[..16] + "!A1";
var payload = new
{
local_part = username,
domain = "silverlabs.uk",
name = fullName,
password = tempPassword,
password2 = tempPassword,
password,
password2 = password,
quota = 1024, // 1GB
active = 1,
force_pw_update = 1
force_pw_update = 0
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -194,4 +352,143 @@ public class ProvisioningService
return (false, $"Error: {ex.Message}");
}
}
private async Task<(bool Success, string Message)> CreateGiteaUserAsync(
string username, string email, string fullName, string password)
{
try
{
var client = _httpClientFactory.CreateClient("Gitea");
var payload = new
{
email,
full_name = fullName,
login_name = username,
must_change_password = false,
password,
send_notify = false,
username,
visibility = "public"
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/admin/users", content);
if (response.IsSuccessStatusCode)
return (true, "User created");
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("Gitea user creation failed: {Status} {Body}", response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Gitea user creation error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
// --- Password update methods ---
private async Task<(bool Success, string Message)> UpdateMattermostPasswordAsync(string username, string newPassword)
{
try
{
var client = _httpClientFactory.CreateClient("Mattermost");
// Look up user ID by username
var userResponse = await client.GetAsync($"/api/v4/users/username/{username}");
if (!userResponse.IsSuccessStatusCode)
{
_logger.LogWarning("Mattermost user lookup failed for {Username}: {Status}", username, userResponse.StatusCode);
return (false, $"User not found ({userResponse.StatusCode})");
}
var userData = await userResponse.Content.ReadFromJsonAsync<JsonElement>();
var userId = userData.GetProperty("id").GetString();
// Update password (admin reset — no old password needed with bot token)
var payload = new { new_password = newPassword };
var response = await client.PutAsJsonAsync($"/api/v4/users/{userId}/password", payload);
if (response.IsSuccessStatusCode)
return (true, "Password updated");
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Mattermost password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Mattermost password update error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
private async Task<(bool Success, string Message)> UpdateMailcowPasswordAsync(string username, string newPassword)
{
try
{
var client = _httpClientFactory.CreateClient("Mailcow");
var payload = new
{
items = new[] { $"{username}@silverlabs.uk" },
attr = new
{
password = newPassword,
password2 = newPassword
}
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/edit/mailbox", content);
if (response.IsSuccessStatusCode)
return (true, "Password updated");
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Mailcow password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Mailcow password update error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
private async Task<(bool Success, string Message)> UpdateGiteaPasswordAsync(string username, string newPassword)
{
try
{
var client = _httpClientFactory.CreateClient("Gitea");
var payload = new
{
login_name = username,
password = newPassword,
must_change_password = false,
source_id = 0
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/admin/users/{username}")
{
Content = content
};
var response = await client.SendAsync(request);
if (response.IsSuccessStatusCode)
return (true, "Password updated");
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Gitea password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Gitea password update error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
}