Compare commits

...

28 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
a8b7cc2ffd chore: migrate CI/CD from GitLab to Gitea Actions
All checks were successful
Build and Deploy / deploy (push) Successful in 19s
Replace .gitlab-ci.yml with .gitea/workflows/deploy.yml for build and
deploy pipeline. Deploy target updated to 10.0.0.247.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:59:11 +00:00
21c07adf54 feat(developers): add developer program signup with SilverDESK integration
Add developer application page with form submission that creates tickets
in SilverDESK. Includes provisioning service scaffolding for Mattermost,
Mailcow, and Gitea account creation. Fixes API key header casing
(X-API-Key) and ticket payload to match SilverDESK's CreateTicketDto
contract.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:51:36 +00:00
d2780e9295 feat: Update SDK page to use single NuGet templates package
Changed from two separate tar.gz download links to a single NuGet package:
- Updated Quick Start to use 'dotnet new install SilverLabs.SilverSHELL.Templates'
- Replaced dual download cards with single templates package card
- Link now points to https://nuget.silverlabs.uk/packages/silverlabs.silvershell.templates/
- Simplified installation process for users

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 21:28:35 +01:00
62dc7bd93f fix: Change Dockerfile to run ASP.NET Core instead of static nginx
The Blazor Web App requires the ASP.NET runtime to function properly,
as it uses interactive server components. Changed from nginx serving
static files to running the full .NET application.

Changes:
- Updated Dockerfile to use aspnet:9.0 runtime image
- Removed nginx configuration (no longer needed)
- Disabled HTTPS redirection (running behind reverse proxy)
- Added publish/ folder to .gitignore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 19:28:01 +01:00
3aa191cd3d feat: Convert website from static HTML to .NET 9.0 Blazor Web App
Major architectural upgrade from static HTML site to modern Blazor Web App:

- Migrated to .NET 9.0 Blazor Web App framework
- Converted home page with 4 gateway cards (Help Desk, App Store, Cloud, SDK)
- Added new SDK card linking to comprehensive SDK documentation
- Converted SDK documentation page to Blazor component
- Updated template download links to nuget.silverlabs.uk repository
- Implemented multi-stage Docker build with .NET SDK 9.0
- Created Blazor-optimized nginx configuration
- Preserved all original styling and animations
- Added .gitignore for Blazor build artifacts

Technical changes:
- New BlazorApp/ project structure with Components architecture
- MainLayout simplified (no default navigation)
- CSS ported to wwwroot (styles.css + sdk-styles.css)
- Multi-stage Dockerfile: Build with dotnet SDK, serve with nginx
- GitLab CI/CD pipeline compatible (auto-detects new Dockerfile)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 19:11:53 +01:00
a7fd925187 feat: Update templates to use wildcard NuGet versions
Updated both templates to always fetch the latest package versions:
- SilverSHELL packages now use Version="*"
- Microsoft .NET 9 packages use Version="9.*"

This ensures users always get the latest features and fixes without
needing to manually update template versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-19 23:20:45 +01:00
ce0f5a3a20 fix: Update starter template with MSBuild-compatible XML directives
Updated starter template package with fixed .csproj file that wraps
template preprocessor directives in XML comments for MSBuild/Rider
compatibility.

This resolves the error: "The element #text> beneath element is unrecognized"

Users can now open and work with the template in IDEs without errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-19 23:11:16 +01:00
a160fb835a fix: SDK page not displaying content
Removed script.js include that was causing errors (no loading screen
on SDK page) and added 'visible' class directly to main-content div
so content displays immediately without JavaScript dependency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-19 22:37:00 +01:00
7ed6f13a30 fix: Reduce aggressive browser caching for static assets
Changed cache expiry from 1 year to 1 hour and removed immutable flag
to allow browser cache updates. The 1-year cache was preventing logo
updates from being visible without clearing browser cache.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-19 22:24:17 +01:00
add81d6d4a fix: Correct CI/CD deployment script variable expansion
Fixed the deployment script to properly expand variables in the SSH
session by removing single quotes from the heredoc delimiter. Also
added error handling with 'set -e' and removed dependency on
/opt/silverlabs/website directory that doesn't exist.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-19 22:20:46 +01:00
86 changed files with 63416 additions and 755 deletions

View File

@@ -0,0 +1,34 @@
name: Build and Deploy
on:
push:
branches: [master]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t silverlabs-website:latest -t silverlabs-website:${{ github.sha }} .
- name: Deploy to server
run: |
# Install SSH tools
apt-get update && apt-get install -y sshpass
# Save and transfer image
docker save silverlabs-website:latest | gzip > /tmp/silverlabs-website.tar.gz
sshpass -p "${{ secrets.DEPLOY_PASSWORD }}" scp -o StrictHostKeyChecking=no /tmp/silverlabs-website.tar.gz sysadmin@10.0.0.247:/tmp/
# SSH and deploy
sshpass -p "${{ secrets.DEPLOY_PASSWORD }}" ssh -o StrictHostKeyChecking=no sysadmin@10.0.0.247 bash -s << 'EOF'
set -e
docker load < /tmp/silverlabs-website.tar.gz
docker stop silverlabs-website 2>/dev/null || true
docker rm silverlabs-website 2>/dev/null || true
docker run -d \
--name silverlabs-website \
--restart unless-stopped \
-p 8100:80 \
silverlabs-website:latest
docker ps | grep silverlabs-website
rm /tmp/silverlabs-website.tar.gz
echo "Deployment complete!"
EOF

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# .NET Build artifacts
BlazorApp/bin/
BlazorApp/obj/
BlazorApp/.vs/
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Rr]elease/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
# Visual Studio cache/options directory
.vs/
# Rider
.idea/
# Backup folder
old-static-site/
# Publish output
publish/
# OS Files
.DS_Store
Thumbs.db

View File

@@ -1,74 +0,0 @@
stages:
- build
- deploy
variables:
DOCKER_IMAGE: silverlabs-website
# Use Docker image
image: docker:latest
# Build Docker image
build:website:
stage: build
services:
- docker:dind
script:
- echo "Building SilverLabs Website Docker image..."
- docker build -t $DOCKER_IMAGE:latest -t $DOCKER_IMAGE:${CI_COMMIT_SHORT_SHA} .
- docker images
tags:
- docker
only:
- master
- main
# Deploy to production server (PORTAINER-01)
deploy:production:
stage: deploy
image: docker:latest
services:
- docker:dind
dependencies:
- build:website
before_script:
- apk add --no-cache sshpass openssh-client
script:
- echo "🚀 Deploying SilverLabs Website to silverlabs.uk (PORTAINER-01)"
# Build Docker image
- docker build -t $DOCKER_IMAGE:latest -t $DOCKER_IMAGE:${CI_COMMIT_SHORT_SHA} .
# Save and transfer image to server
- docker save $DOCKER_IMAGE:latest | gzip > /tmp/$DOCKER_IMAGE-image.tar.gz
- sshpass -p "Phenom12#." scp -o StrictHostKeyChecking=no /tmp/$DOCKER_IMAGE-image.tar.gz sysadmin@10.0.0.51:/tmp/
# Deploy on server
- |
sshpass -p "Phenom12#." ssh -o StrictHostKeyChecking=no sysadmin@10.0.0.51 << 'ENDSSH'
cd /opt/silverlabs/website
docker load < /tmp/$DOCKER_IMAGE-image.tar.gz
# Stop and remove old container
docker stop silverlabs-website 2>/dev/null || true
docker rm silverlabs-website 2>/dev/null || true
# Start new container
docker run -d \
--name silverlabs-website \
--restart unless-stopped \
-p 8100:80 \
silverlabs-website:latest
docker ps | grep silverlabs-website
rm /tmp/silverlabs-website-image.tar.gz
echo "✅ Deployment complete!"
ENDSSH
environment:
name: production
url: https://silverlabs.uk
only:
- master
- main
tags:
- docker

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="@Assets["styles.css"]" />
<link rel="stylesheet" href="@Assets["sdk-styles.css"]" />
<link rel="stylesheet" href="@Assets["developers-styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
@inherits LayoutComponentBase
@Body
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View File

