feat(developers): add password-synced provisioning and deployment confirmation flow
All checks were successful
Build and Deploy / deploy (push) Successful in 41s
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:
308
BlazorApp/Components/Pages/DeploymentConfirm.razor
Normal file
308
BlazorApp/Components/Pages/DeploymentConfirm.razor
Normal 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);
|
||||
}
|
||||
@@ -28,9 +28,9 @@
|
||||
</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>
|
||||
<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" 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,10 +48,116 @@ public static class DeveloperEndpoints
|
||||
if (string.IsNullOrEmpty(fullName) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(desiredUsername))
|
||||
return Results.Problem("Could not parse applicant details from ticket description", statusCode: 422);
|
||||
|
||||
var (success, message) = await provisioningService.ApproveApplicationAsync(
|
||||
ticketId, desiredUsername, email, fullName);
|
||||
// Generate confirmation token instead of provisioning immediately
|
||||
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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public record ConfirmDeploymentRequest(string Token, string Password);
|
||||
public record SyncPasswordRequest(string Username, string NewPassword);
|
||||
|
||||
@@ -51,6 +51,14 @@ builder.Services.AddHttpClient("Mailcow", client =>
|
||||
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>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,5 +18,10 @@
|
||||
"BaseUrl": "https://mail.silverlined.uk",
|
||||
"ApiKey": "2A21AA-47E4E5-46DD62-A650F0-BC7566"
|
||||
},
|
||||
"Gitea": {
|
||||
"BaseUrl": "https://git.silverlabs.uk",
|
||||
"ApiToken": ""
|
||||
},
|
||||
"SiteBaseUrl": "https://silverlabs.uk",
|
||||
"AdminApiKey": "aawb2MHblbfmqdhcS7Xp2/ibQOUbUE1BDoqdJOu0bjM="
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user