first commit ish

This commit is contained in:
Nils Blomgren
2026-06-04 08:51:22 +02:00
parent e0f967482e
commit 658f15d086
320 changed files with 307404 additions and 1187 deletions

View File

@@ -0,0 +1,500 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject VaultService VaultService
@inject TenantService TenantService
@using EntKube.Web.Components.Pages.Shared
@* ═══════════════════════════════════════════════════════════════════
App Secrets — customer portal view for managing vault secrets
scoped to a single app.
Access rules:
Viewer+ — list secret names, reveal values on demand
Admin — add / update / delete secrets, configure K8s sync
═══════════════════════════════════════════════════════════════════ *@
<div class="d-flex align-items-center mb-3">
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
<div>
<h5 class="mb-0">Secrets — @App.Name</h5>
<small class="text-muted">
Application secrets stored with AES-256-GCM envelope encryption.
Values are never transmitted in plain text.
</small>
</div>
</div>
@if (!vaultInitialized)
{
<div class="alert alert-warning py-2 small">
<i class="bi bi-shield-exclamation me-1"></i>
No vault has been set up for this tenant. Ask your platform administrator to initialize it.
</div>
}
else
{
@* ── Add Secret form (Admin only) ── *@
@if (AccessRole >= CustomerAccessRole.Admin)
{
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-plus-circle me-1 text-primary"></i><strong>Add / Update Secret</strong>
</div>
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm"
placeholder="e.g. API_KEY, DB_PASSWORD"
@bind="newSecretName" @bind:event="oninput" />
</div>
<div class="col-md-5">
<label class="form-label small">Value</label>
<input type="password" class="form-control form-control-sm"
placeholder="Secret value"
@bind="newSecretValue" @bind:event="oninput" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary" @onclick="AddSecret"
disabled="@(string.IsNullOrWhiteSpace(newSecretName) || string.IsNullOrWhiteSpace(newSecretValue) || saving)">
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
<i class="bi bi-plus-lg me-1"></i>Set Secret
</button>
</div>
</div>
<div class="form-text">
If a secret with this name already exists it will be updated in place.
</div>
@if (errorMessage is not null)
{
<div class="alert alert-danger py-1 small mt-2 mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
</div>
}
@if (successMessage is not null)
{
<div class="alert alert-success py-1 small mt-2 mb-0">
<i class="bi bi-check-circle me-1"></i>@successMessage
</div>
}
</div>
</div>
}
@* ── Secrets list ── *@
<div class="card shadow-sm">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-lock me-2 text-primary"></i>
<strong>Stored Secrets</strong>
@if (secrets is not null)
{
<span class="badge bg-secondary ms-1">@secrets.Count</span>
}
</div>
@if (AccessRole >= CustomerAccessRole.Admin && secrets?.Any(s => s.SyncToKubernetes) == true)
{
<button class="btn btn-sm btn-outline-success" @onclick="SyncAllToKubernetes" disabled="@syncingSecrets">
@if (syncingSecrets) { <span class="spinner-border spinner-border-sm me-1"></span> }
else { <i class="bi bi-cloud-arrow-up me-1"></i> }
Sync All to K8s
</button>
}
</div>
<div class="card-body p-0">
@if (secrets is null)
{
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (secrets.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No secrets stored for this app yet.</p>
@if (AccessRole >= CustomerAccessRole.Admin)
{
<p class="text-muted small mb-0">Use the form above to add one.</p>
}
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>K8s Sync</th>
<th>K8s Secret</th>
<th>Namespace</th>
<th>Updated</th>
<th class="text-end" style="min-width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (VaultSecret secret in secrets)
{
<tr>
<td><code>@secret.Name</code></td>
<td>
@if (secret.SyncToKubernetes)
{
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Enabled</span>
}
else
{
<span class="badge bg-secondary">Off</span>
}
</td>
<td><small class="text-muted">@(secret.KubernetesSecretName ?? "—")</small></td>
<td><small class="text-muted">@(secret.KubernetesNamespace ?? "—")</small></td>
<td><small class="text-muted">@secret.UpdatedAt.ToString("MMM d, HH:mm")</small></td>
<td class="text-end">
@* Reveal — available to all roles *@
<button class="btn btn-sm btn-outline-secondary me-1"
@onclick="() => RevealSecret(secret)"
title="@(revealedSecretId == secret.Id ? "Hide value" : "Reveal value")">
<i class="bi @(revealedSecretId == secret.Id ? "bi-eye-slash" : "bi-eye")"></i>
</button>
@* Admin-only: edit, K8s sync, delete *@
@if (AccessRole >= CustomerAccessRole.Admin)
{
<button class="btn btn-sm btn-outline-warning me-1"
@onclick="() => StartEdit(secret)" title="Update value">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-info me-1"
@onclick="() => ToggleSync(secret)"
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Configure K8s sync")">
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
</button>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => DeleteSecret(secret.Id)" title="Delete secret">
<i class="bi bi-trash"></i>
</button>
}
</td>
</tr>
@* ── Reveal value row ── *@
@if (revealedSecretId == secret.Id)
{
<tr class="table-light">
<td colspan="6">
<div class="d-flex align-items-center px-2 py-1 gap-2">
<span class="text-muted small">Value:</span>
@if (revealLoading)
{
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
}
else
{
<code class="user-select-all">@revealedValue</code>
}
</div>
</td>
</tr>
}
@* ── Edit value row ── *@
@if (editSecretId == secret.Id)
{
<tr class="table-light">
<td colspan="6">
<div class="row g-2 p-2 align-items-center">
<div class="col-md-6">
<input type="password" class="form-control form-control-sm"
placeholder="New secret value"
@bind="editSecretValue" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-success" @onclick="SaveEdit"
disabled="@(string.IsNullOrWhiteSpace(editSecretValue) || saving)">
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
<i class="bi bi-check-lg me-1"></i>Update
</button>
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="CancelEdit">
Cancel
</button>
</div>
</div>
</td>
</tr>
}
@* ── K8s sync config row ── *@
@if (syncConfigSecretId == secret.Id)
{
<tr class="table-light">
<td colspan="6">
<div class="row g-2 p-2 align-items-center">
@if (clusters is not null)
{
<div class="col-md-3">
<select class="form-select form-select-sm" @bind="syncClusterId">
<option value="@Guid.Empty">Target cluster…</option>
@foreach (KubernetesCluster cluster in clusters)
{
<option value="@cluster.Id">@cluster.Name</option>
}
</select>
</div>
}
<div class="col-md-3">
<input type="text" class="form-control form-control-sm"
placeholder="K8s Secret name (e.g. my-app-secrets)"
@bind="syncSecretName" />
</div>
<div class="col-md-3">
<input type="text" class="form-control form-control-sm"
placeholder="Namespace (e.g. my-app)"
@bind="syncNamespace" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-success" @onclick="SaveSync">
<i class="bi bi-check-lg me-1"></i>Save
</button>
<button class="btn btn-sm btn-outline-secondary ms-1"
@onclick="() => syncConfigSecretId = null">
Cancel
</button>
</div>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
@if (syncOutput is not null)
{
<div class="m-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
}
</div>
</div>
@* ── Docker Registry Credentials ── *@
<div class="mt-4">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-box-seam fs-5 me-2 text-primary"></i>
<h6 class="mb-0">Docker Registry Credentials</h6>
<span class="ms-2 text-muted small">Pull secrets for private container registries</span>
</div>
<DockerRegistriesPanel
TenantId="TenantId"
AppId="App.Id"
IsAdmin="AccessRole >= CustomerAccessRole.Admin" />
</div>
}
@code {
[Parameter] public required Data.App App { get; set; }
[Parameter] public Guid TenantId { get; set; }
[Parameter] public CustomerAccessRole AccessRole { get; set; }
private bool vaultInitialized;
private List<VaultSecret>? secrets;
private List<KubernetesCluster>? clusters;
// Add form
private string newSecretName = "";
private string newSecretValue = "";
private bool saving;
private string? errorMessage;
private string? successMessage;
// Reveal
private Guid? revealedSecretId;
private string? revealedValue;
private bool revealLoading;
// Edit
private Guid? editSecretId;
private string editSecretValue = "";
// K8s sync config
private Guid? syncConfigSecretId;
private string syncSecretName = "";
private string syncNamespace = "";
private Guid syncClusterId;
// Sync-to-K8s state
private bool syncingSecrets;
private string? syncOutput;
protected override async Task OnInitializedAsync()
{
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
vaultInitialized = vault is not null;
if (vaultInitialized)
{
await LoadSecrets();
}
// Load clusters for the K8s sync config dropdown.
clusters = await TenantService.GetClustersAsync(TenantId);
}
private async Task LoadSecrets()
{
secrets = await VaultService.GetAppSecretsAsync(TenantId, App.Id);
}
// ──────── Add ────────
private async Task AddSecret()
{
errorMessage = null;
successMessage = null;
saving = true;
try
{
await VaultService.SetAppSecretAsync(TenantId, App.Id, newSecretName.Trim(), newSecretValue.Trim());
successMessage = $"Secret '{newSecretName.Trim()}' saved.";
newSecretName = "";
newSecretValue = "";
await LoadSecrets();
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally { saving = false; }
}
// ──────── Reveal ────────
private async Task RevealSecret(VaultSecret secret)
{
if (revealedSecretId == secret.Id)
{
revealedSecretId = null;
revealedValue = null;
return;
}
revealLoading = true;
revealedSecretId = secret.Id;
revealedValue = null;
revealedValue = await VaultService.GetSecretValueByIdAsync(secret.Id);
revealLoading = false;
}
// ──────── Edit ────────
private void StartEdit(VaultSecret secret)
{
editSecretId = secret.Id;
editSecretValue = "";
revealedSecretId = null;
revealedValue = null;
}
private void CancelEdit()
{
editSecretId = null;
editSecretValue = "";
}
private async Task SaveEdit()
{
if (editSecretId is null || string.IsNullOrWhiteSpace(editSecretValue)) return;
saving = true;
errorMessage = null;
try
{
bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim());
successMessage = updated ? "Secret value updated." : null;
errorMessage = updated ? null : "Failed to update secret.";
}
finally { saving = false; }
editSecretId = null;
editSecretValue = "";
await LoadSecrets();
}
// ──────── Delete ────────
private async Task DeleteSecret(Guid secretId)
{
errorMessage = null;
(bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId);
if (!canDelete)
{
errorMessage = reason;
return;
}
await VaultService.DeleteSecretAsync(secretId);
revealedSecretId = null;
editSecretId = null;
await LoadSecrets();
}
// ──────── K8s Sync ────────
private void ToggleSync(VaultSecret secret)
{
if (secret.SyncToKubernetes)
{
_ = DisableSync(secret.Id);
}
else
{
syncConfigSecretId = secret.Id;
syncSecretName = secret.KubernetesSecretName ?? "";
syncNamespace = secret.KubernetesNamespace ?? "";
syncClusterId = secret.KubernetesClusterId ?? Guid.Empty;
}
}
private async Task DisableSync(Guid secretId)
{
await VaultService.ConfigureKubernetesSyncAsync(secretId, syncEnabled: false, secretName: null, ns: null);
await LoadSecrets();
}
private async Task SaveSync()
{
if (syncConfigSecretId is null) return;
Guid? clusterId = syncClusterId != Guid.Empty ? syncClusterId : (Guid?)null;
await VaultService.ConfigureKubernetesSyncAsync(
syncConfigSecretId.Value,
syncEnabled: true,
secretName: syncSecretName.Trim(),
ns: syncNamespace.Trim(),
clusterId: clusterId);
syncConfigSecretId = null;
await LoadSecrets();
}
private async Task SyncAllToKubernetes()
{
syncingSecrets = true;
syncOutput = null;
try
{
HelmExecutionResult result = await VaultService.SyncAppSecretsToKubernetesAsync(TenantId, App.Id);
syncOutput = result.Output;
}
finally
{
syncingSecrets = false;
}
}
}