@@ -0,0 +1,98 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,30 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">SilverLabs.Website</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="weather">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
</NavLink>
</div>
</nav>
</div>

View File

@@ -0,0 +1,105 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -0,0 +1,19 @@
@page "/counter"
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}

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

@@ -0,0 +1,492 @@
@page "/developers"
@using SilverLabs.Website.Models
@using SilverLabs.Website.Services
@inject DeveloperApplicationService ApplicationService
@inject IJSRuntime JS
@rendermode InteractiveServer
<PageTitle>Join the Team - 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>Join the SilverLabs Team</h1>
<p class="dev-subtitle">Help us build privacy-first infrastructure. Whether you test our products or write code, there's a place for you.</p>
</div>
@if (_submitted)
{
<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>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
{
<EditForm Model="_application" OnValidSubmit="HandleSubmit" FormName="developer-application">
<DataAnnotationsValidator />
<!-- Role Selector -->
<div class="dev-section">
<h2 class="dev-section-title">Choose Your Role</h2>
<div class="role-selector">
<div class="role-card @(_application.Role == ApplicationRole.Tester ? "role-active" : "")"
@onclick="() => SelectRole(ApplicationRole.Tester)">
<div class="role-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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
</div>
<h3>Product Tester</h3>
<p>Test our apps across devices, find bugs, and provide feedback that shapes our products.</p>
</div>
<div class="role-card @(_application.Role == ApplicationRole.Developer ? "role-active" : "")"
@onclick="() => SelectRole(ApplicationRole.Developer)">
<div class="role-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">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
</div>
<h3>Developer</h3>
<p>Contribute code, build modules, and help architect privacy-first solutions.</p>
</div>
</div>
<ValidationMessage For="() => _application.Role" />
</div>
<!-- Personal Details -->
<div class="dev-section">
<h2 class="dev-section-title">About You</h2>
<div class="form-grid">
<div class="form-group">
<label for="fullName">Full Name</label>
<InputText id="fullName" @bind-Value="_application.FullName" class="form-input" placeholder="Jane Doe" />
<ValidationMessage For="() => _application.FullName" />
</div>
<div class="form-group">
<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>
<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">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>
<p class="dev-section-desc">Which platforms do you use or have access to?</p>
<div class="platform-grid">
@foreach (var platform in _availablePlatforms)
{
var isChecked = _application.Platforms.Contains(platform);
<label class="platform-chip @(isChecked ? "platform-active" : "")">
<input type="checkbox" checked="@isChecked"
@onchange="() => TogglePlatform(platform)" />
<span>@platform</span>
</label>
}
</div>
<ValidationMessage For="() => _application.Platforms" />
</div>
<!-- Role-Specific Assessment -->
@if (_application.Role == ApplicationRole.Tester)
{
<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">
<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>
<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>
}
<!-- What You Get -->
<div class="dev-section dev-perks">
<h2 class="dev-section-title">What You'll Get</h2>
<div class="perks-grid">
<div class="perk-item">
<strong>@@@("username")@@silverlabs.uk</strong>
<span>Your own SilverLabs email</span>
</div>
<div class="perk-item">
<strong>SilverDESK</strong>
<span>Project management & issue tracking</span>
</div>
<div class="perk-item">
<strong>Mattermost</strong>
<span>Team chat & collaboration</span>
</div>
<div class="perk-item">
<strong>Gitea Access</strong>
<span>Source code repositories</span>
</div>
</div>
</div>
<!-- Submit -->
<div class="dev-submit-area">
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="dev-error">@_errorMessage</div>
}
<button type="submit" class="dev-btn dev-btn-primary" disabled="@IsSubmitDisabled">
@if (_submitting)
{
<span class="btn-spinner"></span>
<span>Submitting...</span>
}
else
{
<span>Submit Application</span>
}
</button>
</div>
</EditForm>
}
<a href="/" class="back-link">← Back to SilverLabs Home</a>
</div>
</div>
@code {
private DeveloperApplication _application = new() { Role = ApplicationRole.Tester };
private bool _submitting;
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;
}
private void TogglePlatform(string platform)
{
if (_application.Platforms.Contains(platform))
_application.Platforms.Remove(platform);
else
_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;
_submitting = true;
try
{
var (success, message, token) = await ApplicationService.SubmitApplicationAsync(_application);
if (success)
{
_resultMessage = message;
_submitted = true;
}
else
{
_errorMessage = message;
}
}
catch
{
_errorMessage = "An unexpected error occurred. Please try again later.";
}
finally
{
_submitting = false;
}
}
}

View File

@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -0,0 +1,79 @@
@page "/"
<PageTitle>SilverLabs - Innovation Gateway</PageTitle>
<div class="main-content visible">
<header class="header">
<img src="logo.png" alt="SilverLabs Logo" class="logo">
</header>
<main class="main">
<h1 class="title">Welcome to SilverLabs</h1>
<p class="subtitle">Your Innovation Gateway</p>
<div class="gateway-grid">
<a href="https://silverdesk.silverlabs.uk" class="gateway-card">
<div class="card-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">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<h2 class="card-title">Help Desk</h2>
<p class="card-description">Support & Assistance</p>
</a>
<a href="https://appstore.silverlabs.uk" class="gateway-card">
<div class="card-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">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</div>
<h2 class="card-title">App Store</h2>
<p class="card-description">Applications & Tools</p>
</a>
<a href="https://cloud.silverlabs.uk" class="gateway-card">
<div class="card-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="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path>
</svg>
</div>
<h2 class="card-title">Cloud</h2>
<p class="card-description">Storage & Collaboration</p>
</a>
<a href="/sdk" class="gateway-card">
<div class="card-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">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
</div>
<h2 class="card-title">SDK</h2>
<p class="card-description">Developer Resources</p>
</a>
<a href="/developers" class="gateway-card">
<div class="card-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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
</div>
<h2 class="card-title">Developers</h2>
<p class="card-description">Join the Team</p>
</a>
</div>
</main>
<footer class="footer">
<p>&copy; 2025 SilverLabs. All rights reserved.</p>
</footer>
</div>

View File

