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.Services
|
||||
@inject DeveloperApplicationService ApplicationService
|
||||
@inject IJSRuntime JS
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Join the Team - SilverLabs</PageTitle>
|
||||
@@ -28,7 +29,11 @@
|
||||
</div>
|
||||
<h2>Application Submitted</h2>
|
||||
<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>
|
||||
}
|
||||
else
|
||||
@@ -81,26 +86,74 @@
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<span class="form-hint">Leave blank to use your @@silverlabs.uk address</span>
|
||||
<ValidationMessage For="() => _application.Email" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Desired Username</label>
|
||||
<InputText id="username" @bind-Value="_application.DesiredUsername" class="form-input" placeholder="janedoe" />
|
||||
<span class="form-hint">This will be your handle across SilverLabs services</span>
|
||||
<input id="username" class="form-input" placeholder="janedoe" value="@_application.DesiredUsername"
|
||||
@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" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="timezone">Location / Timezone</label>
|
||||
<InputText id="timezone" @bind-Value="_application.Timezone" class="form-input" placeholder="e.g. Europe/London, US/Eastern" />
|
||||
<label for="timezone">Timezone</label>
|
||||
<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" />
|
||||
</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 -->
|
||||
<div class="dev-section">
|
||||
<h2 class="dev-section-title">Devices & Platforms</h2>
|
||||
@@ -119,29 +172,101 @@
|
||||
<ValidationMessage For="() => _application.Platforms" />
|
||||
</div>
|
||||
|
||||
<!-- Developer-only: Skills -->
|
||||
@if (_application.Role == ApplicationRole.Developer)
|
||||
<!-- Role-Specific Assessment -->
|
||||
@if (_application.Role == ApplicationRole.Tester)
|
||||
{
|
||||
<div class="dev-section dev-section-fade-in">
|
||||
<h2 class="dev-section-title">Skills & Experience</h2>
|
||||
<p class="dev-section-desc">Tell us about your technical background — languages, frameworks, and any open-source contributions.</p>
|
||||
<div class="dev-section">
|
||||
<h2 class="dev-section-title">About Your Experience</h2>
|
||||
<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">
|
||||
<InputTextArea id="skills" @bind-Value="_application.Skills" class="form-input form-textarea"
|
||||
placeholder="e.g. C#/.NET 5 years, Blazor, PostgreSQL, Docker, contributed to..." rows="5" />
|
||||
<label for="additionalNotes">Anything else you'd like us to know? (optional)</label>
|
||||
<InputTextArea id="additionalNotes" @bind-Value="_application.AdditionalNotes" class="form-input form-textarea"
|
||||
placeholder="Previous testing experience, specific interests, etc." rows="3" />
|
||||
</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="dev-section">
|
||||
<h2 class="dev-section-title">Why SilverLabs?</h2>
|
||||
<p class="dev-section-desc">What draws you to privacy-first development? What do you hope to contribute?</p>
|
||||
<div class="form-group">
|
||||
<InputTextArea id="motivation" @bind-Value="_application.Motivation" class="form-input form-textarea"
|
||||
placeholder="Tell us what motivates you..." rows="5" />
|
||||
<ValidationMessage For="() => _application.Motivation" />
|
||||
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||||
<label>Experience Level</label>
|
||||
<div class="experience-selector">
|
||||
@foreach (var range in SkillCatalog.ExperienceRanges)
|
||||
{
|
||||
<button type="button"
|
||||
class="exp-btn @(_application.ExperienceRange == range ? "exp-active" : "")"
|
||||
@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>
|
||||
}
|
||||
|
||||
<!-- What You Get -->
|
||||
<div class="dev-section dev-perks">
|
||||
@@ -172,7 +297,7 @@
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<span class="btn-spinner"></span>
|
||||
@@ -197,9 +322,73 @@
|
||||
private bool _submitted;
|
||||
private string? _resultMessage;
|
||||
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 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)
|
||||
{
|
||||
_application.Role = role;
|
||||
@@ -213,6 +402,65 @@
|
||||
_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()
|
||||
{
|
||||
_errorMessage = null;
|
||||
@@ -220,7 +468,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
var (success, message) = await ApplicationService.SubmitApplicationAsync(_application);
|
||||
var (success, message, token) = await ApplicationService.SubmitApplicationAsync(_application);
|
||||
|
||||
if (success)
|
||||
{
|
||||
|
||||
@@ -9,18 +9,26 @@ public static class DeveloperEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/developers");
|
||||
|
||||
group.MapGet("/check-username/{username}", async (string username, DeveloperApplicationService service) =>
|
||||
{
|
||||
var available = await service.CheckUsernameAsync(username);
|
||||
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) =>
|
||||
{
|
||||
var (success, message) = await service.SubmitApplicationAsync(application);
|
||||
var (success, message, token) = await service.SubmitApplicationAsync(application);
|
||||
return success
|
||||
? Results.Ok(new { message })
|
||||
? Results.Ok(new { message, token })
|
||||
: Results.Problem(message, statusCode: 502);
|
||||
});
|
||||
|
||||
group.MapPost("/approve/{ticketId:int}", async (
|
||||
int ticketId,
|
||||
ApproveRequest request,
|
||||
ProvisioningService service,
|
||||
group.MapPost("/approve/{ticketId}", async (
|
||||
string ticketId,
|
||||
DeveloperTicketParsingService ticketService,
|
||||
ProvisioningService provisioningService,
|
||||
HttpContext context,
|
||||
IConfiguration config) =>
|
||||
{
|
||||
@@ -30,14 +38,151 @@ public static class DeveloperEndpoints
|
||||
if (string.IsNullOrEmpty(expectedKey) || apiKey != expectedKey)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var (success, message) = await service.ApproveApplicationAsync(
|
||||
ticketId, request.Username, request.Email, request.FullName);
|
||||
var ticket = await ticketService.FetchTicketAsync(ticketId);
|
||||
if (ticket is null)
|
||||
return Results.Problem("Failed to fetch ticket from SilverDESK", statusCode: 502);
|
||||
|
||||
return success
|
||||
? Results.Ok(new { message })
|
||||
: Results.Problem(message, statusCode: 502);
|
||||
var description = ticket.Value.GetProperty("description").GetString() ?? "";
|
||||
var (fullName, email, desiredUsername, role) = ticketService.ParseApplicationFromDescription(description);
|
||||
|
||||
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;
|
||||
|
||||
public class DeveloperApplication
|
||||
public class DeveloperApplication : IValidatableObject
|
||||
{
|
||||
[Required(ErrorMessage = "Full name is required")]
|
||||
[StringLength(100, MinimumLength = 2)]
|
||||
public string FullName { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Email is required")]
|
||||
[EmailAddress(ErrorMessage = "Invalid email address")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string? Email { get; set; }
|
||||
|
||||
[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")]
|
||||
@@ -26,11 +25,44 @@ public class DeveloperApplication
|
||||
[MinLength(1, ErrorMessage = "Please select at least one platform")]
|
||||
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")]
|
||||
[StringLength(2000, MinimumLength = 20, ErrorMessage = "Motivation must be between 20 and 2000 characters")]
|
||||
public string Motivation { get; set; } = string.Empty;
|
||||
[Required(ErrorMessage = "Please confirm your password")]
|
||||
[Compare("Password", ErrorMessage = "Passwords do not match")]
|
||||
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
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// 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
|
||||
builder.Services.AddHttpClient("SilverDesk", client =>
|
||||
{
|
||||
@@ -42,6 +51,14 @@ builder.Services.AddHttpClient("Mailcow", client =>
|
||||
client.DefaultRequestHeaders.Add("X-API-Key", apiKey);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient("Gitea", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(builder.Configuration["Gitea:BaseUrl"] ?? "https://git.silverlabs.uk");
|
||||
var token = builder.Configuration["Gitea:ApiToken"];
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"token {token}");
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<ProvisioningService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SilverLabs.Website.Models;
|
||||
@@ -15,13 +17,68 @@ public class DeveloperApplicationService
|
||||
_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
|
||||
{
|
||||
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}",
|
||||
Description = ticketBody,
|
||||
@@ -29,51 +86,199 @@ public class DeveloperApplicationService
|
||||
Category = "Developer Program"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
// Use a fresh HttpClient without the X-API-Key default header so that
|
||||
// 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);
|
||||
return (true, "Application submitted successfully! We'll review it and get back to you soon.");
|
||||
var errorBody = await ticketResponse.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to create ticket: {StatusCode} - {Body}", ticketResponse.StatusCode, errorBody);
|
||||
// User was created but ticket failed — still return success with a note
|
||||
return (true, "Your account has been created, but we had trouble submitting your application ticket. Please log in to SilverDESK and create a support ticket.", token);
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to create ticket: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return (false, "Something went wrong submitting your application. Please try again later.");
|
||||
// 3. Create DeveloperApplication record linking user + ticket
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
_logger.LogError(ex, "Error submitting developer application for {Email}", application.Email);
|
||||
return (false, "Unable to connect to the application service. Please try again later.");
|
||||
_logger.LogError(ex, "Error submitting developer application for {Username}", application.DesiredUsername);
|
||||
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)
|
||||
{
|
||||
var effectiveEmail = string.IsNullOrWhiteSpace(app.Email)
|
||||
? $"{app.DesiredUsername}@silverlabs.uk"
|
||||
: app.Email.Trim();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("## Developer Program Application");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**Role:** {app.Role}");
|
||||
sb.AppendLine($"**Full Name:** {app.FullName}");
|
||||
sb.AppendLine($"**Email:** {app.Email}");
|
||||
sb.AppendLine($"**Email:** {effectiveEmail}");
|
||||
sb.AppendLine($"**Desired Username:** {app.DesiredUsername}");
|
||||
sb.AppendLine($"**Timezone:** {app.Timezone}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**Platforms:** {string.Join(", ", app.Platforms)}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (app.Role == ApplicationRole.Developer && !string.IsNullOrWhiteSpace(app.Skills))
|
||||
if (app.Role == ApplicationRole.Tester)
|
||||
{
|
||||
sb.AppendLine("**Skills & Experience:**");
|
||||
sb.AppendLine(app.Skills);
|
||||
sb.AppendLine("### Assessment");
|
||||
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();
|
||||
if (app.SelectedSkills.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"**Technologies:** {string.Join(", ", app.SelectedSkills)}");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine("**Motivation:**");
|
||||
sb.AppendLine(app.Motivation);
|
||||
if (!string.IsNullOrWhiteSpace(app.AdditionalNotes))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("### Additional Notes");
|
||||
sb.AppendLine(app.AdditionalNotes.Trim());
|
||||
}
|
||||
|
||||
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.Json;
|
||||
|
||||
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
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<ProvisioningService> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public ProvisioningService(IHttpClientFactory httpClientFactory, ILogger<ProvisioningService> logger)
|
||||
private static readonly ConcurrentDictionary<string, PendingDeployment> _pendingDeployments = new();
|
||||
|
||||
public ProvisioningService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<ProvisioningService> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> ApproveApplicationAsync(
|
||||
int ticketId, string username, string email, string fullName)
|
||||
// --- Token management ---
|
||||
|
||||
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 allSuccess = true;
|
||||
|
||||
// 1. Create SilverDESK user
|
||||
var (deskOk, deskMsg) = await CreateSilverDeskUserAsync(username, email, fullName);
|
||||
results.Add($"SilverDESK: {deskMsg}");
|
||||
if (!deskOk) allSuccess = false;
|
||||
|
||||
// 2. Create Mattermost user
|
||||
var (mmOk, mmMsg) = await CreateMattermostUserAsync(username, email, fullName);
|
||||
// 1. Create Mattermost user
|
||||
var (mmOk, mmMsg) = await CreateMattermostUserAsync(username, email, fullName, password);
|
||||
results.Add($"Mattermost: {mmMsg}");
|
||||
if (!mmOk) allSuccess = false;
|
||||
|
||||
// 3. Create Mailcow mailbox
|
||||
var (mailOk, mailMsg) = await CreateMailcowMailboxAsync(username, fullName);
|
||||
// 1b. Add to SilverLABS team (only if user was created)
|
||||
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}");
|
||||
if (!mailOk) allSuccess = false;
|
||||
|
||||
// 4. Update SilverDESK ticket
|
||||
if (allSuccess)
|
||||
// 3. Create Gitea user (Developers only)
|
||||
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);
|
||||
_logger.LogInformation("Provisioning for {Username}: {Summary}", username, summary);
|
||||
_logger.LogInformation("Provisioning for {Username} (ticket {TicketId}): {Summary}", username, ticketId, 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)
|
||||
{
|
||||
try
|
||||
{
|
||||
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 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
|
||||
{
|
||||
@@ -83,7 +319,7 @@ public class ProvisioningService
|
||||
username,
|
||||
first_name = nameParts[0],
|
||||
last_name = nameParts.Length > 1 ? nameParts[1] : "",
|
||||
password = Guid.NewGuid().ToString("N")[..16] + "!A1" // Temporary password
|
||||
password
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
@@ -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
|
||||
{
|
||||
@@ -113,11 +383,11 @@ public class ProvisioningService
|
||||
local_part = username,
|
||||
domain = "silverlabs.uk",
|
||||
name = fullName,
|
||||
password = Guid.NewGuid().ToString("N")[..16] + "!A1", // Temporary password
|
||||
password2 = "",
|
||||
password,
|
||||
password2 = password,
|
||||
quota = 1024, // 1GB
|
||||
active = 1,
|
||||
force_pw_update = 1
|
||||
force_pw_update = 0
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
@@ -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
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("SilverDesk");
|
||||
var payload = new { status, note = $"Application approved. Provisioning results:\n{note}" };
|
||||
var client = _httpClientFactory.CreateClient("Gitea");
|
||||
var payload = new
|
||||
{
|
||||
email,
|
||||
full_name = fullName,
|
||||
login_name = username,
|
||||
must_change_password = false,
|
||||
password,
|
||||
send_notify = false,
|
||||
username,
|
||||
visibility = "public"
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
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)
|
||||
{
|
||||
_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": {
|
||||
"BaseUrl": "https://ops.silverlined.uk",
|
||||
"ApiToken": "ktmfkpxz7ffr5g1imuqg8hm58c"
|
||||
"ApiToken": "ktmfkpxz7ffr5g1imuqg8hm58c",
|
||||
"TeamId": "ear83bc7nprzpe878ey7hxza7h"
|
||||
},
|
||||
"Mailcow": {
|
||||
"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;
|
||||
}
|
||||
|
||||
/* 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-message {
|
||||
color: #f87171;
|
||||
@@ -385,6 +412,119 @@
|
||||
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 */
|
||||
@media (max-width: 768px) {
|
||||
.dev-header h1 {
|
||||
|
||||
Reference in New Issue
Block a user