Compare commits

..

18 Commits

Author SHA1 Message Date
af971b7b83 fix(sync): normalize username to lowercase for Mattermost and Gitea password sync
All checks were successful
Build and Deploy / deploy (push) Successful in 42s
Mattermost and Gitea store usernames as lowercase but SilverDESK passes
the original case (e.g. "Merlin" instead of "merlin"), causing 404/400
errors on case-sensitive API lookups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:32:11 +00:00
4a087a4f24 fix(sync): log Mattermost user lookup response body on failure
All checks were successful
Build and Deploy / deploy (push) Successful in 43s
Adds response body to the warning log when Mattermost user lookup fails,
making it easier to diagnose token/permission issues from logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:03:02 +00:00
502d48da99 app update
All checks were successful
Build and Deploy / deploy (push) Successful in 42s
2026-02-24 14:48:02 +00:00
cd2994d7eb fix(developers): add Mattermost team membership and role-aware Gitea provisioning
All checks were successful
Build and Deploy / deploy (push) Successful in 18s
New users are now added to the SilverLABS Mattermost team after account
creation. Gitea provisioning is skipped for Testers (only Developers get
repo access). Role is parsed from ticket description and threaded through
the entire approval/confirmation flow. Gitea API token is now configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:10:45 +00:00
dc9a60a7a2 feat(developers): simplify timezone dropdown and make email optional
All checks were successful
Build and Deploy / deploy (push) Successful in 42s
Replace 100+ raw system timezones with curated list of 26 major zones
with browser auto-detection via Intl API. Remove email requirement since
applicants receive a @silverlabs.uk address — fallback to username@silverlabs.uk
when no personal email is provided.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:55:08 +00:00
44e3ad94e0 feat(developers): add service URLs and onboarding guide to provisioning reply and success page
All checks were successful
Build and Deploy / deploy (push) Successful in 41s
Ticket replies now include full onboarding info (webmail, IMAP/SMTP, Mattermost, Gitea, SilverDESK URLs) instead of raw provisioning status. Confirmation success page uses clickable service links with email client config details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:57:51 +00:00
296c7fefc5 fix(developers): use fresh HttpClient for ticket creation to authenticate as applicant
All checks were successful
Build and Deploy / deploy (push) Successful in 41s
The typed HttpClient has X-API-Key set as a default header, which caused
SilverDESK's MultiAuth policy to route to ApiKey auth instead of Bearer/JWT.
This made tickets owned by the MCP system user instead of the applicant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:07:21 +00:00
9cbbd2d4f2 feat(developers): add password-synced provisioning and deployment confirmation flow
All checks were successful
Build and Deploy / deploy (push) Successful in 41s
Replace random unrecoverable passwords with a confirmation-based flow:
admin approval generates a secure token and sends a ticket reply with a
confirmation link; the developer clicks the link, enters their SilverDESK
password, and all services (Mattermost, Mailcow, Gitea) are provisioned
with that password. Adds password sync endpoint for SilverDESK resets and
updates the post-signup success panel to redirect to SilverDESK login with
the username pre-populated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:16:13 +00:00
c4febd7036 fix(developers): fix application record creation and approval flow
All checks were successful
Build and Deploy / deploy (push) Successful in 18s
Fix two bugs preventing developer applications from appearing in SilverDESK:

1. Application creation payload used wrong types - ticketId was parsed as
   int (GetInt32) but SilverDESK expects a Guid string, and appliedRole
   was cast to int but the DTO expects "Tester"/"Developer" strings.

2. Approval provisioning now updates the DeveloperApplication record in
   SilverDESK after Mattermost/Mailcow provisioning, setting status to
   Approved and the correct provisioning flags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:40:42 +00:00