@@ -0,0 +1,237 @@
@page "/sdk"
<PageTitle>SilverSHELL SDK - SilverLabs</PageTitle>
<div class="main-content visible">
<header class="header">
<img src="../logo.png" alt="SilverLabs Logo" class="logo">
</header>
<div class="sdk-container">
<div class="sdk-header">
<h1>SilverSHELL SDK</h1>
<p style="font-size: 1.2rem; color: rgba(255, 255, 255, 0.7);">Build modular Blazor WebAssembly applications with ease</p>
</div>
<!-- Quick Start Section -->
<div class="sdk-section">
<h2>🚀 Quick Start</h2>
<p>Get started with SilverSHELL in just a few minutes. Follow these simple steps:</p>
<ol class="steps-list">
<li>
<strong>Install the templates</strong> from NuGet
<div class="code-block"><code>dotnet new install SilverLabs.SilverSHELL.Templates</code></div>
</li>
<li>
<strong>Create your first project</strong>
<div class="code-block"><code>dotnet new silvershell-starter -n MyApp --pwa true --module-repository true
cd MyApp
dotnet run</code></div>
</li>
<li>
<strong>Access your application</strong> at <code>https://localhost:5001</code>
</li>
</ol>
</div>
<!-- Downloads Section -->
<div class="sdk-section">
<h2>📦 Templates Package</h2>
<p>Install the SilverSHELL templates package from NuGet:</p>
<div class="download-grid">
<div class="download-card">
<h3>SilverSHELL Templates</h3>
<p>Complete package with both Starter and Module templates</p>
<p><strong>Package ID:</strong> SilverLabs.SilverSHELL.Templates</p>
<p><strong>Includes:</strong> Blazor WebAssembly starter template, module template with CI/CD, PWA support, module repository integration</p>
<div class="code-block"><code>dotnet new install SilverLabs.SilverSHELL.Templates</code></div>
<a href="https://nuget.silverlabs.uk/packages/silverlabs.silvershell.templates/" class="download-btn" target="_blank">
View on NuGet
</a>
</div>
</div>
</div>
<!-- Creating Applications Section -->
<div class="sdk-section">
<h2>🏗️ Creating Applications</h2>
<h3>Using the Starter Template</h3>
<div class="code-block"><code># Create a new application
dotnet new silvershell-starter -n MyAwesomeApp
# With PWA support
dotnet new silvershell-starter -n MyAwesomeApp --pwa true
# With Module Repository integration
dotnet new silvershell-starter -n MyAwesomeApp --module-repository true</code></div>
<h3>Adding Modules</h3>
<p>SilverSHELL supports multiple ways to add modules to your application:</p>
<h3>Option 1: Configuration File</h3>
<p>Edit <code>wwwroot/appsettings.json</code>:</p>
<div class="code-block"><code>{
"AMS": {
"Deployment": {
"PreloadModules": [
"SilverLabs.SilverSHELL.Auth.Login",
"SilverSHELL.Modules.ModuleBrowser"
]
}
}
}</code></div>
<h3>Option 2: Module Browser UI</h3>
<ol>
<li>Navigate to <code>/modules/browse</code> in your application</li>
<li>Search for modules from <strong>library.silverlabs.uk</strong></li>
<li>Click "Install" on any module</li>
<li>Modules are downloaded and installed automatically</li>
</ol>
<h3>Option 3: Manual Installation</h3>
<div class="code-block"><code># Copy module DLLs to the modules directory
cp SomeModule.dll wwwroot/modules/
dotnet run
# Module is automatically discovered and loaded!</code></div>
</div>
<!-- Creating Modules Section -->
<div class="sdk-section">
<h2>🔧 Creating Modules</h2>
<h3>Using the Module Template</h3>
<div class="code-block"><code># Create a basic module
dotnet new silvershell-module -n MyModule
# Create a module with widgets
dotnet new silvershell-module -n MyModule --includeWidgets true
# Create a module with search provider
dotnet new silvershell-module -n MyModule --includeSearchProvider true
# Create a module with everything
dotnet new silvershell-module -n MyModule \
--includeWidgets true \
--includeSearchProvider true \
--includeTests true</code></div>
<h3>Module Structure</h3>
<p>The template creates an organized structure:</p>
<div class="code-block"><code>MyModule/
├── Configuration/
│ ├── ModuleMetadata.cs # Module identity and version
│ ├── EndpointConfiguration.cs # Navigation routes
│ └── WidgetConfiguration.cs # Dashboard widgets
├── Pages/
│ └── Index.razor # Razor pages
├── Components/
│ └── ... # Razor components
├── .gitlab-ci.yml # GitLab CI/CD
├── .github/workflows/
│ └── publish.yml # GitHub Actions
└── MyModuleMain.cs # Module entry point</code></div>
</div>
<!-- Publishing Modules Section -->
<div class="sdk-section">
<h2>🚀 Publishing Modules</h2>
<div class="info-box">
<strong>CI/CD Included!</strong> The module template includes ready-to-use CI/CD pipelines for both GitLab and GitHub.
</div>
<h3>Automated Publishing (Recommended)</h3>
<p>The templates include CI/CD pipelines for automatic publishing:</p>
<ol class="steps-list">
<li>
<strong>Configure CI/CD variables</strong>
<div class="code-block"><code># In GitLab: Settings > CI/CD > Variables
# In GitHub: Settings > Secrets > Actions
# Add variable:
MODULE_REPO_TOKEN: [your token from library.silverlabs.uk]</code></div>
</li>
<li>
<strong>Commit and push your code</strong>
<div class="code-block"><code>git add .
git commit -m "feat: Initial module implementation"
git push</code></div>
</li>
<li>
<strong>Create a release tag</strong>
<div class="code-block"><code>git tag v1.0.0
git push --tags</code></div>
</li>
<li>
<strong>Trigger publish</strong> from your CI/CD pipeline UI
</li>
</ol>
<h3>Manual Publishing</h3>
<div class="code-block"><code># Build and package
dotnet pack --configuration Release -o dist/
# Upload to repository
curl -X POST "https://library.silverlabs.uk/api/modules/publish" \
-F "id=MyModule" \
-F "name=My Awesome Module" \
-F "version=1.0.0" \
-F "description=A great module" \
-F "author=Your Name" \
-F "package=@@dist/MyModule.1.0.0.nupkg"</code></div>
</div>
<!-- Available Modules Section -->
<div class="sdk-section">
<h2>📚 Available Modules</h2>
<p>Browse and install modules from the SilverSHELL module repository:</p>
<div class="info-box">
<strong>Module Repository:</strong> <a href="https://library.silverlabs.uk" target="_blank" style="color: #a78bfa;">library.silverlabs.uk</a>
</div>
<h3>Featured Modules:</h3>
<ul>
<li><strong>Auth.Login</strong> - User authentication and login UI</li>
<li><strong>Auth.Registration</strong> - User registration system</li>
<li><strong>Auth.UserManagement</strong> - User administration</li>
<li><strong>Auth.MyAccount</strong> - User profile management</li>
<li><strong>ModuleBrowser</strong> - Browse and install modules from the UI</li>
</ul>
<h3>Explore All Modules</h3>
<div class="code-block"><code># List all available modules via API
curl https://library.silverlabs.uk/api/modules
# Search for specific modules
curl https://library.silverlabs.uk/api/modules/search?q=auth</code></div>
</div>
<!-- Resources Section -->
<div class="sdk-section">
<h2>📖 Resources</h2>
<ul>
<li><strong>Module Repository API:</strong> <a href="https://library.silverlabs.uk/api/modules" target="_blank" style="color: #a78bfa;">https://library.silverlabs.uk/api/modules</a></li>
<li><strong>Demo Application:</strong> <a href="https://demo.silverlabs.uk" target="_blank" style="color: #a78bfa;">https://demo.silverlabs.uk</a></li>
<li><strong>GitLab Repository:</strong> <a href="https://gitlab.silverlabs.uk/silverlabs/silvershell" target="_blank" style="color: #a78bfa;">GitLab</a></li>
</ul>
</div>
<!-- Support Section -->
<div class="sdk-section">
<h2>💬 Support</h2>
<p>Need help? We're here for you:</p>
<ul>
<li><strong>Help Desk:</strong> <a href="https://silverdesk.silverlabs.uk" target="_blank" style="color: #a78bfa;">silverdesk.silverlabs.uk</a></li>
<li><strong>Issues:</strong> <a href="https://gitlab.silverlabs.uk/silverlabs/silvershell/-/issues" target="_blank" style="color: #a78bfa;">GitLab Issues</a></li>
</ul>
</div>
<a href="/" class="back-link">← Back to SilverLabs Home</a>
</div>
</div>

