Compare commits
18 Commits
a8b7cc2ffd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| af971b7b83 | |||
| 4a087a4f24 | |||
| 502d48da99 | |||
| cd2994d7eb | |||
| dc9a60a7a2 | |||
| 44e3ad94e0 | |||
| 296c7fefc5 | |||
| 9cbbd2d4f2 | |||
| c4febd7036 | |||
| 324ce141d0 | |||
| e5eacd8725 | |||
| 33b21959d8 | |||
| a8d827eace | |||
| d0785e04e1 | |||
| a4d2e571d5 | |||
| 008ca7f65d | |||
| 587467321d | |||
| ed5d14989a |
357
BlazorApp/Components/Pages/DeploymentConfirm.razor
Normal file
357
BlazorApp/Components/Pages/DeploymentConfirm.razor
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
@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">
|
||||||
|
<a href="https://mail.silverlined.uk" target="_blank" class="confirm-service-item confirm-service-link">
|
||||||
|
<strong>Email</strong>
|
||||||
|
<span>@(_username)@@silverlabs.uk</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://ops.silverlined.uk" target="_blank" class="confirm-service-item confirm-service-link">
|
||||||
|
<strong>Mattermost</strong>
|
||||||
|
<span>Team chat & collaboration</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://git.silverlabs.uk" target="_blank" class="confirm-service-item confirm-service-link">
|
||||||
|
<strong>Gitea</strong>
|
||||||
|
<span>Source code repositories</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://silverdesk.silverlabs.uk" target="_blank" class="confirm-service-item confirm-service-link">
|
||||||
|
<strong>SilverDESK</strong>
|
||||||
|
<span>Support & tickets</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-email-config">
|
||||||
|
<strong>Email Client Setup</strong>
|
||||||
|
<div class="confirm-email-detail"><span>IMAP:</span> mail.silverlined.uk:993 (SSL)</div>
|
||||||
|
<div class="confirm-email-detail"><span>SMTP:</span> mail.silverlined.uk:465 (SSL)</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(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-link {
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid rgba(77, 208, 225, 0.15);
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-service-link:hover {
|
||||||
|
background: rgba(77, 208, 225, 0.08);
|
||||||
|
border-color: rgba(77, 208, 225, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-email-config {
|
||||||
|
margin: 1rem auto;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-email-config strong {
|
||||||
|
display: block;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-email-detail {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-email-detail span {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 768px) {
|
||||||
|
.confirm-user-info {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-services {
|
||||||
|
grid-template-columns: repeat(2, 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);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
@using SilverLabs.Website.Models
|
@using SilverLabs.Website.Models
|
||||||
@using SilverLabs.Website.Services
|
@using SilverLabs.Website.Services
|
||||||
@inject DeveloperApplicationService ApplicationService
|
@inject DeveloperApplicationService ApplicationService
|
||||||
|
@inject IJSRuntime JS
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<PageTitle>Join the Team - SilverLabs</PageTitle>
|
<PageTitle>Join the Team - SilverLabs</PageTitle>
|
||||||
@@ -28,7 +29,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<h2>Application Submitted</h2>
|
<h2>Application Submitted</h2>
|
||||||
<p>@_resultMessage</p>
|
<p>@_resultMessage</p>
|
||||||
<a href="/" class="dev-btn dev-btn-secondary">Back to Home</a>
|
<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/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>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -81,26 +86,74 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email Address</label>
|
<label for="email">Email Address (optional)</label>
|
||||||
<InputText id="email" @bind-Value="_application.Email" class="form-input" placeholder="jane@example.com" />
|
<InputText id="email" @bind-Value="_application.Email" class="form-input" placeholder="jane@example.com" />
|
||||||
|
<span class="form-hint">Leave blank to use your @@silverlabs.uk address</span>
|
||||||
<ValidationMessage For="() => _application.Email" />
|
<ValidationMessage For="() => _application.Email" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">Desired Username</label>
|
<label for="username">Desired Username</label>
|
||||||
<InputText id="username" @bind-Value="_application.DesiredUsername" class="form-input" placeholder="janedoe" />
|
<input id="username" class="form-input" placeholder="janedoe" value="@_application.DesiredUsername"
|
||||||
<span class="form-hint">This will be your handle across SilverLabs services</span>
|
@oninput="OnUsernameInput" @onfocusout="OnUsernameBlur" autocomplete="off" />
|
||||||
|
<span class="form-hint">3–30 characters: letters, numbers, hyphens and underscores</span>
|
||||||
|
@if (!string.IsNullOrEmpty(_usernameFormatError))
|
||||||
|
{
|
||||||
|
<span class="username-status username-format-error">@_usernameFormatError</span>
|
||||||
|
}
|
||||||
|
else 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>
|
||||||
|
}
|
||||||
|
else if (_usernameCheckState == UsernameCheckState.Error)
|
||||||
|
{
|
||||||
|
<span class="username-status username-error">⚠ Could not check availability — you can still submit</span>
|
||||||
|
}
|
||||||
<ValidationMessage For="() => _application.DesiredUsername" />
|
<ValidationMessage For="() => _application.DesiredUsername" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="timezone">Location / Timezone</label>
|
<label for="timezone">Timezone</label>
|
||||||
<InputText id="timezone" @bind-Value="_application.Timezone" class="form-input" placeholder="e.g. Europe/London, US/Eastern" />
|
<InputSelect id="timezone" @bind-Value="_application.Timezone" class="form-input">
|
||||||
|
<option value="">Select your timezone...</option>
|
||||||
|
@foreach (var tz in _timezones)
|
||||||
|
{
|
||||||
|
<option value="@tz.Id">@tz.Label</option>
|
||||||
|
}
|
||||||
|
</InputSelect>
|
||||||
<ValidationMessage For="() => _application.Timezone" />
|
<ValidationMessage For="() => _application.Timezone" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Platforms -->
|
||||||
<div class="dev-section">
|
<div class="dev-section">
|
||||||
<h2 class="dev-section-title">Devices & Platforms</h2>
|
<h2 class="dev-section-title">Devices & Platforms</h2>
|
||||||
@@ -119,29 +172,101 @@
|
|||||||
<ValidationMessage For="() => _application.Platforms" />
|
<ValidationMessage For="() => _application.Platforms" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Developer-only: Skills -->
|
<!-- Role-Specific Assessment -->
|
||||||
@if (_application.Role == ApplicationRole.Developer)
|
@if (_application.Role == ApplicationRole.Tester)
|
||||||
{
|
{
|
||||||
<div class="dev-section dev-section-fade-in">
|
<div class="dev-section">
|
||||||
<h2 class="dev-section-title">Skills & Experience</h2>
|
<h2 class="dev-section-title">About Your Experience</h2>
|
||||||
<p class="dev-section-desc">Tell us about your technical background — languages, frameworks, and any open-source contributions.</p>
|
<p class="dev-section-desc">Help us understand your background — there are no wrong answers.</p>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||||||
|
<label>How well do you understand the internet and online services?</label>
|
||||||
|
<div class="star-rating">
|
||||||
|
@for (int i = 1; i <= 5; i++)
|
||||||
|
{
|
||||||
|
var rating = i;
|
||||||
|
<span class="star-rating-star @(rating <= (_internetHover > 0 ? _internetHover : (_application.InternetUnderstanding ?? 0)) ? "star-filled" : "")"
|
||||||
|
@onclick="() => _application.InternetUnderstanding = rating"
|
||||||
|
@onmouseover="() => _internetHover = rating"
|
||||||
|
@onmouseout="() => _internetHover = 0">@(rating <= (_internetHover > 0 ? _internetHover : (_application.InternetUnderstanding ?? 0)) ? "\u2605" : "\u2606")</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="rating-labels">
|
||||||
|
<span>Beginner</span>
|
||||||
|
<span>Expert</span>
|
||||||
|
</div>
|
||||||
|
<ValidationMessage For="() => _application.InternetUnderstanding" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||||||
|
<label>How much do you enjoy trying new software and finding issues?</label>
|
||||||
|
<div class="star-rating">
|
||||||
|
@for (int i = 1; i <= 5; i++)
|
||||||
|
{
|
||||||
|
var rating = i;
|
||||||
|
<span class="star-rating-star @(rating <= (_testingHover > 0 ? _testingHover : (_application.EnjoysTesting ?? 0)) ? "star-filled" : "")"
|
||||||
|
@onclick="() => _application.EnjoysTesting = rating"
|
||||||
|
@onmouseover="() => _testingHover = rating"
|
||||||
|
@onmouseout="() => _testingHover = 0">@(rating <= (_testingHover > 0 ? _testingHover : (_application.EnjoysTesting ?? 0)) ? "\u2605" : "\u2606")</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="rating-labels">
|
||||||
|
<span>Not really</span>
|
||||||
|
<span>Love it</span>
|
||||||
|
</div>
|
||||||
|
<ValidationMessage For="() => _application.EnjoysTesting" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<InputTextArea id="skills" @bind-Value="_application.Skills" class="form-input form-textarea"
|
<label for="additionalNotes">Anything else you'd like us to know? (optional)</label>
|
||||||
placeholder="e.g. C#/.NET 5 years, Blazor, PostgreSQL, Docker, contributed to..." rows="5" />
|
<InputTextArea id="additionalNotes" @bind-Value="_application.AdditionalNotes" class="form-input form-textarea"
|
||||||
|
placeholder="Previous testing experience, specific interests, etc." rows="3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="dev-section">
|
||||||
|
<h2 class="dev-section-title">Your Skills</h2>
|
||||||
|
<p class="dev-section-desc">Select your experience level and the technologies you work with.</p>
|
||||||
|
|
||||||
<!-- Motivation -->
|
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||||||
<div class="dev-section">
|
<label>Experience Level</label>
|
||||||
<h2 class="dev-section-title">Why SilverLabs?</h2>
|
<div class="experience-selector">
|
||||||
<p class="dev-section-desc">What draws you to privacy-first development? What do you hope to contribute?</p>
|
@foreach (var range in SkillCatalog.ExperienceRanges)
|
||||||
<div class="form-group">
|
{
|
||||||
<InputTextArea id="motivation" @bind-Value="_application.Motivation" class="form-input form-textarea"
|
<button type="button"
|
||||||
placeholder="Tell us what motivates you..." rows="5" />
|
class="exp-btn @(_application.ExperienceRange == range ? "exp-active" : "")"
|
||||||
<ValidationMessage For="() => _application.Motivation" />
|
@onclick="() => _application.ExperienceRange = range">@range</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<ValidationMessage For="() => _application.ExperienceRange" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||||||
|
<label>Technologies (select all that apply)</label>
|
||||||
|
@foreach (var category in SkillCatalog.SkillCategories)
|
||||||
|
{
|
||||||
|
<div class="skill-category-label">@category.Key</div>
|
||||||
|
<div class="skill-bubbles">
|
||||||
|
@foreach (var skill in category.Value)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="skill-bubble @(_application.SelectedSkills.Contains(skill) ? "skill-active" : "")"
|
||||||
|
@onclick="() => ToggleSkill(skill)">@skill</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<ValidationMessage For="() => _application.SelectedSkills" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="additionalNotes">Anything not listed above? (optional)</label>
|
||||||
|
<InputTextArea id="additionalNotes" @bind-Value="_application.AdditionalNotes" class="form-input form-textarea"
|
||||||
|
placeholder="Other skills, open-source contributions, areas of interest..." rows="3" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
<!-- What You Get -->
|
<!-- What You Get -->
|
||||||
<div class="dev-section dev-perks">
|
<div class="dev-section dev-perks">
|
||||||
@@ -172,7 +297,7 @@
|
|||||||
{
|
{
|
||||||
<div class="dev-error">@_errorMessage</div>
|
<div class="dev-error">@_errorMessage</div>
|
||||||
}
|
}
|
||||||
<button type="submit" class="dev-btn dev-btn-primary" disabled="@_submitting">
|
<button type="submit" class="dev-btn dev-btn-primary" disabled="@IsSubmitDisabled">
|
||||||
@if (_submitting)
|
@if (_submitting)
|
||||||
{
|
{
|
||||||
<span class="btn-spinner"></span>
|
<span class="btn-spinner"></span>
|
||||||
@@ -197,9 +322,73 @@
|
|||||||
private bool _submitted;
|
private bool _submitted;
|
||||||
private string? _resultMessage;
|
private string? _resultMessage;
|
||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
|
private int _internetHover;
|
||||||
|
private int _testingHover;
|
||||||
|
|
||||||
|
private UsernameCheckState _usernameCheckState = UsernameCheckState.None;
|
||||||
|
private string? _usernameFormatError;
|
||||||
|
private string? _lastCheckedUsername;
|
||||||
|
|
||||||
private readonly string[] _availablePlatforms = { "Windows", "macOS", "Linux", "Android", "iOS", "Other" };
|
private readonly string[] _availablePlatforms = { "Windows", "macOS", "Linux", "Android", "iOS", "Other" };
|
||||||
|
|
||||||
|
private static readonly List<(string Id, string Label)> _timezones = new()
|
||||||
|
{
|
||||||
|
("Pacific/Midway", "(UTC-11:00) Midway Island"),
|
||||||
|
("Pacific/Honolulu", "(UTC-10:00) Hawaii"),
|
||||||
|
("America/Anchorage", "(UTC-09:00) Alaska"),
|
||||||
|
("America/Los_Angeles", "(UTC-08:00) Pacific Time (US & Canada)"),
|
||||||
|
("America/Denver", "(UTC-07:00) Mountain Time (US & Canada)"),
|
||||||
|
("America/Chicago", "(UTC-06:00) Central Time (US & Canada)"),
|
||||||
|
("America/New_York", "(UTC-05:00) Eastern Time (US & Canada)"),
|
||||||
|
("America/Caracas", "(UTC-04:00) Venezuela"),
|
||||||
|
("America/Halifax", "(UTC-04:00) Atlantic Time (Canada)"),
|
||||||
|
("America/Sao_Paulo", "(UTC-03:00) Brazil"),
|
||||||
|
("Atlantic/South_Georgia","(UTC-02:00) Mid-Atlantic"),
|
||||||
|
("Atlantic/Azores", "(UTC-01:00) Azores"),
|
||||||
|
("Europe/London", "(UTC+00:00) London, Dublin, Lisbon"),
|
||||||
|
("Europe/Berlin", "(UTC+01:00) Berlin, Paris, Amsterdam"),
|
||||||
|
("Europe/Bucharest", "(UTC+02:00) Bucharest, Helsinki, Athens"),
|
||||||
|
("Europe/Moscow", "(UTC+03:00) Moscow, Istanbul"),
|
||||||
|
("Asia/Dubai", "(UTC+04:00) Dubai, Baku"),
|
||||||
|
("Asia/Karachi", "(UTC+05:00) Karachi, Tashkent"),
|
||||||
|
("Asia/Kolkata", "(UTC+05:30) Mumbai, New Delhi"),
|
||||||
|
("Asia/Dhaka", "(UTC+06:00) Dhaka, Almaty"),
|
||||||
|
("Asia/Bangkok", "(UTC+07:00) Bangkok, Jakarta"),
|
||||||
|
("Asia/Shanghai", "(UTC+08:00) Beijing, Singapore, Perth"),
|
||||||
|
("Asia/Tokyo", "(UTC+09:00) Tokyo, Seoul"),
|
||||||
|
("Australia/Sydney", "(UTC+10:00) Sydney, Melbourne"),
|
||||||
|
("Pacific/Noumea", "(UTC+11:00) Solomon Islands"),
|
||||||
|
("Pacific/Auckland", "(UTC+12:00) Auckland, Fiji"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly System.Text.RegularExpressions.Regex UsernamePattern =
|
||||||
|
new(@"^[a-zA-Z0-9_-]{3,30}$", System.Text.RegularExpressions.RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private enum UsernameCheckState { None, Checking, Available, Taken, Error }
|
||||||
|
|
||||||
|
private bool IsSubmitDisabled =>
|
||||||
|
_submitting || _usernameCheckState == UsernameCheckState.Taken || _usernameCheckState == UsernameCheckState.Checking;
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && string.IsNullOrEmpty(_application.Timezone))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var detectedTz = await JS.InvokeAsync<string>("eval", "Intl.DateTimeFormat().resolvedOptions().timeZone");
|
||||||
|
if (!string.IsNullOrEmpty(detectedTz) && _timezones.Any(tz => tz.Id == detectedTz))
|
||||||
|
{
|
||||||
|
_application.Timezone = detectedTz;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Browser may not support Intl API — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void SelectRole(ApplicationRole role)
|
private void SelectRole(ApplicationRole role)
|
||||||
{
|
{
|
||||||
_application.Role = role;
|
_application.Role = role;
|
||||||
@@ -213,6 +402,65 @@
|
|||||||
_application.Platforms.Add(platform);
|
_application.Platforms.Add(platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ToggleSkill(string skill)
|
||||||
|
{
|
||||||
|
if (_application.SelectedSkills.Contains(skill))
|
||||||
|
_application.SelectedSkills.Remove(skill);
|
||||||
|
else
|
||||||
|
_application.SelectedSkills.Add(skill);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUsernameInput(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
var username = e.Value?.ToString() ?? "";
|
||||||
|
_application.DesiredUsername = username;
|
||||||
|
|
||||||
|
// Reset API check state while typing — we'll check on blur
|
||||||
|
_usernameCheckState = UsernameCheckState.None;
|
||||||
|
_usernameFormatError = null;
|
||||||
|
|
||||||
|
// Show inline format feedback as they type
|
||||||
|
if (username.Length > 0 && username.Length < 3)
|
||||||
|
{
|
||||||
|
_usernameFormatError = "Username must be at least 3 characters";
|
||||||
|
}
|
||||||
|
else if (username.Length > 30)
|
||||||
|
{
|
||||||
|
_usernameFormatError = "Username must be 30 characters or fewer";
|
||||||
|
}
|
||||||
|
else if (username.Length >= 3 && !UsernamePattern.IsMatch(username))
|
||||||
|
{
|
||||||
|
_usernameFormatError = "Only letters, numbers, hyphens and underscores allowed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnUsernameBlur()
|
||||||
|
{
|
||||||
|
var username = _application.DesiredUsername?.Trim() ?? "";
|
||||||
|
|
||||||
|
// Don't check if empty, invalid format, or already checked this exact username
|
||||||
|
if (string.IsNullOrEmpty(username) || !UsernamePattern.IsMatch(username))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (username == _lastCheckedUsername && _usernameCheckState != UsernameCheckState.Error)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_usernameCheckState = UsernameCheckState.Checking;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var available = await ApplicationService.CheckUsernameAsync(username);
|
||||||
|
_lastCheckedUsername = username;
|
||||||
|
|
||||||
|
_usernameCheckState = available switch
|
||||||
|
{
|
||||||
|
true => UsernameCheckState.Available,
|
||||||
|
false => UsernameCheckState.Taken,
|
||||||
|
null => UsernameCheckState.Error
|
||||||
|
};
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleSubmit()
|
private async Task HandleSubmit()
|
||||||
{
|
{
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
@@ -220,7 +468,7 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (success, message) = await ApplicationService.SubmitApplicationAsync(_application);
|
var (success, message, token) = await ApplicationService.SubmitApplicationAsync(_application);
|
||||||
|
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,18 +9,26 @@ public static class DeveloperEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/developers");
|
var group = app.MapGroup("/api/developers");
|
||||||
|
|
||||||
|
group.MapGet("/check-username/{username}", async (string username, DeveloperApplicationService service) =>
|
||||||
|
{
|
||||||
|
var available = await service.CheckUsernameAsync(username);
|
||||||
|
if (available is null)
|
||||||
|
return Results.Problem("Unable to verify username availability", statusCode: 503);
|
||||||
|
return Results.Ok(new { available = available.Value });
|
||||||
|
});
|
||||||
|
|
||||||
group.MapPost("/apply", async (DeveloperApplication application, DeveloperApplicationService service) =>
|
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
|
return success
|
||||||
? Results.Ok(new { message })
|
? Results.Ok(new { message, token })
|
||||||
: Results.Problem(message, statusCode: 502);
|
: Results.Problem(message, statusCode: 502);
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/approve/{ticketId:int}", async (
|
group.MapPost("/approve/{ticketId}", async (
|
||||||
int ticketId,
|
string ticketId,
|
||||||
ApproveRequest request,
|
DeveloperTicketParsingService ticketService,
|
||||||
ProvisioningService service,
|
ProvisioningService provisioningService,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
IConfiguration config) =>
|
IConfiguration config) =>
|
||||||
{
|
{
|
||||||
@@ -30,14 +38,151 @@ public static class DeveloperEndpoints
|
|||||||
if (string.IsNullOrEmpty(expectedKey) || apiKey != expectedKey)
|
if (string.IsNullOrEmpty(expectedKey) || apiKey != expectedKey)
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
|
|
||||||
var (success, message) = await service.ApproveApplicationAsync(
|
var ticket = await ticketService.FetchTicketAsync(ticketId);
|
||||||
ticketId, request.Username, request.Email, request.FullName);
|
if (ticket is null)
|
||||||
|
return Results.Problem("Failed to fetch ticket from SilverDESK", statusCode: 502);
|
||||||
|
|
||||||
return success
|
var description = ticket.Value.GetProperty("description").GetString() ?? "";
|
||||||
? Results.Ok(new { message })
|
var (fullName, email, desiredUsername, role) = ticketService.ParseApplicationFromDescription(description);
|
||||||
: Results.Problem(message, statusCode: 502);
|
|
||||||
|
if (string.IsNullOrEmpty(fullName) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(desiredUsername))
|
||||||
|
return Results.Problem("Could not parse applicant details from ticket description", statusCode: 422);
|
||||||
|
|
||||||
|
// Generate confirmation token instead of provisioning immediately
|
||||||
|
var deployment = provisioningService.CreatePendingDeployment(desiredUsername, email, fullName, ticketId, role);
|
||||||
|
|
||||||
|
var siteBase = config["SiteBaseUrl"] ?? "https://silverlabs.uk";
|
||||||
|
var confirmUrl = $"{siteBase}/developers/confirm/{deployment.Token}";
|
||||||
|
|
||||||
|
// Send ticket reply with confirmation link
|
||||||
|
var giteaLine = string.Equals(role, "Developer", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "\n- **Gitea**: Source code repository access"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
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{giteaLine}
|
||||||
|
""";
|
||||||
|
|
||||||
|
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, deployment.Role);
|
||||||
|
|
||||||
|
var isDeveloper = string.Equals(deployment.Role, "Developer", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var giteaSuccessSection = isDeveloper
|
||||||
|
? $"\n\n**Gitea** (Source Code): [git.silverlabs.uk](https://git.silverlabs.uk)"
|
||||||
|
: "";
|
||||||
|
var giteaFailSection = isDeveloper
|
||||||
|
? $"\n- **Gitea**: [git.silverlabs.uk](https://git.silverlabs.uk)"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Send follow-up ticket reply with results
|
||||||
|
var resultContent = success
|
||||||
|
? $"""
|
||||||
|
Your accounts have been successfully provisioned! Here's how to access your services:
|
||||||
|
|
||||||
|
**Email**: {deployment.Username}@silverlabs.uk
|
||||||
|
- Webmail: [mail.silverlined.uk](https://mail.silverlined.uk)
|
||||||
|
- IMAP: `mail.silverlined.uk:993` (SSL)
|
||||||
|
- SMTP: `mail.silverlined.uk:465` (SSL)
|
||||||
|
|
||||||
|
**Mattermost** (Team Chat): [ops.silverlined.uk](https://ops.silverlined.uk){giteaSuccessSection}
|
||||||
|
|
||||||
|
**SilverDESK** (Support & Tickets): [silverdesk.silverlabs.uk](https://silverdesk.silverlabs.uk)
|
||||||
|
|
||||||
|
All services use the same password you entered during activation.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Provisioning status: {message}*
|
||||||
|
"""
|
||||||
|
: $"""
|
||||||
|
Account provisioning completed with some issues:
|
||||||
|
|
||||||
|
{message}
|
||||||
|
|
||||||
|
Some services may not be available yet. Please contact an administrator for assistance.
|
||||||
|
|
||||||
|
Once resolved, your services will be:
|
||||||
|
- **Email**: {deployment.Username}@silverlabs.uk — [mail.silverlined.uk](https://mail.silverlined.uk)
|
||||||
|
- **Mattermost**: [ops.silverlined.uk](https://ops.silverlined.uk){giteaFailSection}
|
||||||
|
""";
|
||||||
|
|
||||||
|
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 ApproveRequest(string Username, string Email, string FullName);
|
public record ConfirmDeploymentRequest(string Token, string Password);
|
||||||
|
public record SyncPasswordRequest(string Username, string NewPassword);
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace SilverLabs.Website.Models;
|
namespace SilverLabs.Website.Models;
|
||||||
|
|
||||||
public class DeveloperApplication
|
public class DeveloperApplication : IValidatableObject
|
||||||
{
|
{
|
||||||
[Required(ErrorMessage = "Full name is required")]
|
[Required(ErrorMessage = "Full name is required")]
|
||||||
[StringLength(100, MinimumLength = 2)]
|
[StringLength(100, MinimumLength = 2)]
|
||||||
public string FullName { get; set; } = string.Empty;
|
public string FullName { get; set; } = string.Empty;
|
||||||
|
|
||||||
[Required(ErrorMessage = "Email is required")]
|
|
||||||
[EmailAddress(ErrorMessage = "Invalid email address")]
|
[EmailAddress(ErrorMessage = "Invalid email address")]
|
||||||
public string Email { get; set; } = string.Empty;
|
public string? Email { get; set; }
|
||||||
|
|
||||||
[Required(ErrorMessage = "Username is required")]
|
[Required(ErrorMessage = "Username is required")]
|
||||||
[RegularExpression(@"^[a-zA-Z0-9_-]{3,30}$", ErrorMessage = "Username must be 3-30 characters, letters, numbers, hyphens and underscores only")]
|
[RegularExpression(@"^[a-zA-Z0-9_-]{3,30}$", ErrorMessage = "Username must be 3-30 characters, letters, numbers, hyphens and underscores only")]
|
||||||
@@ -26,11 +25,44 @@ public class DeveloperApplication
|
|||||||
[MinLength(1, ErrorMessage = "Please select at least one platform")]
|
[MinLength(1, ErrorMessage = "Please select at least one platform")]
|
||||||
public List<string> Platforms { get; set; } = new();
|
public List<string> Platforms { get; set; } = new();
|
||||||
|
|
||||||
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 tell us why you want to join")]
|
[Required(ErrorMessage = "Please confirm your password")]
|
||||||
[StringLength(2000, MinimumLength = 20, ErrorMessage = "Motivation must be between 20 and 2000 characters")]
|
[Compare("Password", ErrorMessage = "Passwords do not match")]
|
||||||
public string Motivation { get; set; } = string.Empty;
|
public string ConfirmPassword { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Tester-specific
|
||||||
|
public int? InternetUnderstanding { get; set; }
|
||||||
|
public int? EnjoysTesting { get; set; }
|
||||||
|
|
||||||
|
// Developer-specific
|
||||||
|
public string? ExperienceRange { get; set; }
|
||||||
|
public List<string> SelectedSkills { get; set; } = new();
|
||||||
|
|
||||||
|
// Shared optional
|
||||||
|
public string? AdditionalNotes { get; set; }
|
||||||
|
|
||||||
|
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
if (Role == ApplicationRole.Tester)
|
||||||
|
{
|
||||||
|
if (!InternetUnderstanding.HasValue || InternetUnderstanding < 1 || InternetUnderstanding > 5)
|
||||||
|
yield return new ValidationResult("Please rate your internet understanding", new[] { nameof(InternetUnderstanding) });
|
||||||
|
|
||||||
|
if (!EnjoysTesting.HasValue || EnjoysTesting < 1 || EnjoysTesting > 5)
|
||||||
|
yield return new ValidationResult("Please rate your enthusiasm for testing", new[] { nameof(EnjoysTesting) });
|
||||||
|
}
|
||||||
|
else if (Role == ApplicationRole.Developer)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ExperienceRange))
|
||||||
|
yield return new ValidationResult("Please select your experience level", new[] { nameof(ExperienceRange) });
|
||||||
|
|
||||||
|
if (SelectedSkills.Count == 0)
|
||||||
|
yield return new ValidationResult("Please select at least one skill", new[] { nameof(SelectedSkills) });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ApplicationRole
|
public enum ApplicationRole
|
||||||
|
|||||||
37
BlazorApp/Models/SkillCatalog.cs
Normal file
37
BlazorApp/Models/SkillCatalog.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
namespace SilverLabs.Website.Models;
|
||||||
|
|
||||||
|
public static class SkillCatalog
|
||||||
|
{
|
||||||
|
public static readonly string[] ExperienceRanges =
|
||||||
|
{
|
||||||
|
"< 1 year",
|
||||||
|
"1-3 years",
|
||||||
|
"3-5 years",
|
||||||
|
"5-10 years",
|
||||||
|
"10+ years"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly Dictionary<string, string[]> SkillCategories = new()
|
||||||
|
{
|
||||||
|
["Languages"] = new[]
|
||||||
|
{
|
||||||
|
"C#", "Python", "JavaScript", "TypeScript", "Go", "Rust",
|
||||||
|
"Java", "C/C++", "PHP", "Ruby", "Swift", "Kotlin"
|
||||||
|
},
|
||||||
|
["Frameworks"] = new[]
|
||||||
|
{
|
||||||
|
".NET/Blazor", "React", "Angular", "Vue", "Django",
|
||||||
|
"Node.js", "Next.js", "Svelte", "Spring Boot", "Flask"
|
||||||
|
},
|
||||||
|
["Infrastructure"] = new[]
|
||||||
|
{
|
||||||
|
"Docker", "Kubernetes", "Linux", "Nginx", "Terraform",
|
||||||
|
"CI/CD", "AWS", "Azure", "Proxmox"
|
||||||
|
},
|
||||||
|
["Databases"] = new[]
|
||||||
|
{
|
||||||
|
"PostgreSQL", "MySQL", "SQLite", "MongoDB", "Redis",
|
||||||
|
"SQL Server", "Elasticsearch"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,15 @@ builder.Services.AddHttpClient<DeveloperApplicationService>(client =>
|
|||||||
client.DefaultRequestHeaders.Add("X-API-Key", apiKey);
|
client.DefaultRequestHeaders.Add("X-API-Key", apiKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// HttpClient for DeveloperTicketParsingService (fetches tickets from SilverDESK)
|
||||||
|
builder.Services.AddHttpClient<DeveloperTicketParsingService>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(builder.Configuration["SilverDesk:BaseUrl"] ?? "https://silverdesk.silverlabs.uk");
|
||||||
|
var apiKey = builder.Configuration["SilverDesk:ApiKey"];
|
||||||
|
if (!string.IsNullOrEmpty(apiKey))
|
||||||
|
client.DefaultRequestHeaders.Add("X-API-Key", apiKey);
|
||||||
|
});
|
||||||
|
|
||||||
// Named HttpClients for provisioning
|
// Named HttpClients for provisioning
|
||||||
builder.Services.AddHttpClient("SilverDesk", client =>
|
builder.Services.AddHttpClient("SilverDesk", client =>
|
||||||
{
|
{
|
||||||
@@ -42,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();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using SilverLabs.Website.Models;
|
using SilverLabs.Website.Models;
|
||||||
@@ -15,13 +17,68 @@ public class DeveloperApplicationService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(bool Success, string Message)> SubmitApplicationAsync(DeveloperApplication application)
|
/// <summary>
|
||||||
|
/// Checks username availability. Returns: true = available, false = taken, null = error/unknown.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool?> CheckUsernameAsync(string username)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ticketBody = FormatTicketBody(application);
|
var response = await _httpClient.GetAsync($"/api/auth/check-username/{Uri.EscapeDataString(username)}");
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Username check returned {StatusCode} for {Username}", response.StatusCode, username);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var payload = new
|
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
if (result.TryGetProperty("available", out var available))
|
||||||
|
return available.GetBoolean();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error checking username availability for {Username}", username);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, string Message, string? Token)> SubmitApplicationAsync(DeveloperApplication application)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Use silverlabs.uk address when no personal email provided
|
||||||
|
var effectiveEmail = string.IsNullOrWhiteSpace(application.Email)
|
||||||
|
? $"{application.DesiredUsername}@silverlabs.uk"
|
||||||
|
: application.Email.Trim();
|
||||||
|
|
||||||
|
// 1. Register user on SilverDESK
|
||||||
|
var registerPayload = new
|
||||||
|
{
|
||||||
|
username = application.DesiredUsername,
|
||||||
|
email = effectiveEmail,
|
||||||
|
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}",
|
Subject = $"[Developer Program] {application.Role} Application - {application.FullName}",
|
||||||
Description = ticketBody,
|
Description = ticketBody,
|
||||||
@@ -29,51 +86,199 @@ public class DeveloperApplicationService
|
|||||||
Category = "Developer Program"
|
Category = "Developer Program"
|
||||||
};
|
};
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(payload);
|
// Use a fresh HttpClient without the X-API-Key default header so that
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
// SilverDESK's MultiAuth policy routes to Bearer/JWT auth (the new user's token)
|
||||||
|
// instead of ApiKey auth (which resolves to the MCP system user).
|
||||||
|
using var userClient = new HttpClient { BaseAddress = _httpClient.BaseAddress };
|
||||||
|
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 userClient.SendAsync(ticketRequest);
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (!ticketResponse.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Developer application submitted for {Email} as {Role}", application.Email, application.Role);
|
var errorBody = await ticketResponse.Content.ReadAsStringAsync();
|
||||||
return (true, "Application submitted successfully! We'll review it and get back to you soon.");
|
_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();
|
// 3. Create DeveloperApplication record linking user + ticket
|
||||||
_logger.LogError("Failed to create ticket: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
try
|
||||||
return (false, "Something went wrong submitting your application. Please try again later.");
|
{
|
||||||
|
var userId = authResult.GetProperty("user").GetProperty("id").GetString();
|
||||||
|
var ticketResult = await ticketResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var ticketId = ticketResult.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
var applicationPayload = new
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
ticketId,
|
||||||
|
fullName = application.FullName,
|
||||||
|
email = effectiveEmail,
|
||||||
|
desiredUsername = application.DesiredUsername,
|
||||||
|
timezone = application.Timezone,
|
||||||
|
appliedRole = application.Role.ToString(),
|
||||||
|
platforms = application.Platforms,
|
||||||
|
skills = SerializeAssessment(application),
|
||||||
|
motivation = GenerateMotivationSummary(application),
|
||||||
|
status = 0, // Pending
|
||||||
|
silverDeskProvisioned = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var appResponse = await _httpClient.PostAsJsonAsync("/api/developer-program/applications", applicationPayload);
|
||||||
|
|
||||||
|
if (appResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("DeveloperApplication record created for {Email}", effectiveEmail);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var appError = await appResponse.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogWarning("Failed to create DeveloperApplication record for {Email}: {StatusCode} - {Body}",
|
||||||
|
effectiveEmail, appResponse.StatusCode, appError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to create DeveloperApplication record for {Email} — user and ticket were created successfully",
|
||||||
|
effectiveEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Developer application submitted for {Email} as {Role} — user registered and ticket created",
|
||||||
|
effectiveEmail, application.Role);
|
||||||
|
|
||||||
|
return (true, "Application submitted successfully! Your SilverDESK account has been created.", token);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error submitting developer application for {Email}", application.Email);
|
_logger.LogError(ex, "Error submitting developer application for {Username}", application.DesiredUsername);
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes structured assessment data as JSON for the Skills column.
|
||||||
|
/// </summary>
|
||||||
|
internal static string SerializeAssessment(DeveloperApplication app)
|
||||||
|
{
|
||||||
|
object data;
|
||||||
|
|
||||||
|
if (app.Role == ApplicationRole.Tester)
|
||||||
|
{
|
||||||
|
data = new
|
||||||
|
{
|
||||||
|
type = "tester",
|
||||||
|
internetUnderstanding = app.InternetUnderstanding ?? 0,
|
||||||
|
enjoysTesting = app.EnjoysTesting ?? 0,
|
||||||
|
additionalNotes = app.AdditionalNotes ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
data = new
|
||||||
|
{
|
||||||
|
type = "developer",
|
||||||
|
experienceRange = app.ExperienceRange ?? "",
|
||||||
|
selectedSkills = app.SelectedSkills,
|
||||||
|
additionalNotes = app.AdditionalNotes ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(data, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a human-readable summary for the Motivation field (backward compatibility).
|
||||||
|
/// </summary>
|
||||||
|
internal static string GenerateMotivationSummary(DeveloperApplication app)
|
||||||
|
{
|
||||||
|
if (app.Role == ApplicationRole.Tester)
|
||||||
|
{
|
||||||
|
var summary = $"Internet understanding: {app.InternetUnderstanding}/5, Testing enthusiasm: {app.EnjoysTesting}/5";
|
||||||
|
if (!string.IsNullOrWhiteSpace(app.AdditionalNotes))
|
||||||
|
summary += $". Notes: {app.AdditionalNotes.Trim()}";
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var skills = app.SelectedSkills.Count > 0
|
||||||
|
? string.Join(", ", app.SelectedSkills)
|
||||||
|
: "None selected";
|
||||||
|
var summary = $"{app.ExperienceRange} experience. Skills: {skills}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(app.AdditionalNotes))
|
||||||
|
summary += $". Notes: {app.AdditionalNotes.Trim()}";
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
private static string FormatTicketBody(DeveloperApplication app)
|
||||||
{
|
{
|
||||||
|
var effectiveEmail = string.IsNullOrWhiteSpace(app.Email)
|
||||||
|
? $"{app.DesiredUsername}@silverlabs.uk"
|
||||||
|
: app.Email.Trim();
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("## Developer Program Application");
|
sb.AppendLine("## Developer Program Application");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine($"**Role:** {app.Role}");
|
sb.AppendLine($"**Role:** {app.Role}");
|
||||||
sb.AppendLine($"**Full Name:** {app.FullName}");
|
sb.AppendLine($"**Full Name:** {app.FullName}");
|
||||||
sb.AppendLine($"**Email:** {app.Email}");
|
sb.AppendLine($"**Email:** {effectiveEmail}");
|
||||||
sb.AppendLine($"**Desired Username:** {app.DesiredUsername}");
|
sb.AppendLine($"**Desired Username:** {app.DesiredUsername}");
|
||||||
sb.AppendLine($"**Timezone:** {app.Timezone}");
|
sb.AppendLine($"**Timezone:** {app.Timezone}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine($"**Platforms:** {string.Join(", ", app.Platforms)}");
|
sb.AppendLine($"**Platforms:** {string.Join(", ", app.Platforms)}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|
||||||
if (app.Role == ApplicationRole.Developer && !string.IsNullOrWhiteSpace(app.Skills))
|
if (app.Role == ApplicationRole.Tester)
|
||||||
{
|
{
|
||||||
sb.AppendLine("**Skills & Experience:**");
|
sb.AppendLine("### Assessment");
|
||||||
sb.AppendLine(app.Skills);
|
sb.AppendLine($"- Internet understanding: {"*".PadLeft(app.InternetUnderstanding ?? 0, '*')}{new string('-', 5 - (app.InternetUnderstanding ?? 0))} ({app.InternetUnderstanding}/5)");
|
||||||
|
sb.AppendLine($"- Testing enthusiasm: {"*".PadLeft(app.EnjoysTesting ?? 0, '*')}{new string('-', 5 - (app.EnjoysTesting ?? 0))} ({app.EnjoysTesting}/5)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.AppendLine("### Skills & Experience");
|
||||||
|
sb.AppendLine($"**Experience:** {app.ExperienceRange}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
if (app.SelectedSkills.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"**Technologies:** {string.Join(", ", app.SelectedSkills)}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.AppendLine("**Motivation:**");
|
if (!string.IsNullOrWhiteSpace(app.AdditionalNotes))
|
||||||
sb.AppendLine(app.Motivation);
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("### Additional Notes");
|
||||||
|
sb.AppendLine(app.AdditionalNotes.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
53
BlazorApp/Services/DeveloperTicketParsingService.cs
Normal file
53
BlazorApp/Services/DeveloperTicketParsingService.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace SilverLabs.Website.Services;
|
||||||
|
|
||||||
|
public class DeveloperTicketParsingService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ILogger<DeveloperTicketParsingService> _logger;
|
||||||
|
|
||||||
|
public DeveloperTicketParsingService(HttpClient httpClient, ILogger<DeveloperTicketParsingService> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JsonElement?> FetchTicketAsync(string ticketId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync($"/api/tickets/{ticketId}");
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to fetch ticket {TicketId}: {Status}", ticketId, response.StatusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
return JsonSerializer.Deserialize<JsonElement>(json);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching ticket {TicketId}", ticketId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public (string? FullName, string? Email, string? DesiredUsername, string? Role) ParseApplicationFromDescription(string description)
|
||||||
|
{
|
||||||
|
var fullName = ExtractField(description, @"\*\*Full Name:\*\*\s*(.+)");
|
||||||
|
var email = ExtractField(description, @"\*\*Email:\*\*\s*(.+)");
|
||||||
|
var desiredUsername = ExtractField(description, @"\*\*Desired Username:\*\*\s*(.+)");
|
||||||
|
var role = ExtractField(description, @"\*\*Role:\*\*\s*(.+)");
|
||||||
|
|
||||||
|
return (fullName, email, desiredUsername, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractField(string text, string pattern)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(text, pattern);
|
||||||
|
return match.Success ? match.Groups[1].Value.Trim() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,58 +1,293 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
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,
|
||||||
|
string? Role,
|
||||||
|
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 ---
|
||||||
int ticketId, string username, string email, string fullName)
|
|
||||||
|
public PendingDeployment CreatePendingDeployment(string username, string email, string fullName, string ticketId, string? role = null)
|
||||||
|
{
|
||||||
|
CleanupExpiredTokens();
|
||||||
|
|
||||||
|
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||||
|
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||||
|
|
||||||
|
var deployment = new PendingDeployment(
|
||||||
|
token, username, email, fullName, ticketId, role,
|
||||||
|
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, string? role = null)
|
||||||
{
|
{
|
||||||
var results = new List<string>();
|
var results = new List<string>();
|
||||||
var allSuccess = true;
|
var allSuccess = true;
|
||||||
|
|
||||||
// 1. Create SilverDESK user
|
// 1. Create Mattermost user
|
||||||
var (deskOk, deskMsg) = await CreateSilverDeskUserAsync(username, email, fullName);
|
var (mmOk, mmMsg) = await CreateMattermostUserAsync(username, email, fullName, password);
|
||||||
results.Add($"SilverDESK: {deskMsg}");
|
|
||||||
if (!deskOk) allSuccess = false;
|
|
||||||
|
|
||||||
// 2. Create Mattermost user
|
|
||||||
var (mmOk, mmMsg) = await CreateMattermostUserAsync(username, email, fullName);
|
|
||||||
results.Add($"Mattermost: {mmMsg}");
|
results.Add($"Mattermost: {mmMsg}");
|
||||||
if (!mmOk) allSuccess = false;
|
if (!mmOk) allSuccess = false;
|
||||||
|
|
||||||
// 3. Create Mailcow mailbox
|
// 1b. Add to SilverLABS team (only if user was created)
|
||||||
var (mailOk, mailMsg) = await CreateMailcowMailboxAsync(username, fullName);
|
if (mmOk)
|
||||||
|
{
|
||||||
|
var (teamOk, teamMsg) = await AddMattermostUserToTeamAsync(username);
|
||||||
|
results.Add($"Mattermost Team: {teamMsg}");
|
||||||
|
if (!teamOk) allSuccess = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create Mailcow mailbox
|
||||||
|
var (mailOk, mailMsg) = await CreateMailcowMailboxAsync(username, fullName, password);
|
||||||
results.Add($"Mailcow: {mailMsg}");
|
results.Add($"Mailcow: {mailMsg}");
|
||||||
if (!mailOk) allSuccess = false;
|
if (!mailOk) allSuccess = false;
|
||||||
|
|
||||||
// 4. Update SilverDESK ticket
|
// 3. Create Gitea user (Developers only)
|
||||||
if (allSuccess)
|
var giteaOk = false;
|
||||||
|
if (string.Equals(role, "Developer", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await UpdateTicketStatusAsync(ticketId, "approved", string.Join("\n", results));
|
var (gOk, giteaMsg) = await CreateGiteaUserAsync(username, email, fullName, password);
|
||||||
|
giteaOk = gOk;
|
||||||
|
results.Add($"Gitea: {giteaMsg}");
|
||||||
|
if (!giteaOk) allSuccess = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
results.Add("Gitea: Skipped (not required for Tester role)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
var summary = string.Join("; ", results);
|
var summary = string.Join("; ", results);
|
||||||
_logger.LogInformation("Provisioning for {Username}: {Summary}", username, summary);
|
_logger.LogInformation("Provisioning for {Username} (ticket {TicketId}): {Summary}", username, ticketId, summary);
|
||||||
|
|
||||||
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)
|
||||||
|
{
|
||||||
|
// Normalize username to lowercase - Mattermost and Gitea store usernames as lowercase
|
||||||
|
// and their API lookups are case-sensitive
|
||||||
|
var normalizedUsername = username.ToLowerInvariant();
|
||||||
|
var results = new List<string>();
|
||||||
|
var allSuccess = true;
|
||||||
|
|
||||||
|
// 1. Mattermost - need to look up user ID first
|
||||||
|
var (mmOk, mmMsg) = await UpdateMattermostPasswordAsync(normalizedUsername, newPassword);
|
||||||
|
results.Add($"Mattermost: {mmMsg}");
|
||||||
|
if (!mmOk) allSuccess = false;
|
||||||
|
|
||||||
|
// 2. Mailcow
|
||||||
|
var (mailOk, mailMsg) = await UpdateMailcowPasswordAsync(normalizedUsername, newPassword);
|
||||||
|
results.Add($"Mailcow: {mailMsg}");
|
||||||
|
if (!mailOk) allSuccess = false;
|
||||||
|
|
||||||
|
// 3. Gitea
|
||||||
|
var (giteaOk, giteaMsg) = await UpdateGiteaPasswordAsync(normalizedUsername, newPassword);
|
||||||
|
results.Add($"Gitea: {giteaMsg}");
|
||||||
|
if (!giteaOk) allSuccess = false;
|
||||||
|
|
||||||
|
var summary = string.Join("; ", results);
|
||||||
|
_logger.LogInformation("Password sync for {Username} (normalized: {NormalizedUsername}): {Summary}", username, normalizedUsername, summary);
|
||||||
|
|
||||||
|
return (allSuccess, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Application status update ---
|
||||||
|
|
||||||
|
private async Task<(bool Success, string Message)> UpdateApplicationStatusAsync(
|
||||||
|
string ticketId, bool mattermostProvisioned, bool mailcowProvisioned, bool giteaProvisioned = false)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("SilverDesk");
|
||||||
|
|
||||||
|
var lookupResponse = await client.GetAsync($"/api/developer-program/applications?ticketId={ticketId}");
|
||||||
|
if (!lookupResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await lookupResponse.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogWarning("Failed to look up application by ticket {TicketId}: {Status} {Body}",
|
||||||
|
ticketId, lookupResponse.StatusCode, body);
|
||||||
|
return (false, $"Lookup failed ({lookupResponse.StatusCode})");
|
||||||
|
}
|
||||||
|
|
||||||
|
var apps = await lookupResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
if (apps.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No application found for ticket {TicketId}", ticketId);
|
||||||
|
return (false, "No application found for ticket");
|
||||||
|
}
|
||||||
|
|
||||||
|
var appId = apps[0].GetProperty("id").GetString();
|
||||||
|
|
||||||
|
var updatePayload = new
|
||||||
|
{
|
||||||
|
status = 1, // ApplicationStatus.Approved
|
||||||
|
mattermostProvisioned,
|
||||||
|
mailcowProvisioned,
|
||||||
|
giteaProvisioned
|
||||||
|
};
|
||||||
|
|
||||||
|
var updateResponse = await client.PutAsJsonAsync($"/api/developer-program/applications/{appId}", updatePayload);
|
||||||
|
if (updateResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Application {AppId} updated to Approved for ticket {TicketId}", appId, ticketId);
|
||||||
|
return (true, "Updated to Approved");
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateBody = await updateResponse.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogWarning("Failed to update application {AppId}: {Status} {Body}",
|
||||||
|
appId, updateResponse.StatusCode, updateBody);
|
||||||
|
return (false, $"Update failed ({updateResponse.StatusCode})");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error updating application status for ticket {TicketId}", ticketId);
|
||||||
|
return (false, $"Error: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient("SilverDesk");
|
var client = _httpClientFactory.CreateClient("SilverDesk");
|
||||||
var payload = new { username, email, name = fullName, role = "user" };
|
var payload = new
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
fullName,
|
||||||
|
password = Guid.NewGuid().ToString("N")[..16] + "!A1"
|
||||||
|
};
|
||||||
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");
|
||||||
|
|
||||||
@@ -71,7 +306,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
|
||||||
{
|
{
|
||||||
@@ -83,7 +319,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");
|
||||||
@@ -103,7 +339,41 @@ public class ProvisioningService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(bool Success, string Message)> CreateMailcowMailboxAsync(string username, string fullName)
|
private async Task<(bool Success, string Message)> AddMattermostUserToTeamAsync(string username)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
return (false, $"User lookup failed ({userResponse.StatusCode})");
|
||||||
|
|
||||||
|
var userData = await userResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var userId = userData.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
// Add to SilverLABS team
|
||||||
|
var teamId = _configuration["Mattermost:TeamId"] ?? "ear83bc7nprzpe878ey7hxza7h";
|
||||||
|
var payload = new { team_id = teamId, user_id = userId };
|
||||||
|
var response = await client.PostAsJsonAsync($"/api/v4/teams/{teamId}/members", payload);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return (true, "Added to team");
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogError("Mattermost team join failed: {Status} {Body}", response.StatusCode, body);
|
||||||
|
return (false, $"Team join failed ({response.StatusCode})");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Mattermost team join error for {Username}", username);
|
||||||
|
return (false, $"Error: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool Success, string Message)> CreateMailcowMailboxAsync(
|
||||||
|
string username, string fullName, string password)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -113,11 +383,11 @@ public class ProvisioningService
|
|||||||
local_part = username,
|
local_part = username,
|
||||||
domain = "silverlabs.uk",
|
domain = "silverlabs.uk",
|
||||||
name = fullName,
|
name = fullName,
|
||||||
password = Guid.NewGuid().ToString("N")[..16] + "!A1", // Temporary password
|
password,
|
||||||
password2 = "",
|
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");
|
||||||
@@ -137,20 +407,144 @@ public class ProvisioningService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task UpdateTicketStatusAsync(int ticketId, string status, string note)
|
private async Task<(bool Success, string Message)> CreateGiteaUserAsync(
|
||||||
|
string username, string email, string fullName, string password)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient("SilverDesk");
|
var client = _httpClientFactory.CreateClient("Gitea");
|
||||||
var payload = new { status, note = $"Application approved. Provisioning results:\n{note}" };
|
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 json = JsonSerializer.Serialize(payload);
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
await client.PutAsync($"/api/tickets/{ticketId}", content);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to update ticket {TicketId} status", ticketId);
|
_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)
|
||||||
|
{
|
||||||
|
var lookupBody = await userResponse.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogWarning("Mattermost user lookup failed for {Username}: {Status} {Body}",
|
||||||
|
username, userResponse.StatusCode, lookupBody);
|
||||||
|
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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,17 @@
|
|||||||
},
|
},
|
||||||
"Mattermost": {
|
"Mattermost": {
|
||||||
"BaseUrl": "https://ops.silverlined.uk",
|
"BaseUrl": "https://ops.silverlined.uk",
|
||||||
"ApiToken": "ktmfkpxz7ffr5g1imuqg8hm58c"
|
"ApiToken": "ktmfkpxz7ffr5g1imuqg8hm58c",
|
||||||
|
"TeamId": "ear83bc7nprzpe878ey7hxza7h"
|
||||||
},
|
},
|
||||||
"Mailcow": {
|
"Mailcow": {
|
||||||
"BaseUrl": "https://mail.silverlined.uk",
|
"BaseUrl": "https://mail.silverlined.uk",
|
||||||
"ApiKey": ""
|
"ApiKey": "2A21AA-47E4E5-46DD62-A650F0-BC7566"
|
||||||
},
|
},
|
||||||
"AdminApiKey": ""
|
"Gitea": {
|
||||||
|
"BaseUrl": "https://git.silverlabs.uk",
|
||||||
|
"ApiToken": "70ec152b27ee12d8a2cfb7241df5735351df72cd"
|
||||||
|
},
|
||||||
|
"SiteBaseUrl": "https://silverlabs.uk",
|
||||||
|
"AdminApiKey": "aawb2MHblbfmqdhcS7Xp2/ibQOUbUE1BDoqdJOu0bjM="
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,33 @@
|
|||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Username status */
|
||||||
|
.username-status {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-checking {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-available {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-taken {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-error {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-format-error {
|
||||||
|
color: #fb923c;
|
||||||
|
}
|
||||||
|
|
||||||
/* Validation messages */
|
/* Validation messages */
|
||||||
.validation-message {
|
.validation-message {
|
||||||
color: #f87171;
|
color: #f87171;
|
||||||
@@ -385,6 +412,119 @@
|
|||||||
color: #00B8D4;
|
color: #00B8D4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Star Rating */
|
||||||
|
.star-rating {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-rating-star {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
transition: color 0.15s ease, transform 0.15s ease;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-rating-star:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-rating-star.star-filled {
|
||||||
|
color: #4DD0E1;
|
||||||
|
text-shadow: 0 0 8px rgba(77, 208, 225, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
max-width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Experience Selector */
|
||||||
|
.experience-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exp-btn {
|
||||||
|
padding: 0.45rem 1.1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 100px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exp-btn:hover {
|
||||||
|
border-color: rgba(77, 208, 225, 0.4);
|
||||||
|
background: rgba(77, 208, 225, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exp-btn.exp-active {
|
||||||
|
background: rgba(77, 208, 225, 0.12);
|
||||||
|
border-color: #4DD0E1;
|
||||||
|
color: #4DD0E1;
|
||||||
|
box-shadow: 0 0 12px rgba(77, 208, 225, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skill Bubbles */
|
||||||
|
.skill-category-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-category-label:first-of-type {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-bubbles {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-bubble {
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 100px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-bubble:hover {
|
||||||
|
border-color: rgba(77, 208, 225, 0.35);
|
||||||
|
background: rgba(77, 208, 225, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-bubble.skill-active {
|
||||||
|
background: rgba(77, 208, 225, 0.12);
|
||||||
|
border-color: #4DD0E1;
|
||||||
|
color: #4DD0E1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dev-header h1 {
|
.dev-header h1 {
|
||||||
|
|||||||
Reference in New Issue
Block a user