324ce141d0 feat(developers): add timezone dropdown and rework skills section
All checks were successful
Build and Deploy / deploy (push) Successful in 16s
Replace free-text timezone input with a dropdown populated from system
timezones. Replace "Why SilverLabs?" motivation section with a
skills-focused "What You Bring" section that collects what candidates
can contribute to the team, with role-specific placeholders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:44:04 +00:00
e5eacd8725 fix(developers): check username on blur instead of keystroke to avoid rate limiting
All checks were successful
Build and Deploy / deploy (push) Successful in 20s
SilverDESK rate-limits /api/auth/check-username after ~2 requests with a
5-minute cooldown. The old 500ms debounce per keystroke quickly exhausted
this limit, breaking the form. Now checks only on field blur, validates
format client-side while typing, and caches results to skip redundant calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:10:04 +00:00
33b21959d8 fix(developers): distinguish API errors from taken usernames in availability check
All checks were successful
Build and Deploy / deploy (push) Successful in 40s
CheckUsernameAsync returned false (taken) on any API failure, making every
username appear taken when SilverDESK was unreachable. Now returns nullable
bool so errors show a warning instead of blocking submission.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:00:46 +00:00
a8d827eace feat(developers): create DeveloperApplication record on signup
All checks were successful
Build and Deploy / deploy (push) Successful in 42s
After registering the user and creating the ticket, call the
SilverDESK developer-program API to create a proper application
record linking the user and ticket. This ensures applications
appear in the /developer-program/applications dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:03:32 +00:00
d0785e04e1 feat(developers): overhaul signup to auto-register SilverDESK accounts
All checks were successful
Build and Deploy / deploy (push) Successful in 41s
Users now pick a password and get a SilverDESK account immediately on
submit. The form includes debounced username availability checking,
password fields with validation, and a post-submit link to SilverDESK.
The approval flow no longer creates a SilverDESK user (already exists)
and only provisions Mattermost + Mailcow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 11:03:16 +00:00
a4d2e571d5 fix(developers): return 200 on partial provisioning failure
All checks were successful
Build and Deploy / deploy (push) Successful in 17s
The approval endpoint now always returns 200 when provisioning was
attempted, with success/failure details in the response body. This
allows the SilverDESK webhook step to proceed with remaining actions
(note, reply, status change) even when individual services fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:43:49 +00:00
008ca7f65d fix(developers): configure Mailcow API key and fix password2 field
All checks were successful
Build and Deploy / deploy (push) Successful in 18s
Add the Mailcow read/write API key so mailbox provisioning actually
authenticates. Also set password2 to match password as required by
the Mailcow API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:49:36 +00:00
587467321d fix(developers): correct SilverDESK provisioning payload fields
All checks were successful
Build and Deploy / deploy (push) Successful in 41s
The CreateUserDto expects `fullName` and `password` but the payload
was sending `name` (wrong property name) and omitting password
entirely, causing 400 BadRequest validation errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:49:37 +00:00
ed5d14989a feat(developers): fix approval webhook flow and add ticket parsing service
All checks were successful
Build and Deploy / deploy (push) Successful in 16s
Change approve endpoint from int to string ticketId to match SilverDESK
GUIDs. Remove body parameter requirement so the endpoint works as a
webhook target. Add DeveloperTicketParsingService to fetch and parse
applicant details from ticket descriptions. Remove redundant ticket
status update from ProvisioningService since SilverDESK action engine
now handles SetStatus/AddNote/AddReply steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:10:46 +00:00
11 changed files with 1728 additions and 94 deletions

View 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);
}

View File

@@ -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,8 +29,12 @@
</div>
<h2>Application Submitted</h2>
<p>@_resultMessage</p>
<p class="dev-account-note">Your SilverDESK account has been created. Log in with the password you just chose to track your application.</p>
<div class="dev-success-actions">
<a href="https://silverdesk.silverlabs.uk/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">330 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">&#10003; Username is available</span>
}
else if (_usernameCheckState == UsernameCheckState.Taken)
{
<span class="username-status username-taken">&#10007; Username is already taken</span>
}
else if (_usernameCheckState == UsernameCheckState.Error)
{
<span class="username-status username-error">&#9888; 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>
}
<!-- Motivation -->
else
{
<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>
<h2 class="dev-section-title">Your Skills</h2>
<p class="dev-section-desc">Select your experience level and the technologies you work with.</p>
<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">
<InputTextArea id="motivation" @bind-Value="_application.Motivation" class="form-input form-textarea"
placeholder="Tell us what motivates you..." rows="5" />
<ValidationMessage For="() => _application.Motivation" />
<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>
}
<!-- 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)
{

View File

@@ -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);

View File

@@ -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

View 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"
}
};
}

View File

@@ -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();

View File

@@ -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.LogError(ex, "Error submitting developer application for {Email}", application.Email);
return (false, "Unable to connect to the application service. Please try again later.");
_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 {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();
}

View 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;
}
}

View File

@@ -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}");
}
}
}

View File

@@ -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="
}

View File

@@ -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 {