View File

@@ -0,0 +1,64 @@
@page "/weather"
@attribute [StreamRendering]
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates showing data.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Farenheit">Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
// Simulate asynchronous loading to demonstrate streaming rendering
await Task.Delay(500);
var startDate = DateOnly.FromDateTime(DateTime.Now);
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
}
private class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}

View File

@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

View File

@@ -0,0 +1,10 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using SilverLabs.Website
@using SilverLabs.Website.Components

View File

@@ -0,0 +1,188 @@
using SilverLabs.Website.Models;
using SilverLabs.Website.Services;
namespace SilverLabs.Website.Endpoints;
public static class DeveloperEndpoints
{
public static void MapDeveloperEndpoints(this WebApplication app)
{
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, token) = await service.SubmitApplicationAsync(application);
return success
? Results.Ok(new { message, token })
: Results.Problem(message, statusCode: 502);
});
group.MapPost("/approve/{ticketId}", async (
string ticketId,
DeveloperTicketParsingService ticketService,
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 ticket = await ticketService.FetchTicketAsync(ticketId);
if (ticket is null)
return Results.Problem("Failed to fetch ticket from SilverDESK", 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 ConfirmDeploymentRequest(string Token, string Password);
public record SyncPasswordRequest(string Username, string NewPassword);

View File

@@ -0,0 +1,72 @@
using System.ComponentModel.DataAnnotations;
namespace SilverLabs.Website.Models;
public class DeveloperApplication : IValidatableObject
{
[Required(ErrorMessage = "Full name is required")]
[StringLength(100, MinimumLength = 2)]
public string FullName { get; set; } = string.Empty;
[EmailAddress(ErrorMessage = "Invalid email address")]
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")]
public string DesiredUsername { get; set; } = string.Empty;
[Required(ErrorMessage = "Timezone is required")]
public string Timezone { get; set; } = string.Empty;
[Required(ErrorMessage = "Please select a role")]
public ApplicationRole Role { get; set; }
[Required(ErrorMessage = "Please select at least one platform")]
[MinLength(1, ErrorMessage = "Please select at least one platform")]
public List<string> Platforms { get; set; } = new();
[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 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
{
Tester,
Developer
}

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

84
BlazorApp/Program.cs Normal file
View File

@@ -0,0 +1,84 @@
using SilverLabs.Website.Components;
using SilverLabs.Website.Endpoints;
using SilverLabs.Website.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// HttpClient for SilverDESK (used by DeveloperApplicationService directly)
builder.Services.AddHttpClient<DeveloperApplicationService>(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);
});
// 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 =>
{
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);
});
builder.Services.AddHttpClient("Mattermost", client =>
{
client.BaseAddress = new Uri(builder.Configuration["Mattermost:BaseUrl"] ?? "https://ops.silverlined.uk");
var token = builder.Configuration["Mattermost:ApiToken"];
if (!string.IsNullOrEmpty(token))
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
});
builder.Services.AddHttpClient("Mailcow", client =>
{
client.BaseAddress = new Uri(builder.Configuration["Mailcow:BaseUrl"] ?? "https://mail.silverlined.uk");
var apiKey = builder.Configuration["Mailcow:ApiKey"];
if (!string.IsNullOrEmpty(apiKey))
client.DefaultRequestHeaders.Add("X-API-Key", apiKey);
});
builder.Services.AddHttpClient("Gitea", client =>
{
client.BaseAddress = new Uri(builder.Configuration["Gitea:BaseUrl"] ?? "https://git.silverlabs.uk");
var token = builder.Configuration["Gitea:ApiToken"];
if (!string.IsNullOrEmpty(token))
client.DefaultRequestHeaders.Add("Authorization", $"token {token}");
});
builder.Services.AddScoped<ProvisioningService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
// app.UseHttpsRedirection(); // Disabled - running behind reverse proxy
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapDeveloperEndpoints();
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5072",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7104;http://localhost:5072",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,285 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using SilverLabs.Website.Models;
namespace SilverLabs.Website.Services;
public class DeveloperApplicationService
{
private readonly HttpClient _httpClient;
private readonly ILogger<DeveloperApplicationService> _logger;
public DeveloperApplicationService(HttpClient httpClient, ILogger<DeveloperApplicationService> logger)
{
_httpClient = httpClient;
_logger = logger;
}
/// <summary>
/// Checks username availability. Returns: true = available, false = taken, null = error/unknown.
/// </summary>
public async Task<bool?> CheckUsernameAsync(string username)
{
try
{
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 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,
Priority = "Medium",
Category = "Developer Program"
};
// 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 ticketResponse = await userClient.SendAsync(ticketRequest);
if (!ticketResponse.IsSuccessStatusCode)
{
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);
}
// 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 {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:** {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.Tester)
{
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)}");
}
}
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

