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

@@ -0,0 +1,308 @@
@page "/developers/confirm/{Token}"
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IConfiguration Configuration
@rendermode InteractiveServer
<PageTitle>Activate Your Accounts - 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>Activate Your Accounts</h1>
<p class="dev-subtitle">Confirm your identity to provision your SilverLabs developer accounts.</p>
</div>
@if (_loading)
{
<div class="dev-section" style="text-align: center; padding: 3rem;">
<div class="btn-spinner" style="width: 32px; height: 32px; margin: 0 auto 1rem;"></div>
<p style="color: rgba(255,255,255,0.6);">Loading deployment details...</p>
</div>
}
else if (_invalidToken)
{
<div class="dev-section" style="text-align: center; padding: 3rem;">
<div class="confirm-icon confirm-icon-error">
<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">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</div>
<h2 style="color: #f87171; margin-bottom: 0.75rem;">Invalid or Expired Link</h2>
<p style="color: rgba(255,255,255,0.6); max-width: 400px; margin: 0 auto;">This confirmation link is no longer valid. It may have expired or already been used. Please contact an administrator if you need a new link.</p>
</div>
}
else if (_provisioned)
{
<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>Accounts Activated</h2>
<p>@_resultMessage</p>
<div class="confirm-services">
<div class="confirm-service-item">
<strong>Email</strong>
<span>@(_username)@@silverlabs.uk</span>
</div>
<div class="confirm-service-item">
<strong>Mattermost</strong>
<span>Team chat & collaboration</span>
</div>
<div class="confirm-service-item">
<strong>Gitea</strong>
<span>Source code repositories</span>
</div>
</div>
<p class="dev-account-note">All accounts use the same password you just entered.</p>
<div class="dev-success-actions">
<a href="https://silverdesk.silverlabs.uk" target="_blank" class="dev-btn dev-btn-primary">Go to SilverDESK</a>
<a href="/" class="dev-btn dev-btn-secondary">Back to Home</a>
</div>
</div>
}
else
{
<div class="dev-section">
<h2 class="dev-section-title">Confirm Your Identity</h2>
<p class="dev-section-desc">Enter your SilverDESK password to activate your accounts. All services will use this same password.</p>
<div class="confirm-user-info">
<div class="confirm-user-field">
<span class="confirm-label">Username</span>
<span class="confirm-value">@_username</span>
</div>
<div class="confirm-user-field">
<span class="confirm-label">Email</span>
<span class="confirm-value">@_email</span>
</div>
</div>
<div class="form-group" style="margin-top: 1.5rem; max-width: 400px;">
<label for="password">SilverDESK Password</label>
<input id="password" type="password" class="form-input" @bind="_password"
@bind:event="oninput" @onkeydown="HandleKeyDown" placeholder="Enter your password" />
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="dev-error" style="margin-top: 1rem;">@_errorMessage</div>
}
<div style="margin-top: 1.5rem;">
<button class="dev-btn dev-btn-primary" disabled="@_submitting" @onclick="HandleConfirm">
@if (_submitting)
{
<span class="btn-spinner"></span>
<span>Activating accounts...</span>
}
else
{
<span>Activate My Accounts</span>
}
</button>
</div>
</div>
}
<a href="/" class="back-link">← Back to SilverLabs Home</a>
</div>
</div>
<style>
.confirm-icon-error {
width: 72px;
height: 72px;
margin: 0 auto 1.5rem;
background: rgba(248, 113, 113, 0.15);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.confirm-icon-error svg {
width: 36px;
height: 36px;
stroke: #f87171;
}
.confirm-user-info {
display: flex;
gap: 2rem;
margin-top: 1rem;
padding: 1rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.confirm-user-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.confirm-label {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.confirm-value {
font-size: 1rem;
color: #4DD0E1;
font-weight: 600;
}
.confirm-services {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin: 1.5rem auto;
max-width: 500px;
}
.confirm-service-item {
display: flex;
flex-direction: column;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.04);
border-radius: 10px;
text-align: center;
}
.confirm-service-item strong {
color: #4DD0E1;
font-size: 0.95rem;
margin-bottom: 0.2rem;
}
.confirm-service-item span {
color: rgba(255, 255, 255, 0.55);
font-size: 0.8rem;
}
@@media (max-width: 768px) {
.confirm-user-info {
flex-direction: column;
gap: 0.75rem;
}
.confirm-services {
grid-template-columns: 1fr;
}
}
</style>
@code {
[Parameter] public string Token { get; set; } = "";
private bool _loading = true;
private bool _invalidToken;
private bool _provisioned;
private bool _submitting;
private string? _username;
private string? _email;
private string? _password;
private string? _errorMessage;
private string? _resultMessage;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
try
{
var baseUrl = Navigation.BaseUri.TrimEnd('/');
using var client = new HttpClient();
var response = await client.GetAsync($"{baseUrl}/api/developers/deployment-info/{Token}");
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<DeploymentInfo>();
_username = data?.Username;
_email = data?.Email;
}
else
{
_invalidToken = true;
}
}
catch
{
_invalidToken = true;
}
_loading = false;
StateHasChanged();
}
private async Task HandleKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !_submitting && !string.IsNullOrEmpty(_password))
await HandleConfirm();
}
private async Task HandleConfirm()
{
if (string.IsNullOrEmpty(_password))
{
_errorMessage = "Please enter your password.";
return;
}
_errorMessage = null;
_submitting = true;
StateHasChanged();
try
{
var baseUrl = Navigation.BaseUri.TrimEnd('/');
using var client = new HttpClient();
var payload = new { token = Token, password = _password };
var response = await client.PostAsJsonAsync($"{baseUrl}/api/developers/confirm-deployment", payload);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ProvisionResult>();
_resultMessage = result?.Message ?? "Accounts activated successfully.";
_provisioned = true;
}
else if ((int)response.StatusCode == 401)
{
_errorMessage = "Incorrect password. Please enter the password you created when you applied.";
}
else if ((int)response.StatusCode == 404)
{
_invalidToken = true;
}
else
{
_errorMessage = "Something went wrong. Please try again or contact an administrator.";
}
}
catch
{
_errorMessage = "Connection error. Please try again.";
}
finally
{
_submitting = false;
StateHasChanged();
}
}
private record DeploymentInfo(string Username, string Email, string FullName, DateTime ExpiresAt);
private record ProvisionResult(bool Success, string Message);
}

