491 lines
22 KiB
Plaintext
491 lines
22 KiB
Plaintext
@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">
|
|
<LoadingPanel Loading="@(secrets is null)">
|
|
@if (secrets is not null && secrets.Count == 0)
|
|
{
|
|
<EmptyState Icon="bi-lock"
|
|
Message="@(AccessRole >= CustomerAccessRole.Admin ? "No secrets stored yet. Use the form above to add one." : "No secrets stored for this app yet.")" />
|
|
}
|
|
else if (secrets is not null)
|
|
{
|
|
<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>
|
|
}
|
|
</LoadingPanel>
|
|
@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;
|
|
}
|
|
}
|
|
}
|