@@ -0,0 +1,550 @@
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;
private static readonly ConcurrentDictionary<string, PendingDeployment> _pendingDeployments = new();
public ProvisioningService(
IHttpClientFactory httpClientFactory,
ILogger<ProvisioningService> logger,
IConfiguration configuration)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_configuration = configuration;
}
// --- 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 Mattermost user
var (mmOk, mmMsg) = await CreateMattermostUserAsync(username, email, fullName, password);
results.Add($"Mattermost: {mmMsg}");
if (!mmOk) allSuccess = false;
// 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;
// 3. Create Gitea user (Developers only)
var giteaOk = false;
if (string.Equals(role, "Developer", StringComparison.OrdinalIgnoreCase))
{
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} (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,
fullName,
password = Guid.NewGuid().ToString("N")[..16] + "!A1"
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/users", content);
if (response.IsSuccessStatusCode)
return (true, "User created");
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("SilverDESK user creation failed: {Status} {Body}", response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "SilverDESK user creation error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
private async Task<(bool Success, string Message)> CreateMattermostUserAsync(
string username, string email, string fullName, string password)
{
try
{
var client = _httpClientFactory.CreateClient("Mattermost");
var nameParts = fullName.Split(' ', 2);
var payload = new
{
email,
username,
first_name = nameParts[0],
last_name = nameParts.Length > 1 ? nameParts[1] : "",
password
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v4/users", content);
if (response.IsSuccessStatusCode)
return (true, "User created");
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("Mattermost user creation failed: {Status} {Body}", response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Mattermost user creation error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
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
{
var client = _httpClientFactory.CreateClient("Mailcow");
var payload = new
{
local_part = username,
domain = "silverlabs.uk",
name = fullName,
password,
password2 = password,
quota = 1024, // 1GB
active = 1,
force_pw_update = 0
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/add/mailbox", content);
if (response.IsSuccessStatusCode)
return (true, "Mailbox created");
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("Mailcow mailbox creation failed: {Status} {Body}", response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Mailcow mailbox creation error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
private async Task<(bool Success, string Message)> CreateGiteaUserAsync(
string username, string email, string fullName, string password)
{
try
{
var client = _httpClientFactory.CreateClient("Gitea");
var payload = new
{
email,
full_name = fullName,
login_name = username,
must_change_password = false,
password,
send_notify = false,
username,
visibility = "public"
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/admin/users", content);
if (response.IsSuccessStatusCode)
return (true, "User created");
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("Gitea user creation failed: {Status} {Body}", response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Gitea user creation error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
// --- Password update methods ---
private async Task<(bool Success, string Message)> UpdateMattermostPasswordAsync(string username, string newPassword)
{
try
{
var client = _httpClientFactory.CreateClient("Mattermost");
// Look up user ID by username
var userResponse = await client.GetAsync($"/api/v4/users/username/{username}");
if (!userResponse.IsSuccessStatusCode)
{
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

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,28 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"SilverDesk": {
"BaseUrl": "https://silverdesk.silverlabs.uk",
"ApiKey": "silverdesk-mcp-2025-secure-key"
},
"Mattermost": {
"BaseUrl": "https://ops.silverlined.uk",
"ApiToken": "ktmfkpxz7ffr5g1imuqg8hm58c",
"TeamId": "ear83bc7nprzpe878ey7hxza7h"
},
"Mailcow": {
"BaseUrl": "https://mail.silverlined.uk",
"ApiKey": "2A21AA-47E4E5-46DD62-A650F0-BC7566"
},
"Gitea": {
"BaseUrl": "https://git.silverlabs.uk",
"ApiToken": "70ec152b27ee12d8a2cfb7241df5735351df72cd"
},
"SiteBaseUrl": "https://silverlabs.uk",
"AdminApiKey": "aawb2MHblbfmqdhcS7Xp2/ibQOUbUE1BDoqdJOu0bjM="
}

60
BlazorApp/wwwroot/app.css Normal file
View File

@@ -0,0 +1,60 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}

View File

@@ -0,0 +1,563 @@
/* Developers Page Styles */
.dev-container {
max-width: 860px;
margin: 0 auto;
padding: 0 2rem 4rem;
}
.dev-header {
text-align: center;
margin-bottom: 3rem;
}
.dev-header h1 {
font-size: 2.8rem;
margin-bottom: 1rem;
background: linear-gradient(135deg, #4DD0E1 0%, #00B8D4 40%, #1E5A9E 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dev-subtitle {
font-size: 1.15rem;
color: rgba(255, 255, 255, 0.7);
max-width: 600px;
margin: 0 auto;
line-height: 1.6;
}
/* Sections */
.dev-section {
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 2rem;
margin-bottom: 1.5rem;
animation: devFadeIn 0.5s ease both;
}
.dev-section:nth-child(2) { animation-delay: 0.1s; }
.dev-section:nth-child(3) { animation-delay: 0.15s; }
.dev-section:nth-child(4) { animation-delay: 0.2s; }
.dev-section:nth-child(5) { animation-delay: 0.25s; }
.dev-section:nth-child(6) { animation-delay: 0.3s; }
@keyframes devFadeIn {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.dev-section-fade-in {
animation: devFadeIn 0.4s ease both !important;
}
.dev-section-title {
font-size: 1.4rem;
margin-bottom: 0.75rem;
color: #4DD0E1;
}
.dev-section-desc {
color: rgba(255, 255, 255, 0.6);
margin-bottom: 1rem;
line-height: 1.5;
}
/* Role Selector */
.role-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.role-card {
background: rgba(255, 255, 255, 0.04);
border: 2px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
padding: 1.5rem;
cursor: pointer;
transition: all 0.25s ease;
text-align: center;
}
.role-card:hover {
border-color: rgba(77, 208, 225, 0.4);
background: rgba(77, 208, 225, 0.06);
}
.role-card.role-active {
border-color: #4DD0E1;
background: rgba(77, 208, 225, 0.1);
box-shadow: 0 0 24px rgba(77, 208, 225, 0.15);
}
.role-icon {
width: 56px;
height: 56px;
margin: 0 auto 0.75rem;
background: rgba(255, 255, 255, 0.08);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
}
.role-active .role-icon {
background: rgba(77, 208, 225, 0.18);
}
.role-icon svg {
width: 28px;
height: 28px;
stroke: rgba(255, 255, 255, 0.6);
transition: stroke 0.25s ease;
}
.role-active .role-icon svg {
stroke: #4DD0E1;
}
.role-card h3 {
font-size: 1.2rem;
margin-bottom: 0.4rem;
color: #fff;
}
.role-card p {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.55);
line-height: 1.4;
}
/* Form Elements */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-size: 0.85rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 0.4rem;
letter-spacing: 0.02em;
}
.form-input {
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
padding: 0.75rem 1rem;
color: #fff;
font-size: 0.95rem;
font-family: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
outline: none;
width: 100%;
}
.form-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.form-input:focus {
border-color: #4DD0E1;
box-shadow: 0 0 0 3px rgba(77, 208, 225, 0.12);
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-hint {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.4);
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;
font-size: 0.8rem;
margin-top: 0.3rem;
}
/* Platform Chips */
.platform-grid {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.platform-chip {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 100px;
cursor: pointer;
transition: all 0.2s ease;
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
user-select: none;
}
.platform-chip input[type="checkbox"] {
display: none;
}
.platform-chip:hover {
border-color: rgba(77, 208, 225, 0.4);
}
.platform-chip.platform-active {
background: rgba(77, 208, 225, 0.12);
border-color: #4DD0E1;
color: #4DD0E1;
}
/* Perks */
.dev-perks {
background: linear-gradient(135deg, rgba(30, 90, 158, 0.15) 0%, rgba(0, 184, 212, 0.08) 100%);
border-color: rgba(77, 208, 225, 0.2);
}
.perks-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.perk-item {
display: flex;
flex-direction: column;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.04);
border-radius: 10px;
}
.perk-item strong {
color: #4DD0E1;
font-size: 0.95rem;
margin-bottom: 0.2rem;
}
.perk-item span {
color: rgba(255, 255, 255, 0.55);
font-size: 0.82rem;
}
/* Submit Area */
.dev-submit-area {
text-align: center;
margin-top: 1.5rem;
margin-bottom: 2rem;
}
.dev-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.85rem 2.5rem;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.25s ease;
border: none;
font-family: inherit;
}
.dev-btn-primary {
background: linear-gradient(135deg, #1E5A9E 0%, #00B8D4 100%);
color: #fff;
box-shadow: 0 4px 20px rgba(0, 184, 212, 0.25);
}
.dev-btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 184, 212, 0.35);
}
.dev-btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.dev-btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dev-btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
.btn-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
/* Error / Success */
.dev-error {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
color: #f87171;
padding: 0.75rem 1rem;
border-radius: 10px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.dev-success-panel {
text-align: center;
padding: 4rem 2rem;
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(12px);
border: 1px solid rgba(77, 208, 225, 0.25);
border-radius: 20px;
margin-bottom: 2rem;
animation: devFadeIn 0.5s ease both;
}
.success-icon {
width: 72px;
height: 72px;
margin: 0 auto 1.5rem;
background: rgba(77, 208, 225, 0.15);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.success-icon svg {
width: 36px;
height: 36px;
stroke: #4DD0E1;
}
.dev-success-panel h2 {
font-size: 1.8rem;
margin-bottom: 0.75rem;
color: #4DD0E1;
}
.dev-success-panel p {
color: rgba(255, 255, 255, 0.7);
margin-bottom: 2rem;
max-width: 500px;
margin-left: auto;
margin-right: auto;
line-height: 1.5;
}
/* Back Link - reuse from sdk-styles */
.dev-container .back-link {
display: inline-block;
margin-top: 1rem;
color: #4DD0E1;
text-decoration: none;
font-size: 1rem;
transition: color 0.2s ease;
}
.dev-container .back-link:hover {
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 {
font-size: 2rem;
}
.role-selector {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: 1fr;
}
.perks-grid {
grid-template-columns: 1fr;
}
.dev-container {
padding: 0 1rem 3rem;
}
.dev-section {
padding: 1.5rem;
}
}
@media (max-width: 480px) {
.dev-header h1 {
font-size: 1.6rem;
}
.dev-subtitle {
font-size: 1rem;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -0,0 +1,170 @@
.sdk-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.sdk-header {
text-align: center;
margin-bottom: 3rem;
}
.sdk-header h1 {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sdk-section {
background: rgba(255, 255, 255, 0.05);
border-radius: 1rem;
padding: 2rem;
margin-bottom: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.sdk-section h2 {
font-size: 2rem;
margin-bottom: 1rem;
color: #667eea;
}
.sdk-section h3 {
font-size: 1.5rem;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
color: #a78bfa;
}
.code-block {
background: rgba(0, 0, 0, 0.3);
border-radius: 0.5rem;
padding: 1rem;
margin: 1rem 0;
overflow-x: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.code-block code {
color: #a5f3fc;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
white-space: pre;
}
.download-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.download-card {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
border-radius: 1rem;
padding: 1.5rem;
border: 1px solid rgba(102, 126, 234, 0.3);
transition: all 0.3s ease;
text-decoration: none;
display: block;
}
.download-card:hover {
transform: translateY(-4px);
border-color: rgba(102, 126, 234, 0.6);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.2);
}
.download-card h3 {
margin: 0 0 0.5rem 0;
color: #fff;
}
.download-card p {
color: rgba(255, 255, 255, 0.7);
margin: 0.5rem 0;
}
.download-btn {
display: inline-block;
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 0.5rem;
color: white;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
}
.download-btn:hover {
transform: scale(1.05);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.steps-list {
counter-reset: step-counter;
list-style: none;
padding-left: 0;
}
.steps-list li {
counter-increment: step-counter;
position: relative;
padding-left: 3rem;
margin-bottom: 1.5rem;
}
.steps-list li::before {
content: counter(step-counter);
position: absolute;
left: 0;
top: 0;
width: 2rem;
height: 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: white;
}
.info-box {
background: rgba(102, 126, 234, 0.1);
border-left: 4px solid #667eea;
padding: 1rem;
margin: 1rem 0;
border-radius: 0.5rem;
}
.back-link {
display: inline-block;
margin-top: 2rem;
color: #667eea;
text-decoration: none;
font-size: 1.1rem;
}
.back-link:hover {
color: #764ba2;
}
.sdk-container ul {
color: rgba(255, 255, 255, 0.8);
line-height: 1.8;
}
.sdk-container p {
color: rgba(255, 255, 255, 0.8);
line-height: 1.8;
}
.sdk-container ol {
color: rgba(255, 255, 255, 0.8);
line-height: 1.8;
}

View File

@@ -1,19 +1,30 @@
FROM nginx:alpine
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy website files to nginx html directory
COPY index.html /usr/share/nginx/html/
COPY styles.css /usr/share/nginx/html/
COPY script.js /usr/share/nginx/html/
COPY logo.png /usr/share/nginx/html/
# Copy project file and restore dependencies
COPY BlazorApp/SilverLabs.Website.csproj BlazorApp/
RUN dotnet restore "BlazorApp/SilverLabs.Website.csproj"
# Copy SDK directory with templates
COPY sdk/ /usr/share/nginx/html/sdk/
# Copy all source files
COPY BlazorApp/ BlazorApp/
# Copy custom nginx configuration
COPY nginx-site.conf /etc/nginx/conf.d/default.conf
# Build and publish the application
WORKDIR /src/BlazorApp
RUN dotnet publish "SilverLabs.Website.csproj" -c Release -o /app/publish
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
# Set environment to production
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:80
# Start the Blazor application
ENTRYPOINT ["dotnet", "SilverLabs.Website.dll"]

View File

@@ -1,73 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SilverLabs - Innovation Gateway</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Loading Screen -->
<div id="loading-screen" class="loading-screen">
<div class="loading-content">
<img src="logo.png" alt="SilverLabs Logo" class="loading-logo">
<div class="loading-spinner"></div>
</div>
</div>
<!-- Main Content -->
<div id="main-content" class="main-content">
<header class="header">
<img src="logo.png" alt="SilverLabs Logo" class="logo">
</header>
<main class="main">
<h1 class="title">Welcome to SilverLabs</h1>
<p class="subtitle">Your Innovation Gateway</p>
<div class="gateway-grid">
<a href="https://helpdesk.silverlabs.uk" class="gateway-card">
<div class="card-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">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<h2 class="card-title">Help Desk</h2>
<p class="card-description">Support & Assistance</p>
</a>
<a href="https://appstore.silverlabs.uk" class="gateway-card">
<div class="card-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">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</div>
<h2 class="card-title">App Store</h2>
<p class="card-description">Applications & Tools</p>
</a>
<a href="https://cloud.silverlabs.uk" class="gateway-card">
<div class="card-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="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path>
</svg>
</div>
<h2 class="card-title">Cloud</h2>
<p class="card-description">Storage & Collaboration</p>
</a>
</div>
</main>
<footer class="footer">
<p>&copy; 2025 SilverLabs. All rights reserved.</p>
</footer>
</div>
<script src="script.js"></script>
</body>
</html>

41
nginx-blazor.conf Normal file
View File

@@ -0,0 +1,41 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_types text/css application/javascript application/json image/jpeg image/png application/wasm;
gzip_min_length 1000;
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|wasm|json)$ {
expires 1h;
add_header Cache-Control "public, max-age=3600";
}
# Blazor framework files - longer cache
location /_framework/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# MIME types for Blazor WebAssembly
types {
application/wasm wasm;
application/octet-stream dll;
application/json json;
}
# Main location - support client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

View File

@@ -1,28 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_types text/css application/javascript image/jpeg image/png;
gzip_min_length 1000;
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Main location
location / {
try_files $uri $uri/index.html /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

139
script.js
View File

@@ -1,139 +0,0 @@
// Loading screen functionality
document.addEventListener('DOMContentLoaded', function() {
const loadingScreen = document.getElementById('loading-screen');
const mainContent = document.getElementById('main-content');
// Minimum loading time (in milliseconds) to show the loading screen
const minLoadingTime = 2000;
const startTime = Date.now();
// Function to hide loading screen and show main content
function hideLoadingScreen() {
const elapsedTime = Date.now() - startTime;
const remainingTime = Math.max(0, minLoadingTime - elapsedTime);
setTimeout(() => {
loadingScreen.classList.add('fade-out');
mainContent.classList.add('visible');
// Remove loading screen from DOM after transition
setTimeout(() => {
loadingScreen.style.display = 'none';
}, 500);
}, remainingTime);
}
// Hide loading screen when page is fully loaded
if (document.readyState === 'complete') {
hideLoadingScreen();
} else {
window.addEventListener('load', hideLoadingScreen);
}
// Add floating particles animation to background
createFloatingParticles();
});
// Create floating particles for background effect
function createFloatingParticles() {
const mainContent = document.getElementById('main-content');
const particleCount = 20;
for (let i = 0; i < particleCount; i++) {
createParticle(mainContent);
}
}
function createParticle(container) {
const particle = document.createElement('div');
particle.className = 'particle';
// Random size between 2px and 6px
const size = Math.random() * 4 + 2;
particle.style.width = `${size}px`;
particle.style.height = `${size}px`;
// Random position
particle.style.left = `${Math.random() * 100}%`;
particle.style.top = `${Math.random() * 100}%`;
// Random animation duration between 10s and 30s
const duration = Math.random() * 20 + 10;
particle.style.animationDuration = `${duration}s`;
// Random delay
const delay = Math.random() * 5;
particle.style.animationDelay = `${delay}s`;
// Random opacity
const opacity = Math.random() * 0.3 + 0.1;
particle.style.opacity = opacity;
// Apply styles
particle.style.position = 'absolute';
particle.style.borderRadius = '50%';
particle.style.background = 'rgba(255, 255, 255, 0.5)';
particle.style.pointerEvents = 'none';
particle.style.zIndex = '0';
particle.style.animation = 'float ' + particle.style.animationDuration + ' ease-in-out infinite';
container.appendChild(particle);
}
// Add CSS animation for particles
const style = document.createElement('style');
style.textContent = `
@keyframes float {
0%, 100% {
transform: translate(0, 0) rotate(0deg);
}
25% {
transform: translate(10px, -20px) rotate(90deg);
}
50% {
transform: translate(-15px, -10px) rotate(180deg);
}
75% {
transform: translate(-10px, 20px) rotate(270deg);
}
}
.particle {
filter: blur(1px);
}
`;
document.head.appendChild(style);
// Add smooth scroll behavior for potential internal links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth'
});
}
});
});
// Add interactive hover effect to cards
document.querySelectorAll('.gateway-card').forEach(card => {
card.addEventListener('mousemove', function(e) {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const rotateX = (y - centerY) / 10;
const rotateY = (centerX - x) / 10;
card.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateY(-10px)`;
});
card.addEventListener('mouseleave', function() {
card.style.transform = '';
});
});

View File

@@ -1,429 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SilverSHELL SDK - SilverLabs</title>
<link rel="stylesheet" href="../styles.css">
<style>
.sdk-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.sdk-header {
text-align: center;
margin-bottom: 3rem;
}
.sdk-header h1 {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sdk-section {
background: rgba(255, 255, 255, 0.05);
border-radius: 1rem;
padding: 2rem;
margin-bottom: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.sdk-section h2 {
font-size: 2rem;
margin-bottom: 1rem;
color: #667eea;
}
.sdk-section h3 {
font-size: 1.5rem;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
color: #a78bfa;
}
.code-block {
background: rgba(0, 0, 0, 0.3);
border-radius: 0.5rem;
padding: 1rem;
margin: 1rem 0;
overflow-x: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.code-block code {
color: #a5f3fc;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
white-space: pre;
}
.download-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.download-card {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
border-radius: 1rem;
padding: 1.5rem;
border: 1px solid rgba(102, 126, 234, 0.3);
transition: all 0.3s ease;
text-decoration: none;
display: block;
}
.download-card:hover {
transform: translateY(-4px);
border-color: rgba(102, 126, 234, 0.6);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.2);
}
.download-card h3 {
margin: 0 0 0.5rem 0;
color: #fff;
}
.download-card p {
color: rgba(255, 255, 255, 0.7);
margin: 0.5rem 0;
}
.download-btn {
display: inline-block;
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 0.5rem;
color: white;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
}
.download-btn:hover {
transform: scale(1.05);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.steps-list {
counter-reset: step-counter;
list-style: none;
padding-left: 0;
}
.steps-list li {
counter-increment: step-counter;
position: relative;
padding-left: 3rem;
margin-bottom: 1.5rem;
}
.steps-list li::before {
content: counter(step-counter);
position: absolute;
left: 0;
top: 0;
width: 2rem;
height: 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: white;
}
.info-box {
background: rgba(102, 126, 234, 0.1);
border-left: 4px solid #667eea;
padding: 1rem;
margin: 1rem 0;
border-radius: 0.5rem;
}
.back-link {
display: inline-block;
margin-top: 2rem;
color: #667eea;
text-decoration: none;
font-size: 1.1rem;
}
.back-link:hover {
color: #764ba2;
}
ul {
color: rgba(255, 255, 255, 0.8);
line-height: 1.8;
}
p {
color: rgba(255, 255, 255, 0.8);
line-height: 1.8;
}
</style>
</head>
<body>
<div id="main-content" class="main-content">
<header class="header">
<img src="../logo.png" alt="SilverLabs Logo" class="logo">
</header>
<div class="sdk-container">
<div class="sdk-header">
<h1>SilverSHELL SDK</h1>
<p style="font-size: 1.2rem; color: rgba(255, 255, 255, 0.7);">Build modular Blazor WebAssembly applications with ease</p>
</div>
<!-- Quick Start Section -->
<div class="sdk-section">
<h2>🚀 Quick Start</h2>
<p>Get started with SilverSHELL in just a few minutes. Follow these simple steps:</p>
<ol class="steps-list">
<li>
<strong>Download the templates</strong> (see downloads below)
</li>
<li>
<strong>Extract the templates</strong> to a directory of your choice
</li>
<li>
<strong>Install the templates</strong>
<div class="code-block"><code>dotnet new install ./path/to/SilverSHELL.Starter.Template
dotnet new install ./path/to/SilverSHELL.AppModule.Template</code></div>
</li>
<li>
<strong>Create your first project</strong>
<div class="code-block"><code>dotnet new silvershell-starter -n MyApp --pwa true --module-repository true
cd MyApp
dotnet run</code></div>
</li>
<li>
<strong>Access your application</strong> at <code>https://localhost:5001</code>
</li>
</ol>
</div>
<!-- Downloads Section -->
<div class="sdk-section">
<h2>📦 Downloads</h2>
<p>Download the SilverSHELL project templates to get started:</p>
<div class="download-grid">
<div class="download-card">
<h3>Starter Template</h3>
<p>Create a new SilverSHELL application with minimal configuration</p>
<p><strong>Size:</strong> 6.3 KB</p>
<p><strong>Includes:</strong> Blazor WebAssembly setup, module loading, PWA support</p>
<a href="downloads/silvershell-starter-template.tar.gz" class="download-btn" download>
Download Starter Template
</a>
</div>
<div class="download-card">
<h3>Module Template</h3>
<p>Create reusable SilverSHELL modules with best practices</p>
<p><strong>Size:</strong> 14 KB</p>
<p><strong>Includes:</strong> Module structure, configuration, CI/CD templates</p>
<a href="downloads/silvershell-module-template.tar.gz" class="download-btn" download>
Download Module Template
</a>
</div>
</div>
</div>
<!-- Creating Applications Section -->
<div class="sdk-section">
<h2>🏗️ Creating Applications</h2>
<h3>Using the Starter Template</h3>
<div class="code-block"><code># Create a new application
dotnet new silvershell-starter -n MyAwesomeApp
# With PWA support
dotnet new silvershell-starter -n MyAwesomeApp --pwa true
# With Module Repository integration
dotnet new silvershell-starter -n MyAwesomeApp --module-repository true</code></div>
<h3>Adding Modules</h3>
<p>SilverSHELL supports multiple ways to add modules to your application:</p>
<h3>Option 1: Configuration File</h3>
<p>Edit <code>wwwroot/appsettings.json</code>:</p>
<div class="code-block"><code>{
"AMS": {
"Deployment": {
"PreloadModules": [
"SilverLabs.SilverSHELL.Auth.Login",
"SilverSHELL.Modules.ModuleBrowser"
]
}
}
}</code></div>
<h3>Option 2: Module Browser UI</h3>
<ol>
<li>Navigate to <code>/modules/browse</code> in your application</li>
<li>Search for modules from <strong>library.silverlabs.uk</strong></li>
<li>Click "Install" on any module</li>
<li>Modules are downloaded and installed automatically</li>
</ol>
<h3>Option 3: Manual Installation</h3>
<div class="code-block"><code># Copy module DLLs to the modules directory
cp SomeModule.dll wwwroot/modules/
dotnet run
# Module is automatically discovered and loaded!</code></div>
</div>
<!-- Creating Modules Section -->
<div class="sdk-section">
<h2>🔧 Creating Modules</h2>
<h3>Using the Module Template</h3>
<div class="code-block"><code># Create a basic module
dotnet new silvershell-module -n MyModule
# Create a module with widgets
dotnet new silvershell-module -n MyModule --includeWidgets true
# Create a module with search provider
dotnet new silvershell-module -n MyModule --includeSearchProvider true
# Create a module with everything
dotnet new silvershell-module -n MyModule \
--includeWidgets true \
--includeSearchProvider true \
--includeTests true</code></div>
<h3>Module Structure</h3>
<p>The template creates an organized structure:</p>
<div class="code-block"><code>MyModule/
├── Configuration/
│ ├── ModuleMetadata.cs # Module identity and version
│ ├── EndpointConfiguration.cs # Navigation routes
│ └── WidgetConfiguration.cs # Dashboard widgets
├── Pages/
│ └── Index.razor # Razor pages
├── Components/
│ └── ... # Razor components
├── .gitlab-ci.yml # GitLab CI/CD
├── .github/workflows/
│ └── publish.yml # GitHub Actions
└── MyModuleMain.cs # Module entry point</code></div>
</div>
<!-- Publishing Modules Section -->
<div class="sdk-section">
<h2>🚀 Publishing Modules</h2>
<div class="info-box">
<strong>CI/CD Included!</strong> The module template includes ready-to-use CI/CD pipelines for both GitLab and GitHub.
</div>
<h3>Automated Publishing (Recommended)</h3>
<p>The templates include CI/CD pipelines for automatic publishing:</p>
<ol class="steps-list">
<li>
<strong>Configure CI/CD variables</strong>
<div class="code-block"><code># In GitLab: Settings > CI/CD > Variables
# In GitHub: Settings > Secrets > Actions
# Add variable:
MODULE_REPO_TOKEN: [your token from library.silverlabs.uk]</code></div>
</li>
<li>
<strong>Commit and push your code</strong>
<div class="code-block"><code>git add .
git commit -m "feat: Initial module implementation"
git push</code></div>
</li>
<li>
<strong>Create a release tag</strong>
<div class="code-block"><code>git tag v1.0.0
git push --tags</code></div>
</li>
<li>
<strong>Trigger publish</strong> from your CI/CD pipeline UI
</li>
</ol>
<h3>Manual Publishing</h3>
<div class="code-block"><code># Build and package
dotnet pack --configuration Release -o dist/
# Upload to repository
curl -X POST "https://library.silverlabs.uk/api/modules/publish" \
-F "id=MyModule" \
-F "name=My Awesome Module" \
-F "version=1.0.0" \
-F "description=A great module" \
-F "author=Your Name" \
-F "package=@dist/MyModule.1.0.0.nupkg"</code></div>
</div>
<!-- Available Modules Section -->
<div class="sdk-section">
<h2>📚 Available Modules</h2>
<p>Browse and install modules from the SilverSHELL module repository:</p>
<div class="info-box">
<strong>Module Repository:</strong> <a href="https://library.silverlabs.uk" target="_blank" style="color: #a78bfa;">library.silverlabs.uk</a>
</div>
<h3>Featured Modules:</h3>
<ul>
<li><strong>Auth.Login</strong> - User authentication and login UI</li>
<li><strong>Auth.Registration</strong> - User registration system</li>
<li><strong>Auth.UserManagement</strong> - User administration</li>
<li><strong>Auth.MyAccount</strong> - User profile management</li>
<li><strong>ModuleBrowser</strong> - Browse and install modules from the UI</li>
</ul>
<h3>Explore All Modules</h3>
<div class="code-block"><code># List all available modules via API
curl https://library.silverlabs.uk/api/modules
# Search for specific modules
curl https://library.silverlabs.uk/api/modules/search?q=auth</code></div>
</div>
<!-- Resources Section -->
<div class="sdk-section">
<h2>📖 Resources</h2>
<ul>
<li><strong>Module Repository API:</strong> <a href="https://library.silverlabs.uk/api/modules" target="_blank" style="color: #a78bfa;">https://library.silverlabs.uk/api/modules</a></li>
<li><strong>Demo Application:</strong> <a href="https://demo.silverlabs.uk" target="_blank" style="color: #a78bfa;">https://demo.silverlabs.uk</a></li>
<li><strong>GitLab Repository:</strong> <a href="https://gitlab.silverlabs.uk/silverlabs/silvershell" target="_blank" style="color: #a78bfa;">GitLab</a></li>
</ul>
</div>
<!-- Support Section -->
<div class="sdk-section">
<h2>💬 Support</h2>
<p>Need help? We're here for you:</p>
<ul>
<li><strong>Help Desk:</strong> <a href="https://helpdesk.silverlabs.uk" target="_blank" style="color: #a78bfa;">helpdesk.silverlabs.uk</a></li>
<li><strong>Issues:</strong> <a href="https://gitlab.silverlabs.uk/silverlabs/silvershell/-/issues" target="_blank" style="color: #a78bfa;">GitLab Issues</a></li>
</ul>
</div>
<a href="/" class="back-link">← Back to SilverLabs Home</a>
</div>
</div>
<script src="../script.js"></script>
</body>
</html>