View File

@@ -28,9 +28,9 @@
</div> </div>
<h2>Application Submitted</h2> <h2>Application Submitted</h2>
<p>@_resultMessage</p> <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> <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"> <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="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> <a href="/" class="dev-btn dev-btn-secondary">Back to Home</a>
</div> </div>
</div> </div>

View File

@@ -48,10 +48,116 @@ public static class DeveloperEndpoints
if (string.IsNullOrEmpty(fullName) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(desiredUsername)) if (string.IsNullOrEmpty(fullName) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(desiredUsername))
return Results.Problem("Could not parse applicant details from ticket description", statusCode: 422); return Results.Problem("Could not parse applicant details from ticket description", statusCode: 422);
var (success, message) = await provisioningService.ApproveApplicationAsync( // Generate confirmation token instead of provisioning immediately
ticketId, desiredUsername, email, fullName); var deployment = provisioningService.CreatePendingDeployment(desiredUsername, email, fullName, ticketId);
var siteBase = config["SiteBaseUrl"] ?? "https://silverlabs.uk";
var confirmUrl = $"{siteBase}/developers/confirm/{deployment.Token}";
// Send ticket reply with confirmation link
var replyContent = $"""
Your application has been approved! To activate your accounts, please confirm your identity:
**[Click here to activate your accounts]({confirmUrl})**
You'll need to enter your SilverDESK password to complete the setup. This link expires in 48 hours.
Once confirmed, the following accounts will be created for you:
- **Email**: {desiredUsername}@silverlabs.uk
- **Mattermost**: Team chat access
- **Gitea**: Source code repository access
""";
var (replyOk, replyMsg) = await provisioningService.SendTicketReplyAsync(ticketId, replyContent);
return Results.Ok(new
{
success = true,
message = $"Confirmation link generated and sent via ticket reply. Reply status: {replyMsg}",
confirmUrl
});
});
// Token info endpoint for the confirmation page
group.MapGet("/deployment-info/{token}", (string token, ProvisioningService provisioningService) =>
{
var deployment = provisioningService.GetPendingDeployment(token);
if (deployment is null)
return Results.NotFound(new { message = "Invalid or expired confirmation link" });
return Results.Ok(new
{
username = deployment.Username,
email = deployment.Email,
fullName = deployment.FullName,
expiresAt = deployment.ExpiresAt
});
});
// Confirm deployment with password
group.MapPost("/confirm-deployment", async (
ConfirmDeploymentRequest request,
ProvisioningService provisioningService) =>
{
var deployment = provisioningService.GetPendingDeployment(request.Token);
if (deployment is null)
return Results.NotFound(new { message = "Invalid or expired confirmation link" });
// Validate credentials against SilverDESK
var authenticated = await provisioningService.ValidateSilverDeskCredentialsAsync(
deployment.Username, request.Password);
if (!authenticated)
return Results.Json(new { message = "Invalid password. Please enter your SilverDESK password." }, statusCode: 401);
// Provision all services with the user's password
var (success, message) = await provisioningService.ProvisionWithPasswordAsync(
deployment.TicketId, deployment.Username, deployment.Email, deployment.FullName, request.Password);
// Send follow-up ticket reply with results
var resultContent = success
? $"""
Your accounts have been successfully provisioned:
{message}
You can now log in to all services with your SilverDESK credentials.
"""
: $"""
Account provisioning completed with some issues:
{message}
Please contact an administrator if you have trouble accessing any services.
""";
await provisioningService.SendTicketReplyAsync(deployment.TicketId, resultContent, "close");
// Remove the used token
provisioningService.RemovePendingDeployment(request.Token);
return Results.Ok(new { success, message });
});
// Password sync endpoint (called by SilverDESK on password reset)
group.MapPost("/sync-password", async (
SyncPasswordRequest request,
ProvisioningService provisioningService,
HttpContext context,
IConfiguration config) =>
{
var apiKey = context.Request.Headers["X-Api-Key"].FirstOrDefault();
var expectedKey = config["AdminApiKey"];
if (string.IsNullOrEmpty(expectedKey) || apiKey != expectedKey)
return Results.Unauthorized();
var (success, message) = await provisioningService.SyncPasswordAsync(request.Username, request.NewPassword);
return Results.Ok(new { success, message }); return Results.Ok(new { success, message });
}); });
} }
} }
public record ConfirmDeploymentRequest(string Token, string Password);
public record SyncPasswordRequest(string Username, string NewPassword);

View File

@@ -51,6 +51,14 @@ builder.Services.AddHttpClient("Mailcow", client =>
client.DefaultRequestHeaders.Add("X-API-Key", apiKey); client.DefaultRequestHeaders.Add("X-API-Key", apiKey);
}); });
builder.Services.AddHttpClient("Gitea", client =>
{
client.BaseAddress = new Uri(builder.Configuration["Gitea:BaseUrl"] ?? "https://git.silverlabs.uk");
var token = builder.Configuration["Gitea:ApiToken"];
if (!string.IsNullOrEmpty(token))
client.DefaultRequestHeaders.Add("Authorization", $"token {token}");
});
builder.Services.AddScoped<ProvisioningService>(); builder.Services.AddScoped<ProvisioningService>();
var app = builder.Build(); var app = builder.Build();

View File

@@ -1,41 +1,140 @@
using System.Collections.Concurrent;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
namespace SilverLabs.Website.Services; namespace SilverLabs.Website.Services;
public record PendingDeployment(
string Token,
string Username,
string Email,
string FullName,
string TicketId,
DateTime CreatedAt,
DateTime ExpiresAt);
public class ProvisioningService public class ProvisioningService
{ {
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ProvisioningService> _logger; 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; _httpClientFactory = httpClientFactory;
_logger = logger; _logger = logger;
_configuration = configuration;
} }
public async Task<(bool Success, string Message)> ApproveApplicationAsync( // --- Token management ---
string ticketId, string username, string email, string fullName)
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 results = new List<string>();
var allSuccess = true; var allSuccess = true;
// SilverDESK user already exists (created at application time)
// Only provision external services
// 1. Create Mattermost user // 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}"); results.Add($"Mattermost: {mmMsg}");
if (!mmOk) allSuccess = false; if (!mmOk) allSuccess = false;
// 2. Create Mailcow mailbox // 2. Create Mailcow mailbox
var (mailOk, mailMsg) = await CreateMailcowMailboxAsync(username, fullName); var (mailOk, mailMsg) = await CreateMailcowMailboxAsync(username, fullName, password);
results.Add($"Mailcow: {mailMsg}"); results.Add($"Mailcow: {mailMsg}");
if (!mailOk) allSuccess = false; if (!mailOk) allSuccess = false;
// 3. Update the DeveloperApplication record in SilverDESK // 3. Create Gitea user
var (updateOk, updateMsg) = await UpdateApplicationStatusAsync(ticketId, mmOk, mailOk); 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}"); results.Add($"Application record: {updateMsg}");
if (!updateOk) allSuccess = false; if (!updateOk) allSuccess = false;
@@ -45,14 +144,70 @@ public class ProvisioningService
return (allSuccess, summary); 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( private async Task<(bool Success, string Message)> UpdateApplicationStatusAsync(
string ticketId, bool mattermostProvisioned, bool mailcowProvisioned) string ticketId, bool mattermostProvisioned, bool mailcowProvisioned, bool giteaProvisioned = false)
{ {
try try
{ {
var client = _httpClientFactory.CreateClient("SilverDesk"); var client = _httpClientFactory.CreateClient("SilverDesk");
// Look up the application by ticketId
var lookupResponse = await client.GetAsync($"/api/developer-program/applications?ticketId={ticketId}"); var lookupResponse = await client.GetAsync($"/api/developer-program/applications?ticketId={ticketId}");
if (!lookupResponse.IsSuccessStatusCode) if (!lookupResponse.IsSuccessStatusCode)
{ {
@@ -71,12 +226,12 @@ public class ProvisioningService
var appId = apps[0].GetProperty("id").GetString(); var appId = apps[0].GetProperty("id").GetString();
// Update the application status to Approved with provisioning flags
var updatePayload = new var updatePayload = new
{ {
status = 1, // ApplicationStatus.Approved status = 1, // ApplicationStatus.Approved
mattermostProvisioned, mattermostProvisioned,
mailcowProvisioned mailcowProvisioned,
giteaProvisioned
}; };
var updateResponse = await client.PutAsJsonAsync($"/api/developer-program/applications/{appId}", updatePayload); 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) public async Task<(bool Success, string Message)> CreateSilverDeskUserAsync(string username, string email, string fullName)
{ {
try 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 try
{ {
@@ -140,7 +298,7 @@ public class ProvisioningService
username, username,
first_name = nameParts[0], first_name = nameParts[0],
last_name = nameParts.Length > 1 ? nameParts[1] : "", last_name = nameParts.Length > 1 ? nameParts[1] : "",
password = Guid.NewGuid().ToString("N")[..16] + "!A1" // Temporary password password
}; };
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json"); 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 try
{ {
var client = _httpClientFactory.CreateClient("Mailcow"); var client = _httpClientFactory.CreateClient("Mailcow");
var tempPassword = Guid.NewGuid().ToString("N")[..16] + "!A1";
var payload = new var payload = new
{ {
local_part = username, local_part = username,
domain = "silverlabs.uk", domain = "silverlabs.uk",
name = fullName, name = fullName,
password = tempPassword, password,
password2 = tempPassword, password2 = password,
quota = 1024, // 1GB quota = 1024, // 1GB
active = 1, active = 1,
force_pw_update = 1 force_pw_update = 0
}; };
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json"); var content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -194,4 +352,143 @@ public class ProvisioningService
return (false, $"Error: {ex.Message}"); 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}");
}
}
} }

View File

@@ -18,5 +18,10 @@
"BaseUrl": "https://mail.silverlined.uk", "BaseUrl": "https://mail.silverlined.uk",
"ApiKey": "2A21AA-47E4E5-46DD62-A650F0-BC7566" "ApiKey": "2A21AA-47E4E5-46DD62-A650F0-BC7566"
}, },
"Gitea": {
"BaseUrl": "https://git.silverlabs.uk",
"ApiToken": ""
},
"SiteBaseUrl": "https://silverlabs.uk",
"AdminApiKey": "aawb2MHblbfmqdhcS7Xp2/ibQOUbUE1BDoqdJOu0bjM=" "AdminApiKey": "aawb2MHblbfmqdhcS7Xp2/ibQOUbUE1BDoqdJOu0bjM="
} }