This commit is contained in:
Nils Blomgren
2026-06-07 21:09:37 +02:00
parent 76b7e55931
commit ffe41b4413
41 changed files with 2927 additions and 460 deletions

View File

@@ -11,6 +11,7 @@
**/*.user **/*.user
**/*.dbmdl **/*.dbmdl
**/*.jfm **/*.jfm
**/*.db
**/secrets.dev.yaml **/secrets.dev.yaml
**/values.dev.yaml **/values.dev.yaml
**/Tiltfile **/Tiltfile

View File

@@ -21,9 +21,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libcurl4 \ libcurl4 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Run as non-root
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
RUN mkdir -p /app/Data && chown appuser:appgroup /app/Data
COPY --from=build /app/publish . COPY --from=build /app/publish .
RUN chown -R appuser:appgroup /app
USER appuser
ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
# SQLite database lives here — mount a persistent volume at /app/Data
VOLUME ["/app/Data"]
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["dotnet", "EntKube.Web.dll"] ENTRYPOINT ["dotnet", "EntKube.Web.dll"]

59
docker-compose.yml Normal file
View File

@@ -0,0 +1,59 @@
services:
postgres:
image: postgres:17
environment:
POSTGRES_DB: entkube
POSTGRES_USER: entkube
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-changeme}"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U entkube -d entkube"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
restart: unless-stopped
entkube:
build:
context: .
dockerfile: Dockerfile
image: entkube:latest
ports:
- "8080:8080"
volumes:
- entkube-data:/app/Data
depends_on:
postgres:
condition: service_healthy
environment:
# --- Database ---
DatabaseProvider: Postgres
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=entkube;Username=entkube;Password=${POSTGRES_PASSWORD:-changeme}"
# --- Vault encryption ---
# Required. Generate with: openssl rand -base64 32
Vault__RootKey: "REPLACE_WITH_BASE64_32_BYTE_KEY"
# --- Auth ---
# Set to false after the first admin account is created.
Auth__AllowRegistration: "true"
# --- Bootstrap ---
# Optional. If set, this user is granted the Admin role on every startup
# (no-op if already admin). Useful for recovering access.
# Seed__AdminEmail: "admin@example.com"
# --- Email / SMTP (optional) ---
# Smtp__Host: "smtp.example.com"
# Smtp__Username: "user"
# Smtp__Password: "password"
# Smtp__FromAddress: "alerts@entkube.io"
restart: unless-stopped
volumes:
# Named volumes are NOT removed by "docker compose down".
# Data is only deleted if you explicitly run "docker compose down -v".
postgres-data:
entkube-data:

View File

@@ -14,12 +14,20 @@
@inject ILogger<Register> Logger @inject ILogger<Register> Logger
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager @inject IdentityRedirectManager RedirectManager
@inject IConfiguration Configuration
<PageTitle>Register</PageTitle> <PageTitle>Register</PageTitle>
<h1>Register</h1> @if (!allowRegistration)
{
<h1>Registration Closed</h1>
<p class="text-muted">Self-registration is currently disabled. Please contact an administrator to get access.</p>
}
else
{
<h1>Register</h1>
<div class="row"> <div class="row">
<div class="col-lg-6"> <div class="col-lg-6">
<StatusMessage Message="@Message" /> <StatusMessage Message="@Message" />
<EditForm Model="Input" method="post" OnValidSubmit="RegisterUser" FormName="register"> <EditForm Model="Input" method="post" OnValidSubmit="RegisterUser" FormName="register">
@@ -52,10 +60,12 @@
<ExternalLoginPicker /> <ExternalLoginPicker />
</section> </section>
</div> </div>
</div> </div>
}
@code { @code {
private IEnumerable<IdentityError>? identityErrors; private IEnumerable<IdentityError>? identityErrors;
private bool allowRegistration = true;
[SupplyParameterFromForm] [SupplyParameterFromForm]
private InputModel Input { get; set; } = default!; private InputModel Input { get; set; } = default!;
@@ -68,10 +78,17 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
Input ??= new(); Input ??= new();
allowRegistration = Configuration.GetValue<bool>("Auth:AllowRegistration", true);
} }
public async Task RegisterUser(EditContext editContext) public async Task RegisterUser(EditContext editContext)
{ {
if (!allowRegistration)
{
RedirectManager.RedirectTo("Account/Register");
return;
}
var user = CreateUser(); var user = CreateUser();
await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None); await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);

View File

@@ -3,6 +3,7 @@
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject EntKube.Web.Services.UserAccessService UserAccessService @inject EntKube.Web.Services.UserAccessService UserAccessService
@inject IConfiguration Configuration
<div class="top-row ps-3 navbar navbar-dark"> <div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid"> <div class="container-fluid">
@@ -43,6 +44,16 @@
<AuthorizeView Roles="Admin"> <AuthorizeView Roles="Admin">
<Authorized> <Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="admin/users">
<span class="bi bi-people-nav-menu" aria-hidden="true"></span> Users
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="admin/roles">
<span class="bi bi-shield-lock-nav-menu" aria-hidden="true"></span> Roles
</NavLink>
</div>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="admin/backup"> <NavLink class="nav-link" href="admin/backup">
<span class="bi bi-arrow-repeat-nav-menu" aria-hidden="true"></span> Backup <span class="bi bi-arrow-repeat-nav-menu" aria-hidden="true"></span> Backup
@@ -69,11 +80,14 @@
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@if (allowRegistration)
{
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Register"> <NavLink class="nav-link" href="Account/Register">
<span class="bi bi-person-nav-menu" aria-hidden="true"></span> Register <span class="bi bi-person-nav-menu" aria-hidden="true"></span> Register
</NavLink> </NavLink>
</div> </div>
}
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Login"> <NavLink class="nav-link" href="Account/Login">
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login <span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
@@ -87,12 +101,15 @@
@code { @code {
private string? currentUrl; private string? currentUrl;
private bool showTenantsLink; private bool showTenantsLink;
private bool allowRegistration = true;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
NavigationManager.LocationChanged += OnLocationChanged; NavigationManager.LocationChanged += OnLocationChanged;
allowRegistration = Configuration.GetValue<bool>("Auth:AllowRegistration", true);
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync(); AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
System.Security.Claims.ClaimsPrincipal user = authState.User; System.Security.Claims.ClaimsPrincipal user = authState.User;

View File

@@ -0,0 +1,365 @@
@page "/admin/roles"
@using Microsoft.AspNetCore.Authorization
@using EntKube.Web.Data
@using EntKube.Web.Services
@rendermode InteractiveServer
@attribute [Authorize(Roles = "Admin")]
@inject TenantRoleService RoleService
@inject TenantService TenantService
<PageTitle>Role Management</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="mb-0"><i class="bi bi-shield-lock me-2 text-primary"></i>Role Management</h1>
<p class="text-muted small mb-0 mt-1">Define roles and their feature permissions per tenant.</p>
</div>
</div>
@* ── Tenant selector ── *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<div class="d-flex align-items-center gap-3">
<label class="text-muted small fw-semibold text-nowrap mb-0">Tenant</label>
<select class="form-select" @onchange="OnTenantChanged">
<option value="">Select a tenant…</option>
@foreach (Tenant t in allTenants)
{
<option value="@t.Id" selected="@(t.Id == selectedTenantId)">@t.Name</option>
}
</select>
</div>
</div>
</div>
@if (selectedTenantId != Guid.Empty)
{
@* ── Create role ── *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
<input type="text" class="form-control border-start-0" placeholder="New role name (e.g. DevOps)"
@bind="newRoleName" @bind:event="oninput"
@onkeydown="@(async e => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newRoleName)) await CreateRole(); })" />
<button class="btn btn-primary" @onclick="CreateRole" disabled="@string.IsNullOrWhiteSpace(newRoleName)">
<i class="bi bi-plus-lg me-1"></i>Add Role
</button>
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger alert-dismissible py-2 mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
</div>
}
<LoadingPanel Loading="@(roles is null)" LoadingText="Loading roles…">
@if (roles is not null && roles.Count == 0)
{
<EmptyState Icon="bi-shield-lock"
Title="No roles yet"
Message="Create a role above to define what users in this tenant can access." />
}
else if (roles is not null)
{
<div class="d-flex flex-column gap-3">
@foreach (TenantRole role in roles)
{
bool isExpanded = expandedRoleId == role.Id;
Dictionary<TenantFeature, AccessLevel> perms = TenantRoleService.DecodePermissions(role.PermissionsJson);
<div class="card border-0 shadow-sm">
@* ── Role header ── *@
<div class="card-body py-3 d-flex align-items-center justify-content-between"
style="cursor:pointer" @onclick="() => ToggleExpand(role.Id)">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-chevron-@(isExpanded ? "up" : "down") text-muted"></i>
@if (editingNameId == role.Id)
{
<input type="text" class="form-control form-control-sm" style="width:220px"
value="@editingName"
@oninput="e => editingName = e.Value?.ToString() ?? string.Empty"
@onkeydown="@(async e => { if (e.Key == "Enter") await SaveRename(role.Id); else if (e.Key == "Escape") CancelRename(); })"
@onclick:stopPropagation="true" />
<button class="btn btn-sm btn-success" @onclick:stopPropagation="true" @onclick="() => SaveRename(role.Id)">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick:stopPropagation="true" @onclick="CancelRename">
<i class="bi bi-x"></i>
</button>
}
else
{
<span class="fw-semibold">@role.Name</span>
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
@role.Memberships.Count members
</span>
}
</div>
<div class="d-flex gap-2" @onclick:stopPropagation="true">
<button class="btn btn-sm btn-outline-secondary" title="Rename"
@onclick="() => StartRename(role.Id, role.Name)">
<i class="bi bi-pencil"></i>
</button>
@if (confirmDeleteId == role.Id)
{
<span class="text-danger small align-self-center me-1">Delete?</span>
<button class="btn btn-sm btn-danger" @onclick="() => DeleteRole(role.Id)">Yes</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">No</button>
}
else
{
<button class="btn btn-sm btn-outline-danger" title="Delete role"
@onclick="() => confirmDeleteId = role.Id">
<i class="bi bi-trash"></i>
</button>
}
</div>
</div>
@* ── Permission matrix ── *@
@if (isExpanded)
{
Dictionary<TenantFeature, AccessLevel> draft = GetDraft(role.Id, perms);
bool isDirty = savedMessages.ContainsKey(role.Id) || IsDirty(role.Id, perms);
<div class="card-body border-top pt-3 pb-3">
<div class="table-responsive">
<table class="table table-sm table-borderless mb-3" style="max-width:700px">
<thead>
<tr class="text-muted small">
<th style="width:40%">Feature</th>
<th class="text-center">None</th>
<th class="text-center">View</th>
<th class="text-center">Edit</th>
<th class="text-center">Manage</th>
</tr>
</thead>
<tbody>
@foreach ((TenantFeature feature, string label, string icon) in TenantRoleService.Features)
{
AccessLevel current = TenantRoleService.GetPermission(draft, feature);
<tr>
<td class="align-middle">
<i class="bi @icon me-2 text-muted"></i>@label
</td>
@foreach (AccessLevel level in Enum.GetValues<AccessLevel>())
{
<td class="text-center align-middle">
<input type="radio"
class="form-check-input"
name="@($"perm-{role.Id}-{feature}")"
checked="@(current == level)"
@onchange="() => SetDraft(role.Id, feature, level)" />
</td>
}
</tr>
}
</tbody>
</table>
</div>
<div class="d-flex align-items-center gap-3">
<button class="btn btn-primary btn-sm" @onclick="() => SavePermissions(role.Id)">
<i class="bi bi-floppy me-1"></i>Save Permissions
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => ResetDraft(role.Id, perms)">
Reset
</button>
@if (savedMessages.TryGetValue(role.Id, out string? msg))
{
<span class="text-success small"><i class="bi bi-check-circle me-1"></i>@msg</span>
}
</div>
</div>
}
</div>
}
</div>
}
</LoadingPanel>
}
@code {
private List<Tenant> allTenants = [];
private Guid selectedTenantId;
private List<TenantRole>? roles;
private string newRoleName = "";
private string? errorMessage;
// Expand/collapse
private Guid? expandedRoleId;
// Inline rename
private Guid? editingNameId;
private string editingName = "";
// Delete confirm
private Guid? confirmDeleteId;
// Permission drafts: roleId → working copy of permissions
private Dictionary<Guid, Dictionary<TenantFeature, AccessLevel>> drafts = [];
// Save confirmation messages
private Dictionary<Guid, string> savedMessages = [];
protected override async Task OnInitializedAsync()
{
allTenants = await TenantService.GetAllTenantsAsync();
}
private async Task OnTenantChanged(ChangeEventArgs e)
{
selectedTenantId = Guid.TryParse(e.Value?.ToString(), out Guid id) ? id : Guid.Empty;
roles = null;
drafts.Clear();
savedMessages.Clear();
expandedRoleId = null;
errorMessage = null;
if (selectedTenantId != Guid.Empty)
await LoadRoles();
}
private async Task LoadRoles()
{
roles = await RoleService.GetRolesAsync(selectedTenantId);
// Prune drafts for roles that no longer exist.
HashSet<Guid> ids = roles.Select(r => r.Id).ToHashSet();
foreach (Guid gone in drafts.Keys.Except(ids).ToList())
drafts.Remove(gone);
}
private async Task CreateRole()
{
if (string.IsNullOrWhiteSpace(newRoleName)) return;
errorMessage = null;
try
{
TenantRole created = await RoleService.CreateRoleAsync(selectedTenantId, newRoleName.Trim());
newRoleName = "";
await LoadRoles();
expandedRoleId = created.Id;
}
catch (Exception ex)
{
errorMessage = $"Could not create role: {ex.Message}";
}
}
private async Task DeleteRole(Guid roleId)
{
confirmDeleteId = null;
errorMessage = null;
bool ok = await RoleService.DeleteRoleAsync(roleId);
if (!ok) errorMessage = "Role not found.";
drafts.Remove(roleId);
savedMessages.Remove(roleId);
if (expandedRoleId == roleId) expandedRoleId = null;
await LoadRoles();
}
private void ToggleExpand(Guid roleId)
{
expandedRoleId = expandedRoleId == roleId ? null : roleId;
}
// ── Rename ──
private void StartRename(Guid roleId, string currentName)
{
editingNameId = roleId;
editingName = currentName;
}
private void CancelRename()
{
editingNameId = null;
editingName = "";
}
private async Task SaveRename(Guid roleId)
{
if (string.IsNullOrWhiteSpace(editingName)) return;
errorMessage = null;
try
{
await RoleService.RenameRoleAsync(roleId, editingName.Trim());
CancelRename();
await LoadRoles();
}
catch (Exception ex)
{
errorMessage = $"Could not rename role: {ex.Message}";
}
}
// ── Permission draft management ──
private Dictionary<TenantFeature, AccessLevel> GetDraft(Guid roleId, Dictionary<TenantFeature, AccessLevel> saved)
{
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft))
{
draft = new Dictionary<TenantFeature, AccessLevel>(saved);
drafts[roleId] = draft;
}
return draft;
}
private void SetDraft(Guid roleId, TenantFeature feature, AccessLevel level)
{
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft))
{
draft = [];
drafts[roleId] = draft;
}
draft[feature] = level;
savedMessages.Remove(roleId);
}
private void ResetDraft(Guid roleId, Dictionary<TenantFeature, AccessLevel> saved)
{
drafts[roleId] = new Dictionary<TenantFeature, AccessLevel>(saved);
savedMessages.Remove(roleId);
}
private bool IsDirty(Guid roleId, Dictionary<TenantFeature, AccessLevel> saved)
{
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft)) return false;
foreach (TenantFeature f in Enum.GetValues<TenantFeature>())
{
AccessLevel savedLevel = TenantRoleService.GetPermission(saved, f);
AccessLevel draftLevel = TenantRoleService.GetPermission(draft, f);
if (savedLevel != draftLevel) return true;
}
return false;
}
private async Task SavePermissions(Guid roleId)
{
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft)) return;
errorMessage = null;
await RoleService.SavePermissionsAsync(roleId, draft);
savedMessages[roleId] = "Saved";
await LoadRoles();
// Clear the saved message after 3 seconds.
_ = Task.Delay(3000).ContinueWith(_ =>
{
savedMessages.Remove(roleId);
InvokeAsync(StateHasChanged);
});
}
}

View File

@@ -0,0 +1,462 @@
@page "/admin/users/{UserId}"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Identity
@using EntKube.Web.Data
@using EntKube.Web.Services
@rendermode InteractiveServer
@attribute [Authorize(Roles = "Admin")]
@inject UserManagementService UserManagement
@inject NavigationManager NavigationManager
<PageTitle>@(user?.Email ?? "User")</PageTitle>
@if (user is null && loaded)
{
<div class="alert alert-danger"><i class="bi bi-exclamation-triangle me-2"></i>User not found.</div>
}
else if (user is not null)
{
<div class="d-flex align-items-center mb-1">
<a href="/admin/users" class="btn btn-sm btn-outline-secondary me-3">
<i class="bi bi-arrow-left me-1"></i>Users
</a>
<h1 class="mb-0 fs-3"><i class="bi bi-person-circle me-2 text-primary"></i>@user.Email</h1>
@if (user.EmailConfirmed)
{
<span class="badge bg-success-subtle text-success border border-success-subtle ms-3">Confirmed</span>
}
else
{
<span class="badge bg-warning-subtle text-warning border border-warning-subtle ms-3">Unconfirmed</span>
}
</div>
<p class="text-muted small mb-4">ID: @user.Id</p>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger alert-dismissible py-2" role="alert">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
</div>
}
<div class="row g-4">
@* ── Global Roles ── *@
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title mb-3"><i class="bi bi-shield-lock me-2 text-primary"></i>Global Roles</h5>
@if (allRoles.Count == 0)
{
<p class="text-muted small mb-0">No global roles defined.</p>
}
else
{
<div class="d-flex flex-wrap gap-3">
@foreach (IdentityRole role in allRoles)
{
bool hasRole = userRoles.Contains(role.Name ?? "");
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
id="role-@role.Id"
checked="@hasRole"
@onchange="e => ToggleRole(role.Name!, (bool)(e.Value ?? false))" />
<label class="form-check-label fw-semibold" for="role-@role.Id">
@role.Name
</label>
</div>
}
</div>
}
</div>
</div>
</div>
@* ── Tenant Memberships ── *@
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title mb-3"><i class="bi bi-building me-2 text-primary"></i>Tenant Memberships</h5>
@if (tenantMemberships.Count > 0)
{
<div class="table-responsive mb-3">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Tenant</th>
<th>Role</th>
<th>Joined</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (TenantMembership m in tenantMemberships)
{
<tr>
<td class="align-middle">@m.Tenant.Name</td>
<td class="align-middle">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">@m.Role.Name</span>
</td>
<td class="align-middle text-muted small">@m.JoinedAt.ToString("MMM d, yyyy")</td>
<td class="align-middle text-end">
@if (confirmRemoveTenantId == m.TenantId)
{
<span class="me-2 small text-danger">Remove?</span>
<button class="btn btn-sm btn-danger me-1" @onclick="() => RemoveTenantMembership(m.TenantId)">Yes</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmRemoveTenantId = null">No</button>
}
else
{
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmRemoveTenantId = m.TenantId">
<i class="bi bi-trash"></i>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
@* Add tenant form *@
@if (availableTenants.Count > 0)
{
<div class="d-flex gap-2 align-items-end flex-wrap">
<div>
<label class="form-label small text-muted mb-1">Tenant</label>
<select class="form-select form-select-sm" @bind="addTenantId" style="min-width:180px">
<option value="">Select tenant…</option>
@foreach (Tenant t in availableTenants)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div>
<label class="form-label small text-muted mb-1">Role</label>
@if (addTenantId != Guid.Empty && !rolesForSelectedTenant.Any())
{
<div class="text-muted small">
<i class="bi bi-exclamation-circle me-1"></i>No roles defined for this tenant.
<a href="/admin/roles">Create roles first</a>.
</div>
}
else
{
<select class="form-select form-select-sm" @bind="addTenantRoleId" style="min-width:150px">
<option value="">Select role…</option>
@foreach (TenantRole r in rolesForSelectedTenant)
{
<option value="@r.Id">@r.Name</option>
}
</select>
}
</div>
<button class="btn btn-sm btn-primary" @onclick="AddTenantMembership"
disabled="@(addTenantId == Guid.Empty || addTenantRoleId == Guid.Empty)">
<i class="bi bi-plus-lg me-1"></i>Add
</button>
</div>
}
else if (tenantMemberships.Count == 0)
{
<p class="text-muted small mb-0">No tenants available to assign.</p>
}
</div>
</div>
</div>
@* ── Group Memberships ── *@
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title mb-3"><i class="bi bi-diagram-3 me-2 text-primary"></i>Group Memberships</h5>
@if (groupMemberships.Count > 0)
{
<div class="table-responsive mb-3">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Group</th>
<th>Tenant</th>
<th>Joined</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (GroupMembership gm in groupMemberships)
{
<tr>
<td class="align-middle">@gm.Group.Name</td>
<td class="align-middle text-muted small">@gm.Group.Tenant.Name</td>
<td class="align-middle text-muted small">@gm.JoinedAt.ToString("MMM d, yyyy")</td>
<td class="align-middle text-end">
@if (confirmRemoveGroupId == gm.GroupId)
{
<span class="me-2 small text-danger">Remove?</span>
<button class="btn btn-sm btn-danger me-1" @onclick="() => RemoveGroupMembership(gm.GroupId)">Yes</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmRemoveGroupId = null">No</button>
}
else
{
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmRemoveGroupId = gm.GroupId">
<i class="bi bi-trash"></i>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
@* Add group form — only tenants this user is a member of have groups *@
@if (tenantMemberships.Count > 0)
{
<div class="d-flex gap-2 align-items-end flex-wrap">
<div>
<label class="form-label small text-muted mb-1">Tenant</label>
<select class="form-select form-select-sm" @bind="addGroupTenantId" style="min-width:180px">
<option value="">Select tenant…</option>
@foreach (TenantMembership m in tenantMemberships)
{
<option value="@m.TenantId">@m.Tenant.Name</option>
}
</select>
</div>
<div>
<label class="form-label small text-muted mb-1">Group</label>
<select class="form-select form-select-sm" @bind="addGroupId" style="min-width:180px">
<option value="">Select group…</option>
@foreach (Group g in availableGroupsForTenant)
{
<option value="@g.Id">@g.Name</option>
}
</select>
</div>
<button class="btn btn-sm btn-primary" @onclick="AddGroupMembership"
disabled="@(addGroupId == Guid.Empty)">
<i class="bi bi-plus-lg me-1"></i>Add
</button>
</div>
}
else if (groupMemberships.Count == 0)
{
<p class="text-muted small mb-0">Add the user to a tenant first, then assign groups.</p>
}
</div>
</div>
</div>
@* ── Danger Zone ── *@
<div class="col-12">
<div class="card border-danger border-opacity-50 shadow-sm">
<div class="card-body">
<h5 class="card-title text-danger mb-1"><i class="bi bi-exclamation-triangle me-2"></i>Danger Zone</h5>
<p class="text-muted small mb-3">Permanently delete this user account. All tenant and group memberships are removed. This cannot be undone.</p>
@if (confirmDelete)
{
<div class="alert alert-danger py-2 small mb-3">
<strong>Are you sure?</strong> This will permanently delete <strong>@user.Email</strong>.
</div>
<button class="btn btn-danger me-2" @onclick="DeleteUser">Delete Permanently</button>
<button class="btn btn-outline-secondary" @onclick="() => confirmDelete = false">Cancel</button>
}
else
{
<button class="btn btn-outline-danger" @onclick="() => confirmDelete = true">
<i class="bi bi-trash me-1"></i>Delete User
</button>
}
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public string UserId { get; set; } = null!;
private ApplicationUser? user;
private bool loaded;
private string? errorMessage;
private List<IdentityRole> allRoles = [];
private IList<string> userRoles = [];
private List<TenantMembership> tenantMemberships = [];
private List<Tenant> allTenants = [];
private List<Tenant> availableTenants = [];
// Add-tenant form state
private Guid _addTenantId;
private Guid addTenantId
{
get => _addTenantId;
set
{
_addTenantId = value;
addTenantRoleId = Guid.Empty;
}
}
private Guid addTenantRoleId;
private IEnumerable<TenantRole> rolesForSelectedTenant =>
allTenants.FirstOrDefault(t => t.Id == addTenantId)?.Roles ?? [];
private List<GroupMembership> groupMemberships = [];
// Add-group form state
private Guid _addGroupTenantId;
private Guid addGroupTenantId
{
get => _addGroupTenantId;
set
{
_addGroupTenantId = value;
addGroupId = Guid.Empty;
_ = LoadGroupsForSelectedTenant();
}
}
private Guid addGroupId;
private List<Group> availableGroupsForTenant = [];
private Guid? confirmRemoveTenantId;
private Guid? confirmRemoveGroupId;
private bool confirmDelete;
protected override async Task OnInitializedAsync()
{
await Load();
}
private async Task Load()
{
user = await UserManagement.GetUserByIdAsync(UserId);
loaded = true;
if (user is null) return;
// UserManager and RoleManager share the scoped DbContext — run sequentially.
// The IDbContextFactory-based calls each create their own instance and are safe.
allRoles = await UserManagement.GetAllRolesAsync();
userRoles = await UserManagement.GetUserRolesAsync(UserId);
tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId);
groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId);
allTenants = await UserManagement.GetAllTenantsAsync();
RefreshAvailableTenants();
}
private void RefreshAvailableTenants()
{
HashSet<Guid> joined = tenantMemberships.Select(m => m.TenantId).ToHashSet();
availableTenants = allTenants.Where(t => !joined.Contains(t.Id)).ToList();
if (!availableTenants.Any(t => t.Id == addTenantId))
{
addTenantId = Guid.Empty;
addTenantRoleId = Guid.Empty;
}
}
private async Task LoadGroupsForSelectedTenant()
{
if (_addGroupTenantId == Guid.Empty)
{
availableGroupsForTenant = [];
return;
}
List<Group> all = await UserManagement.GetGroupsForTenantAsync(_addGroupTenantId);
HashSet<Guid> joined = groupMemberships.Select(gm => gm.GroupId).ToHashSet();
availableGroupsForTenant = all.Where(g => !joined.Contains(g.Id)).ToList();
StateHasChanged();
}
private async Task ToggleRole(string role, bool grant)
{
errorMessage = null;
IdentityResult result = grant
? await UserManagement.AddToRoleAsync(UserId, role)
: await UserManagement.RemoveFromRoleAsync(UserId, role);
if (!result.Succeeded)
{
errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
return;
}
userRoles = await UserManagement.GetUserRolesAsync(UserId);
}
private async Task AddTenantMembership()
{
if (addTenantId == Guid.Empty || addTenantRoleId == Guid.Empty) return;
errorMessage = null;
try
{
await UserManagement.AddTenantMembershipAsync(UserId, addTenantId, addTenantRoleId);
addTenantId = Guid.Empty;
tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId);
RefreshAvailableTenants();
}
catch (Exception ex)
{
errorMessage = $"Failed to add tenant membership: {ex.Message}";
}
}
private async Task RemoveTenantMembership(Guid tenantId)
{
confirmRemoveTenantId = null;
errorMessage = null;
await UserManagement.RemoveTenantMembershipAsync(UserId, tenantId);
tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId);
RefreshAvailableTenants();
}
private async Task AddGroupMembership()
{
if (addGroupId == Guid.Empty) return;
errorMessage = null;
try
{
await UserManagement.AddGroupMembershipAsync(UserId, addGroupId);
groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId);
await LoadGroupsForSelectedTenant();
}
catch (Exception ex)
{
errorMessage = $"Failed to add group membership: {ex.Message}";
}
}
private async Task RemoveGroupMembership(Guid groupId)
{
confirmRemoveGroupId = null;
errorMessage = null;
await UserManagement.RemoveGroupMembershipAsync(UserId, groupId);
groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId);
await LoadGroupsForSelectedTenant();
}
private async Task DeleteUser()
{
IdentityResult result = await UserManagement.DeleteUserAsync(UserId);
if (!result.Succeeded)
{
errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
confirmDelete = false;
return;
}
NavigationManager.NavigateTo("/admin/users");
}
}

View File

@@ -0,0 +1,153 @@
@page "/admin/users"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Identity
@using EntKube.Web.Data
@using EntKube.Web.Services
@rendermode InteractiveServer
@attribute [Authorize(Roles = "Admin")]
@inject UserManagementService UserManagement
@inject NavigationManager NavigationManager
<PageTitle>User Management</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="mb-0"><i class="bi bi-people me-2 text-primary"></i>Users</h1>
<p class="text-muted small mb-0 mt-1">Manage system users, roles, and tenant access.</p>
</div>
<button class="btn btn-primary" @onclick="() => showAddForm = !showAddForm">
<i class="bi bi-person-plus me-1"></i>Add User
</button>
</div>
@if (showAddForm)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title mb-3"><i class="bi bi-person-plus me-2 text-primary"></i>New User</h5>
@if (!string.IsNullOrEmpty(addError))
{
<div class="alert alert-danger py-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>@addError
</div>
}
<div class="row g-3">
<div class="col-md-5">
<input type="email" class="form-control" placeholder="Email address"
@bind="newEmail" @bind:event="oninput" />
</div>
<div class="col-md-5">
<input type="password" class="form-control" placeholder="Password"
@bind="newPassword" @bind:event="oninput" />
</div>
<div class="col-md-2">
<button class="btn btn-primary w-100" @onclick="CreateUser"
disabled="@(string.IsNullOrWhiteSpace(newEmail) || string.IsNullOrWhiteSpace(newPassword) || creating)">
@if (creating)
{
<span class="spinner-border spinner-border-sm" role="status"></span>
}
else
{
<span>Create</span>
}
</button>
</div>
</div>
<small class="text-muted mt-2 d-block">The account will be created with email pre-confirmed — no email verification needed.</small>
</div>
</div>
}
<LoadingPanel Loading="@(users is null)" LoadingText="Loading users…">
@if (users is not null && users.Count == 0)
{
<EmptyState Icon="bi-people"
Title="No users yet"
Message="Add the first user above." />
}
else if (users is not null)
{
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Email</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (ApplicationUser user in users)
{
<tr style="cursor:pointer" @onclick='() => NavigationManager.NavigateTo($"/admin/users/{user.Id}")'>
<td class="align-middle">
<i class="bi bi-person-circle me-2 text-muted"></i>@user.Email
</td>
<td class="align-middle">
@if (user.EmailConfirmed)
{
<span class="badge bg-success-subtle text-success border border-success-subtle">Confirmed</span>
}
else
{
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">Unconfirmed</span>
}
</td>
<td class="align-middle text-end">
<i class="bi bi-chevron-right text-muted"></i>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</LoadingPanel>
@code {
private List<ApplicationUser>? users;
private bool showAddForm;
private bool creating;
private string newEmail = "";
private string newPassword = "";
private string? addError;
protected override async Task OnInitializedAsync()
{
await Load();
}
private async Task Load()
{
users = await UserManagement.GetAllUsersAsync();
}
private async Task CreateUser()
{
addError = null;
creating = true;
try
{
IdentityResult result = await UserManagement.CreateUserAsync(newEmail.Trim(), newPassword);
if (!result.Succeeded)
{
addError = string.Join(" ", result.Errors.Select(e => e.Description));
return;
}
newEmail = "";
newPassword = "";
showAddForm = false;
await Load();
}
finally
{
creating = false;
}
}
}

View File

@@ -282,6 +282,11 @@ else
<i class="bi bi-plus me-1"></i>New Deployment <i class="bi bi-plus me-1"></i>New Deployment
</button> </button>
} }
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => SelectSecretsApp(app)"
title="Secrets, databases, messaging & cache">
<i class="bi bi-key me-1"></i>Secrets
</button>
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" <button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false"> data-bs-toggle="dropdown" aria-expanded="false">
@@ -304,11 +309,6 @@ else
</button> </button>
</li> </li>
} }
<li>
<button class="dropdown-item" @onclick="() => SelectSecretsApp(app)">
<i class="bi bi-key me-2"></i>Secrets
</button>
</li>
<li> <li>
<button class="dropdown-item" @onclick="() => SelectGovernanceApp(app)"> <button class="dropdown-item" @onclick="() => SelectGovernanceApp(app)">
<i class="bi bi-shield-check me-2"></i>Policy <i class="bi bi-shield-check me-2"></i>Policy

View File

@@ -2,7 +2,6 @@
@using EntKube.Web.Services @using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@implements IAsyncDisposable @implements IAsyncDisposable
@inject IJSRuntime JS
@inject IncidentService IncidentService @inject IncidentService IncidentService
@inject LokiService LokiService @inject LokiService LokiService
@inject GitSyncService GitSyncService @inject GitSyncService GitSyncService
@@ -441,12 +440,15 @@
<span>Apply to Cluster</span> <span>Apply to Cluster</span>
} }
</button> </button>
@if (!IsGitManaged)
{
<button class="btn btn-sm btn-outline-primary" @onclick="() => showAddManifest = !showAddManifest"> <button class="btn btn-sm btn-outline-primary" @onclick="() => showAddManifest = !showAddManifest">
<i class="bi bi-plus me-1"></i>Add Manifest <i class="bi bi-plus me-1"></i>Add Manifest
</button> </button>
}
</div> </div>
@if (showAddManifest) @if (showAddManifest && !IsGitManaged)
{ {
<div class="card border-primary mb-3"> <div class="card border-primary mb-3">
<div class="card-body"> <div class="card-body">
@@ -467,9 +469,7 @@
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">YAML Content</label> <label class="form-label small">YAML Content</label>
<textarea id="new-manifest-yaml" class="form-control form-control-sm font-monospace" rows="8" <YamlEditor @bind-Value="newManifestYaml" />
@bind="newManifestYaml"
placeholder="apiVersion: v1&#10;kind: Service&#10;..."></textarea>
</div> </div>
<div class="d-flex gap-1"> <div class="d-flex gap-1">
<button class="btn btn-sm btn-primary" @onclick="AddManifest" <button class="btn btn-sm btn-primary" @onclick="AddManifest"
@@ -500,7 +500,7 @@
<span class="fw-medium">@manifest.Name</span> <span class="fw-medium">@manifest.Name</span>
<span class="text-muted small ms-2">#@manifest.SortOrder</span> <span class="text-muted small ms-2">#@manifest.SortOrder</span>
</div> </div>
@if (AccessRole >= CustomerAccessRole.Admin) @if (AccessRole >= CustomerAccessRole.Admin && !IsGitManaged)
{ {
<div class="d-flex gap-1"> <div class="d-flex gap-1">
@if (editingManifestId == manifest.Id) @if (editingManifestId == manifest.Id)
@@ -526,12 +526,11 @@
<div class="card-body p-2"> <div class="card-body p-2">
@if (editingManifestId == manifest.Id) @if (editingManifestId == manifest.Id)
{ {
<textarea id="edit-manifest-yaml-@manifest.Id" class="form-control form-control-sm font-monospace" rows="10" <YamlEditor @bind-Value="editManifestYaml" />
@bind="editManifestYaml"></textarea>
} }
else else
{ {
<pre class="mb-0 small" style="max-height: 200px; overflow-y: auto;">@manifest.YamlContent</pre> <YamlEditor Value="@manifest.YamlContent" ReadOnly="true" />
} }
</div> </div>
</div> </div>
@@ -567,10 +566,7 @@
{ {
<div class="alert alert-danger py-1 small mb-2">@valuesError</div> <div class="alert alert-danger py-1 small mb-2">@valuesError</div>
} }
<textarea id="helm-values-editor" class="form-control font-monospace mb-2" rows="14" <YamlEditor @bind-Value="helmValuesYaml" ReadOnly="@(AccessRole < CustomerAccessRole.Admin)" />
@bind="helmValuesYaml"
placeholder="replicas: 3&#10;persistence:&#10; size: 10Gi"
disabled="@(AccessRole < CustomerAccessRole.Admin)"></textarea>
@if (AccessRole >= CustomerAccessRole.Admin) @if (AccessRole >= CustomerAccessRole.Admin)
{ {
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
@@ -625,7 +621,9 @@
Resources="@resourceTree" Resources="@resourceTree"
Namespace="@Deployment.Namespace" Namespace="@Deployment.Namespace"
DeploymentId="@Deployment.Id" DeploymentId="@Deployment.Id"
AccessRole="@AccessRole" /> AccessRole="@AccessRole"
OnRefresh="LoadResources"
LastRefreshed="@_resourcesRefreshedAt" />
} }
@* ══════════════════════════════════════════════════════════════ @* ══════════════════════════════════════════════════════════════
@@ -995,6 +993,7 @@
</div> </div>
} }
@code { @code {
[Parameter] public required AppDeployment Deployment { get; set; } [Parameter] public required AppDeployment Deployment { get; set; }
[Parameter] public CustomerAccessRole AccessRole { get; set; } [Parameter] public CustomerAccessRole AccessRole { get; set; }
@@ -1008,6 +1007,9 @@
[Parameter] public EventCallback OnBack { get; set; } [Parameter] public EventCallback OnBack { get; set; }
[Parameter] public EventCallback OnDeleted { get; set; } [Parameter] public EventCallback OnDeleted { get; set; }
private bool IsGitManaged => Deployment.Type is
DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps;
private string activeTab = "pods"; private string activeTab = "pods";
// Git tab // Git tab
@@ -1194,9 +1196,6 @@
private bool? yamlApplySuccess; private bool? yamlApplySuccess;
private string? yamlApplyOutput; private string? yamlApplyOutput;
// JS module for reading live textarea values
private IJSObjectReference? jsModule;
// Edit deployment // Edit deployment
private bool showEditDeployment; private bool showEditDeployment;
private bool savingEdit; private bool savingEdit;
@@ -1213,6 +1212,7 @@
// Resources // Resources
private List<DeploymentResource>? resourceTree; private List<DeploymentResource>? resourceTree;
private string? resourcesError; private string? resourcesError;
private DateTime? _resourcesRefreshedAt;
// Metrics // Metrics
private DeploymentMetricsSummary? metrics; private DeploymentMetricsSummary? metrics;
@@ -1236,20 +1236,7 @@
await LoadPodsInternal(); await LoadPodsInternal();
} }
protected override async Task OnAfterRenderAsync(bool firstRender) public ValueTask DisposeAsync() => ValueTask.CompletedTask;
{
if (firstRender)
{
jsModule = await JS.InvokeAsync<IJSObjectReference>(
"import", "./Components/Pages/Portal/PortalDeploymentDetail.razor.js");
}
}
public async ValueTask DisposeAsync()
{
if (jsModule is not null)
await jsModule.DisposeAsync();
}
// ──────── Pod operations ──────── // ──────── Pod operations ────────
@@ -1482,9 +1469,6 @@
private async Task AddManifest() private async Task AddManifest()
{ {
if (jsModule is not null)
newManifestYaml = await jsModule.InvokeAsync<string>("getElementValue", "new-manifest-yaml");
savingManifest = true; savingManifest = true;
try try
{ {
@@ -1506,9 +1490,6 @@
private async Task SaveManifest(Guid manifestId) private async Task SaveManifest(Guid manifestId)
{ {
if (jsModule is not null)
editManifestYaml = await jsModule.InvokeAsync<string>("getElementValue", $"edit-manifest-yaml-{manifestId}");
savingManifest = true; savingManifest = true;
try try
{ {
@@ -1577,11 +1558,6 @@
private async Task SaveHelmValues() private async Task SaveHelmValues()
{ {
// Read the live DOM value so we always have what the user actually typed,
// regardless of whether @bind's onchange fired before this click.
if (jsModule is not null)
helmValuesYaml = await jsModule.InvokeAsync<string>("getElementValue", "helm-values-editor");
savingValues = true; savingValues = true;
valuesSaved = false; valuesSaved = false;
valuesError = null; valuesError = null;
@@ -1610,6 +1586,7 @@
if (result.IsSuccess) if (result.IsSuccess)
{ {
resourceTree = result.Data ?? []; resourceTree = result.Data ?? [];
_resourcesRefreshedAt = DateTime.UtcNow;
// Persist the live status so the header badges are accurate. // Persist the live status so the header badges are accurate.
(SyncStatus sync, HealthStatus health) = (SyncStatus sync, HealthStatus health) =
@@ -1747,4 +1724,6 @@
_ => @<span class="badge bg-secondary">Unknown</span> _ => @<span class="badge bg-secondary">Unknown</span>
}; };
} }

View File

@@ -2,25 +2,34 @@
@using EntKube.Web.Services @using EntKube.Web.Services
@inject VaultService VaultService @inject VaultService VaultService
@inject TenantService TenantService @inject TenantService TenantService
@inject DeploymentService DeploymentService
@inject CnpgService CnpgService
@inject MongoService MongoService
@inject RabbitMQService RabbitMQService
@inject RedisService RedisService
@using EntKube.Web.Components.Pages.Shared @using EntKube.Web.Components.Pages.Shared
@* ═══════════════════════════════════════════════════════════════════ @* ═══════════════════════════════════════════════════════════════════
App Secrets — customer portal view for managing vault secrets App Secrets — customer portal view for managing vault secrets and
scoped to a single app. connected infrastructure credentials scoped to a single app.
Tabs:
App Secrets — vault secrets tied directly to the app
Databases — CNPG / MongoDB / Registered Postgres bindings
Messaging — RabbitMQ bindings (vhost / queue / exchange)
Cache — Redis bindings
Registries — Docker pull secrets
Access rules: Access rules:
Viewer+ — list secret names, reveal values on demand Viewer+ — list and reveal values
Admin — add / update / delete secrets, configure K8s sync Admin — add / update / delete secrets, trigger syncs
═══════════════════════════════════════════════════════════════════ *@ ═══════════════════════════════════════════════════════════════════ *@
<div class="d-flex align-items-center mb-3"> <div class="d-flex align-items-center mb-3">
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i> <i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
<div> <div>
<h5 class="mb-0">Secrets — @App.Name</h5> <h5 class="mb-0">Secrets — @App.Name</h5>
<small class="text-muted"> <small class="text-muted">Application secrets and connected infrastructure credentials.</small>
Application secrets stored with AES-256-GCM envelope encryption.
Values are never transmitted in plain text.
</small>
</div> </div>
</div> </div>
@@ -33,6 +42,40 @@
} }
else else
{ {
@* ── Tab navigation ── *@
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(scope == "app" ? "active" : "")" @onclick='() => SetScope("app")'>
<i class="bi bi-key me-1"></i>App Secrets
</button>
</li>
<li class="nav-item">
<button class="nav-link @(scope == "database" ? "active" : "")" @onclick='() => SetScope("database")'>
<i class="bi bi-database me-1"></i>Databases
</button>
</li>
<li class="nav-item">
<button class="nav-link @(scope == "messaging" ? "active" : "")" @onclick='() => SetScope("messaging")'>
<i class="bi bi-send me-1"></i>Messaging
</button>
</li>
<li class="nav-item">
<button class="nav-link @(scope == "cache" ? "active" : "")" @onclick='() => SetScope("cache")'>
<i class="bi bi-lightning me-1"></i>Cache
</button>
</li>
<li class="nav-item">
<button class="nav-link @(scope == "docker" ? "active" : "")" @onclick='() => SetScope("docker")'>
<i class="bi bi-box-seam me-1"></i>Registries
</button>
</li>
</ul>
@* ════════════════════════════════════════════════════════════════
Tab: App Secrets
════════════════════════════════════════════════════════════════ *@
@if (scope == "app")
{
@* ── Add Secret form (Admin only) ── *@ @* ── Add Secret form (Admin only) ── *@
@if (AccessRole >= CustomerAccessRole.Admin) @if (AccessRole >= CustomerAccessRole.Admin)
{ {
@@ -141,14 +184,12 @@ else
<td><small class="text-muted">@(secret.KubernetesNamespace ?? "—")</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><small class="text-muted">@secret.UpdatedAt.ToString("MMM d, HH:mm")</small></td>
<td class="text-end"> <td class="text-end">
@* Reveal — available to all roles *@
<button class="btn btn-sm btn-outline-secondary me-1" <button class="btn btn-sm btn-outline-secondary me-1"
@onclick="() => RevealSecret(secret)" @onclick="() => RevealSecret(secret)"
title="@(revealedSecretId == secret.Id ? "Hide value" : "Reveal value")"> title="@(revealedSecretId == secret.Id ? "Hide value" : "Reveal value")">
<i class="bi @(revealedSecretId == secret.Id ? "bi-eye-slash" : "bi-eye")"></i> <i class="bi @(revealedSecretId == secret.Id ? "bi-eye-slash" : "bi-eye")"></i>
</button> </button>
@* Admin-only: edit, K8s sync, delete *@
@if (AccessRole >= CustomerAccessRole.Admin) @if (AccessRole >= CustomerAccessRole.Admin)
{ {
<button class="btn btn-sm btn-outline-warning me-1" <button class="btn btn-sm btn-outline-warning me-1"
@@ -168,7 +209,6 @@ else
</td> </td>
</tr> </tr>
@* ── Reveal value row ── *@
@if (revealedSecretId == secret.Id) @if (revealedSecretId == secret.Id)
{ {
<tr class="table-light"> <tr class="table-light">
@@ -188,7 +228,6 @@ else
</tr> </tr>
} }
@* ── Edit value row ── *@
@if (editSecretId == secret.Id) @if (editSecretId == secret.Id)
{ {
<tr class="table-light"> <tr class="table-light">
@@ -214,7 +253,6 @@ else
</tr> </tr>
} }
@* ── K8s sync config row ── *@
@if (syncConfigSecretId == secret.Id) @if (syncConfigSecretId == secret.Id)
{ {
<tr class="table-light"> <tr class="table-light">
@@ -234,12 +272,12 @@ else
} }
<div class="col-md-3"> <div class="col-md-3">
<input type="text" class="form-control form-control-sm" <input type="text" class="form-control form-control-sm"
placeholder="K8s Secret name (e.g. my-app-secrets)" placeholder="K8s Secret name"
@bind="syncSecretName" /> @bind="syncSecretName" />
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<input type="text" class="form-control form-control-sm" <input type="text" class="form-control form-control-sm"
placeholder="Namespace (e.g. my-app)" placeholder="Namespace"
@bind="syncNamespace" /> @bind="syncNamespace" />
</div> </div>
<div class="col-auto"> <div class="col-auto">
@@ -267,19 +305,372 @@ else
} }
</div> </div>
</div> </div>
}
@* ── Docker Registry Credentials ── *@ @* ════════════════════════════════════════════════════════════════
<div class="mt-4"> Tab: Databases
<div class="d-flex align-items-center mb-2"> ════════════════════════════════════════════════════════════════ *@
<i class="bi bi-box-seam fs-5 me-2 text-primary"></i> else if (scope == "database")
<h6 class="mb-0">Docker Registry Credentials</h6> {
<span class="ms-2 text-muted small">Pull secrets for private container registries</span> <LoadingPanel Loading="@(!dbLoaded)">
@if (dbLoaded)
{
bool anyDbBindings = dbBindingsByDeployment.Values.Any(b => b.Count > 0);
@if (!anyDbBindings)
{
<EmptyState Icon="bi-database"
Message="No databases are connected to this app's deployments yet." />
}
else
{
@foreach (AppDeployment deployment in deployments!)
{
List<DatabaseBinding> bindings = dbBindingsByDeployment.GetValueOrDefault(deployment.Id, []);
if (bindings.Count == 0) continue;
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-rocket-takeoff text-primary"></i>
<strong>@deployment.Name</strong>
<span class="text-muted small">
<i class="bi bi-hdd-network me-1"></i>@deployment.Cluster?.Name
<i class="bi bi-box ms-2 me-1"></i>@deployment.Namespace
</span>
</div> </div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Type</th>
<th>Database</th>
<th>K8s Secret</th>
<th>Last Synced</th>
@if (AccessRole >= CustomerAccessRole.Admin)
{
<th class="text-end">Actions</th>
}
</tr>
</thead>
<tbody>
@foreach (DatabaseBinding binding in bindings)
{
<tr>
<td>@DbTypeBadge(binding)</td>
<td>
<span class="fw-medium">@DbDisplayName(binding)</span>
@if (binding.CnpgDatabase?.CnpgCluster is not null)
{
<br /><small class="text-muted">@binding.CnpgDatabase.CnpgCluster.Name</small>
}
else if (binding.MongoDatabase?.MongoCluster is not null)
{
<br /><small class="text-muted">@binding.MongoDatabase.MongoCluster.Name</small>
}
</td>
<td>
<code class="small">@binding.KubernetesSecretName</code>
<br />
<small class="text-muted">@deployment.Namespace</small>
</td>
<td>
@if (binding.LastSyncedAt is not null)
{
<small class="text-muted">@binding.LastSyncedAt.Value.ToString("MMM d, HH:mm")</small>
}
else
{
<span class="badge bg-warning text-dark small">Never synced</span>
}
</td>
@if (AccessRole >= CustomerAccessRole.Admin)
{
<td class="text-end">
@if (binding.CnpgDatabaseId.HasValue || binding.MongoDatabaseId.HasValue)
{
<button class="btn btn-sm btn-outline-success"
@onclick="() => SyncDatabaseBinding(binding)"
disabled="@syncingBindingIds.Contains(binding.Id)"
title="Sync credentials to K8s namespace">
@if (syncingBindingIds.Contains(binding.Id))
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
else
{
<i class="bi bi-cloud-arrow-up me-1"></i>
}
Sync
</button>
}
@if (bindingSyncErrors.TryGetValue(binding.Id, out string? dbErr) && dbErr is not null)
{
<div class="text-danger small mt-1">@dbErr</div>
}
</td>
}
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
}
}
</LoadingPanel>
}
@* ════════════════════════════════════════════════════════════════
Tab: Messaging (RabbitMQ)
════════════════════════════════════════════════════════════════ *@
else if (scope == "messaging")
{
<LoadingPanel Loading="@(!msgLoaded)">
@if (msgLoaded)
{
bool anyMsgBindings = msgBindingsByDeployment.Values.Any(b => b.Count > 0);
@if (!anyMsgBindings)
{
<EmptyState Icon="bi-send"
Message="No RabbitMQ connections are configured for this app's deployments yet." />
}
else
{
@foreach (AppDeployment deployment in deployments!)
{
List<MessagingBinding> bindings = msgBindingsByDeployment.GetValueOrDefault(deployment.Id, []);
if (bindings.Count == 0) continue;
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-rocket-takeoff text-primary"></i>
<strong>@deployment.Name</strong>
<span class="text-muted small">
<i class="bi bi-hdd-network me-1"></i>@deployment.Cluster?.Name
<i class="bi bi-box ms-2 me-1"></i>@deployment.Namespace
</span>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Cluster</th>
<th>Vhost</th>
<th>Queue / Exchange</th>
<th>K8s Secret</th>
<th>Last Synced</th>
@if (AccessRole >= CustomerAccessRole.Admin)
{
<th class="text-end">Actions</th>
}
</tr>
</thead>
<tbody>
@foreach (MessagingBinding binding in bindings)
{
<tr>
<td><small>@binding.Cluster?.Name</small></td>
<td><code class="small">@binding.Vhost</code></td>
<td>
@if (binding.QueueName is not null)
{
<span class="badge bg-info-subtle text-info border border-info-subtle me-1">
<i class="bi bi-collection me-1"></i>Queue
</span>
<code class="small">@binding.QueueName</code>
}
@if (binding.ExchangeName is not null)
{
<span class="badge bg-warning-subtle text-warning border border-warning-subtle me-1">
<i class="bi bi-arrow-left-right me-1"></i>Exchange
</span>
<code class="small">@binding.ExchangeName</code>
}
@if (binding.QueueName is null && binding.ExchangeName is null)
{
<span class="text-muted small">vhost only</span>
}
</td>
<td>
<code class="small">@binding.KubernetesSecretName</code>
<br />
<small class="text-muted">@deployment.Namespace</small>
</td>
<td>
@if (binding.LastSyncedAt is not null)
{
<small class="text-muted">@binding.LastSyncedAt.Value.ToString("MMM d, HH:mm")</small>
}
else
{
<span class="badge bg-warning text-dark small">Never synced</span>
}
</td>
@if (AccessRole >= CustomerAccessRole.Admin)
{
<td class="text-end">
<button class="btn btn-sm btn-outline-success"
@onclick="() => SyncMessagingBinding(binding)"
disabled="@syncingBindingIds.Contains(binding.Id)"
title="Sync AMQP connection details to K8s namespace">
@if (syncingBindingIds.Contains(binding.Id))
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
else
{
<i class="bi bi-cloud-arrow-up me-1"></i>
}
Sync
</button>
@if (bindingSyncErrors.TryGetValue(binding.Id, out string? msgErr) && msgErr is not null)
{
<div class="text-danger small mt-1">@msgErr</div>
}
</td>
}
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
}
}
</LoadingPanel>
}
@* ════════════════════════════════════════════════════════════════
Tab: Cache (Redis)
════════════════════════════════════════════════════════════════ *@
else if (scope == "cache")
{
<LoadingPanel Loading="@(!cacheLoaded)">
@if (cacheLoaded)
{
bool anyCacheBindings = cacheBindingsByDeployment.Values.Any(b => b.Count > 0);
@if (!anyCacheBindings)
{
<EmptyState Icon="bi-lightning"
Message="No Redis cache connections are configured for this app's deployments yet." />
}
else
{
@foreach (AppDeployment deployment in deployments!)
{
List<CacheBinding> bindings = cacheBindingsByDeployment.GetValueOrDefault(deployment.Id, []);
if (bindings.Count == 0) continue;
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-rocket-takeoff text-primary"></i>
<strong>@deployment.Name</strong>
<span class="text-muted small">
<i class="bi bi-hdd-network me-1"></i>@deployment.Cluster?.Name
<i class="bi bi-box ms-2 me-1"></i>@deployment.Namespace
</span>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Redis Cluster</th>
<th>K8s Secret</th>
<th>Last Synced</th>
@if (AccessRole >= CustomerAccessRole.Admin)
{
<th class="text-end">Actions</th>
}
</tr>
</thead>
<tbody>
@foreach (CacheBinding binding in bindings)
{
<tr>
<td>
<i class="bi bi-lightning text-warning me-1"></i>
<span class="fw-medium">@binding.RedisCluster.Name</span>
@if (binding.RedisCluster.KubernetesCluster is not null)
{
<br /><small class="text-muted">@binding.RedisCluster.KubernetesCluster.Name</small>
}
</td>
<td>
<code class="small">@binding.KubernetesSecretName</code>
<br />
<small class="text-muted">@deployment.Namespace</small>
</td>
<td>
@if (binding.LastSyncedAt is not null)
{
<small class="text-muted">@binding.LastSyncedAt.Value.ToString("MMM d, HH:mm")</small>
}
else
{
<span class="badge bg-warning text-dark small">Never synced</span>
}
</td>
@if (AccessRole >= CustomerAccessRole.Admin)
{
<td class="text-end">
<button class="btn btn-sm btn-outline-success"
@onclick="() => SyncCacheBinding(binding)"
disabled="@syncingBindingIds.Contains(binding.Id)"
title="Sync Redis connection details to K8s namespace">
@if (syncingBindingIds.Contains(binding.Id))
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
else
{
<i class="bi bi-cloud-arrow-up me-1"></i>
}
Sync
</button>
@if (bindingSyncErrors.TryGetValue(binding.Id, out string? cacheErr) && cacheErr is not null)
{
<div class="text-danger small mt-1">@cacheErr</div>
}
</td>
}
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
}
}
</LoadingPanel>
}
@* ════════════════════════════════════════════════════════════════
Tab: Docker Registries
════════════════════════════════════════════════════════════════ *@
else if (scope == "docker")
{
<DockerRegistriesPanel <DockerRegistriesPanel
TenantId="TenantId" TenantId="TenantId"
AppId="App.Id" AppId="App.Id"
IsAdmin="AccessRole >= CustomerAccessRole.Admin" /> IsAdmin="AccessRole >= CustomerAccessRole.Admin" />
</div> }
} }
@code { @code {
@@ -288,35 +679,50 @@ else
[Parameter] public CustomerAccessRole AccessRole { get; set; } [Parameter] public CustomerAccessRole AccessRole { get; set; }
private bool vaultInitialized; private bool vaultInitialized;
private string scope = "app";
// ── App Secrets ──────────────────────────────────────────────────
private List<VaultSecret>? secrets; private List<VaultSecret>? secrets;
private List<KubernetesCluster>? clusters; private List<KubernetesCluster>? clusters;
// Add form
private string newSecretName = ""; private string newSecretName = "";
private string newSecretValue = ""; private string newSecretValue = "";
private bool saving; private bool saving;
private string? errorMessage; private string? errorMessage;
private string? successMessage; private string? successMessage;
// Reveal
private Guid? revealedSecretId; private Guid? revealedSecretId;
private string? revealedValue; private string? revealedValue;
private bool revealLoading; private bool revealLoading;
// Edit
private Guid? editSecretId; private Guid? editSecretId;
private string editSecretValue = ""; private string editSecretValue = "";
// K8s sync config
private Guid? syncConfigSecretId; private Guid? syncConfigSecretId;
private string syncSecretName = ""; private string syncSecretName = "";
private string syncNamespace = ""; private string syncNamespace = "";
private Guid syncClusterId; private Guid syncClusterId;
// Sync-to-K8s state
private bool syncingSecrets; private bool syncingSecrets;
private string? syncOutput; private string? syncOutput;
// ── Shared binding state ─────────────────────────────────────────
private List<AppDeployment>? deployments;
private HashSet<Guid> syncingBindingIds = [];
private Dictionary<Guid, string?> bindingSyncErrors = new();
// ── Databases ────────────────────────────────────────────────────
private Dictionary<Guid, List<DatabaseBinding>> dbBindingsByDeployment = new();
private bool dbLoaded;
// ── Messaging ────────────────────────────────────────────────────
private Dictionary<Guid, List<MessagingBinding>> msgBindingsByDeployment = new();
private bool msgLoaded;
// ── Cache ────────────────────────────────────────────────────────
private Dictionary<Guid, List<CacheBinding>> cacheBindingsByDeployment = new();
private bool cacheLoaded;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
SecretVault? vault = await VaultService.GetVaultAsync(TenantId); SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
@@ -327,17 +733,80 @@ else
await LoadSecrets(); await LoadSecrets();
} }
// Load clusters for the K8s sync config dropdown.
clusters = await TenantService.GetClustersAsync(TenantId); clusters = await TenantService.GetClustersAsync(TenantId);
} }
// ── Scope switching ──────────────────────────────────────────────
private async Task SetScope(string newScope)
{
scope = newScope;
errorMessage = null;
successMessage = null;
if (newScope == "database" && !dbLoaded)
await LoadDatabaseBindings();
else if (newScope == "messaging" && !msgLoaded)
await LoadMessagingBindings();
else if (newScope == "cache" && !cacheLoaded)
await LoadCacheBindings();
}
private async Task EnsureDeployments()
{
if (deployments is null)
deployments = await DeploymentService.GetDeploymentsAsync(App.Id);
}
private async Task LoadDatabaseBindings()
{
await EnsureDeployments();
dbBindingsByDeployment.Clear();
foreach (AppDeployment deployment in deployments!)
{
List<DatabaseBinding> bindings = await CnpgService.GetDatabaseBindingsAsync(deployment.Id);
dbBindingsByDeployment[deployment.Id] = bindings;
}
dbLoaded = true;
}
private async Task LoadMessagingBindings()
{
await EnsureDeployments();
msgBindingsByDeployment.Clear();
foreach (AppDeployment deployment in deployments!)
{
List<MessagingBinding> bindings = await RabbitMQService.GetMessagingBindingsForDeploymentAsync(deployment.Id);
msgBindingsByDeployment[deployment.Id] = bindings;
}
msgLoaded = true;
}
private async Task LoadCacheBindings()
{
await EnsureDeployments();
cacheBindingsByDeployment.Clear();
foreach (AppDeployment deployment in deployments!)
{
List<CacheBinding> bindings = await RedisService.GetCacheBindingsForDeploymentAsync(deployment.Id);
cacheBindingsByDeployment[deployment.Id] = bindings;
}
cacheLoaded = true;
}
// ── App Secrets: Add ─────────────────────────────────────────────
private async Task LoadSecrets() private async Task LoadSecrets()
{ {
secrets = await VaultService.GetAppSecretsAsync(TenantId, App.Id); secrets = await VaultService.GetAppSecretsAsync(TenantId, App.Id);
} }
// ──────── Add ────────
private async Task AddSecret() private async Task AddSecret()
{ {
errorMessage = null; errorMessage = null;
@@ -359,7 +828,7 @@ else
finally { saving = false; } finally { saving = false; }
} }
// ──────── Reveal ──────── // ── App Secrets: Reveal ──────────────────────────────────────────
private async Task RevealSecret(VaultSecret secret) private async Task RevealSecret(VaultSecret secret)
{ {
@@ -377,7 +846,7 @@ else
revealLoading = false; revealLoading = false;
} }
// ──────── Edit ──────── // ── App Secrets: Edit ────────────────────────────────────────────
private void StartEdit(VaultSecret secret) private void StartEdit(VaultSecret secret)
{ {
@@ -413,7 +882,7 @@ else
await LoadSecrets(); await LoadSecrets();
} }
// ──────── Delete ──────── // ── App Secrets: Delete ──────────────────────────────────────────
private async Task DeleteSecret(Guid secretId) private async Task DeleteSecret(Guid secretId)
{ {
@@ -432,7 +901,7 @@ else
await LoadSecrets(); await LoadSecrets();
} }
// ──────── K8s Sync ──────── // ── App Secrets: K8s sync ────────────────────────────────────────
private void ToggleSync(VaultSecret secret) private void ToggleSync(VaultSecret secret)
{ {
@@ -487,4 +956,111 @@ else
syncingSecrets = false; syncingSecrets = false;
} }
} }
// ── Database binding sync ────────────────────────────────────────
private async Task SyncDatabaseBinding(DatabaseBinding binding)
{
syncingBindingIds.Add(binding.Id);
bindingSyncErrors.Remove(binding.Id);
try
{
if (binding.CnpgDatabaseId.HasValue && binding.CnpgDatabase is not null)
{
await CnpgService.SyncDatabaseCredentialsToK8sAsync(
TenantId, binding.CnpgDatabase.CnpgClusterId, binding.CnpgDatabase.Id);
}
else if (binding.MongoDatabaseId.HasValue && binding.MongoDatabase is not null)
{
await MongoService.SyncDatabaseCredentialsToK8sAsync(
TenantId, binding.MongoDatabase.MongoClusterId, binding.MongoDatabase.Id);
}
// Refresh to show updated LastSyncedAt.
await LoadDatabaseBindings();
}
catch (Exception ex)
{
bindingSyncErrors[binding.Id] = ex.Message;
}
finally
{
syncingBindingIds.Remove(binding.Id);
}
}
// ── Messaging binding sync ───────────────────────────────────────
private async Task SyncMessagingBinding(MessagingBinding binding)
{
syncingBindingIds.Add(binding.Id);
bindingSyncErrors.Remove(binding.Id);
try
{
await RabbitMQService.SyncMessagingBindingAsync(TenantId, binding.Id);
await LoadMessagingBindings();
}
catch (Exception ex)
{
bindingSyncErrors[binding.Id] = ex.Message;
}
finally
{
syncingBindingIds.Remove(binding.Id);
}
}
// ── Cache binding sync ───────────────────────────────────────────
private async Task SyncCacheBinding(CacheBinding binding)
{
syncingBindingIds.Add(binding.Id);
bindingSyncErrors.Remove(binding.Id);
try
{
await RedisService.SyncCacheBindingAsync(TenantId, binding.Id);
await LoadCacheBindings();
}
catch (Exception ex)
{
bindingSyncErrors[binding.Id] = ex.Message;
}
finally
{
syncingBindingIds.Remove(binding.Id);
}
}
// ── Display helpers ──────────────────────────────────────────────
private RenderFragment DbTypeBadge(DatabaseBinding binding)
{
if (binding.CnpgDatabaseId.HasValue)
return @<span class="badge bg-primary-subtle text-primary border border-primary-subtle">
<i class="bi bi-database me-1"></i>PostgreSQL
</span>;
if (binding.MongoDatabaseId.HasValue)
return @<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-database me-1"></i>MongoDB
</span>;
if (binding.RegisteredPostgresDatabaseId.HasValue)
return @<span class="badge bg-info-subtle text-info border border-info-subtle">
<i class="bi bi-database me-1"></i>Postgres (registered)
</span>;
return @<span class="badge bg-secondary">Unknown</span>;
}
private static string DbDisplayName(DatabaseBinding binding)
{
if (binding.CnpgDatabase is not null) return binding.CnpgDatabase.Name;
if (binding.MongoDatabase is not null) return binding.MongoDatabase.Name;
if (binding.RegisteredPostgresDatabase is not null) return binding.RegisteredPostgresDatabase.Name;
return "—";
}
} }

View File

@@ -19,11 +19,28 @@
.rg-outer { .rg-outer {
overflow-x: auto; overflow-x: auto;
overflow-y: auto; overflow-y: auto;
max-height: 640px; max-height: 75vh;
background: #f6f8fa; background: #f6f8fa;
border-radius: 8px; border-radius: 8px;
border: 1px solid #e9ecef; border: 1px solid #e9ecef;
} }
.rg-fs {
position: fixed;
inset: 0;
z-index: 1055;
background: white;
display: flex;
flex-direction: column;
padding: 12px 16px;
box-shadow: 0 0 0 100vmax rgba(0,0,0,.4);
}
.rg-fs .rg-outer {
flex: 1;
min-height: 0;
max-height: none;
}
.rg-canvas { .rg-canvas {
position: relative; position: relative;
/* width/height set inline from C# layout */ /* width/height set inline from C# layout */
@@ -153,11 +170,43 @@
} }
</style> </style>
<div class="@(_fullscreen ? "rg-fs" : "")">
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
@if (!string.IsNullOrEmpty(Namespace))
{
<span class="badge bg-light border text-muted font-monospace small">
<i class="bi bi-box me-1"></i>@Namespace
</span>
}
@if (!Loading && Resources is not null)
{
<span class="text-muted small">@CountAllResources(Resources) resources</span>
}
@if (LastRefreshed.HasValue && !Loading)
{
<span class="text-muted small ms-auto">
<i class="bi bi-clock me-1"></i>@LastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")
</span>
}
@if (OnRefresh.HasDelegate)
{
<button class="btn btn-sm btn-outline-secondary @(LastRefreshed.HasValue ? "" : "ms-auto")"
@onclick="OnRefresh" disabled="@Loading">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
}
<button class="btn btn-sm btn-outline-secondary @(!LastRefreshed.HasValue && !OnRefresh.HasDelegate ? "ms-auto" : "")"
title="@(_fullscreen ? "Exit fullscreen" : "Fullscreen")"
@onclick="ToggleFullscreen">
<i class="bi @(_fullscreen ? "bi-fullscreen-exit" : "bi-arrows-fullscreen")"></i>
</button>
</div>
<LoadingPanel Loading="@Loading" LoadingText="Querying cluster…" Error="@Error"> <LoadingPanel Loading="@Loading" LoadingText="Querying cluster…" Error="@Error">
@if (!Loading && Error is null && (Resources is null || Resources.Count == 0)) @if (!Loading && Error is null && (Resources is null || Resources.Count == 0))
{ {
<EmptyState Icon="bi-diagram-3" <EmptyState Icon="bi-diagram-3"
Message="@($"No resources in namespace {Namespace}.")" /> Message="@($"No resources tracked for this deployment in namespace {Namespace}.")" />
} }
else if (!Loading && Error is null && Resources is not null && Resources.Count > 0) else if (!Loading && Error is null && Resources is not null && Resources.Count > 0)
{ {
@@ -262,6 +311,7 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
</div> </div>
} }
</LoadingPanel> </LoadingPanel>
</div>
@code { @code {
[Parameter] public bool Loading { get; set; } [Parameter] public bool Loading { get; set; }
@@ -270,6 +320,20 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
[Parameter] public string? Namespace { get; set; } [Parameter] public string? Namespace { get; set; }
[Parameter] public Guid DeploymentId { get; set; } [Parameter] public Guid DeploymentId { get; set; }
[Parameter] public CustomerAccessRole? AccessRole { get; set; } [Parameter] public CustomerAccessRole? AccessRole { get; set; }
[Parameter] public EventCallback OnRefresh { get; set; }
[Parameter] public DateTime? LastRefreshed { get; set; }
private static int CountAllResources(IEnumerable<DeploymentResource>? list)
{
if (list is null) return 0;
int n = 0;
foreach (DeploymentResource r in list)
{
n++;
n += CountAllResources(r.ChildResources);
}
return n;
}
// ── Access gates ──────────────────────────────────────────────────────── // ── Access gates ────────────────────────────────────────────────────────
@@ -314,6 +378,7 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
private string? _feedback; private string? _feedback;
private bool _feedbackOk; private bool _feedbackOk;
private bool _fullscreen;
// ── Lifecycle ──────────────────────────────────────────────────────────── // ── Lifecycle ────────────────────────────────────────────────────────────
@@ -493,6 +558,8 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
_feedbackOk = ok; _feedback = msg; StateHasChanged(); _feedbackOk = ok; _feedback = msg; StateHasChanged();
} }
private void ToggleFullscreen() => _fullscreen = !_fullscreen;
// ── Floating panel renderer ────────────────────────────────────────────── // ── Floating panel renderer ──────────────────────────────────────────────
private RenderFragment RenderPanel(DeploymentResource node, double px, double py) => __builder => private RenderFragment RenderPanel(DeploymentResource node, double px, double py) => __builder =>

View File

@@ -0,0 +1,108 @@
@implements IAsyncDisposable
@inject IJSRuntime JS
<div class="yaml-editor-outer @(_fullscreen ? "yaml-editor-fullscreen" : "")">
@if (_fullscreen)
{
<div class="yaml-editor-fs-bar d-flex align-items-center justify-content-between px-3 py-2 border-bottom bg-light flex-shrink-0">
<span class="small fw-medium text-muted">
<i class="bi bi-code-slash me-1"></i>@Language.ToUpper()
</span>
<button class="btn btn-sm btn-outline-secondary" @onclick="ExitFullscreen">
<i class="bi bi-fullscreen-exit me-1"></i>Exit Fullscreen
</button>
</div>
}
<div class="yaml-editor-inner position-relative">
<div @ref="_container" class="yaml-editor-container @_borderClass" style="min-height: 100px; overflow: hidden;"></div>
@if (Expandable && !_fullscreen)
{
<button class="yaml-expand-btn btn btn-sm btn-light" @onclick="EnterFullscreen" title="Expand to fullscreen">
<i class="bi bi-arrows-fullscreen"></i>
</button>
}
</div>
</div>
@code {
[Parameter] public string Value { get; set; } = "";
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public string Language { get; set; } = "yaml";
[Parameter] public bool ReadOnly { get; set; } = false;
[Parameter] public bool Borderless { get; set; } = false;
[Parameter] public bool AutoHeight { get; set; } = true;
[Parameter] public string MaxHeight { get; set; } = "600px";
[Parameter] public bool Expandable { get; set; } = true;
private string _borderClass => Borderless ? "" : "border border-secondary-subtle rounded";
private bool _fullscreen = false;
private bool _pendingLayoutUpdate = false;
private ElementReference _container;
private IJSObjectReference? _jsModule;
private IJSObjectReference? _editor;
private DotNetObjectReference<YamlEditor>? _dotNetRef;
private string _lastKnownValue = "";
private int MaxHeightPx => int.TryParse(MaxHeight?.Replace("px", "").Trim(), out var px) ? px : 0;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_dotNetRef = DotNetObjectReference.Create(this);
_jsModule = await JS.InvokeAsync<IJSObjectReference>(
"import", "./Components/Pages/Shared/YamlEditor.razor.js");
_editor = await _jsModule.InvokeAsync<IJSObjectReference>(
"createEditor", _container, Value ?? "", Language, ReadOnly, _dotNetRef, AutoHeight, MaxHeightPx);
_lastKnownValue = Value ?? "";
}
if (_pendingLayoutUpdate && _editor is not null && _jsModule is not null)
{
_pendingLayoutUpdate = false;
await _jsModule.InvokeVoidAsync("relayoutEditor", _editor, _container, _fullscreen);
}
}
protected override async Task OnParametersSetAsync()
{
if (_editor is not null && _jsModule is not null && Value != _lastKnownValue)
{
await _jsModule.InvokeVoidAsync("setEditorValue", _editor, Value ?? "");
_lastKnownValue = Value ?? "";
}
}
[JSInvokable]
public async Task OnValueChanged(string value)
{
_lastKnownValue = value;
Value = value;
await ValueChanged.InvokeAsync(value);
}
private void EnterFullscreen()
{
_fullscreen = true;
_pendingLayoutUpdate = true;
}
private void ExitFullscreen()
{
_fullscreen = false;
_pendingLayoutUpdate = true;
}
public async ValueTask DisposeAsync()
{
if (_editor is not null && _jsModule is not null)
{
try { await _jsModule.InvokeVoidAsync("disposeEditor", _editor); } catch { }
await _editor.DisposeAsync();
}
if (_jsModule is not null)
await _jsModule.DisposeAsync();
_dotNetRef?.Dispose();
}
}

View File

@@ -0,0 +1,43 @@
.yaml-editor-outer {
display: block;
}
.yaml-editor-fullscreen {
position: fixed;
inset: 0;
z-index: 1055;
display: flex;
flex-direction: column;
background: white;
box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.45);
}
.yaml-editor-fullscreen .yaml-editor-inner {
flex: 1;
min-height: 0;
overflow: hidden;
}
.yaml-editor-fullscreen .yaml-editor-container {
height: 100%;
}
.yaml-expand-btn {
position: absolute;
top: 6px;
right: 6px;
z-index: 5;
padding: 2px 7px;
font-size: 0.7rem;
line-height: 1.4;
opacity: 0;
transition: opacity 0.15s;
}
.yaml-editor-outer:hover .yaml-expand-btn {
opacity: 0.7;
}
.yaml-expand-btn:hover {
opacity: 1 !important;
}

View File

@@ -0,0 +1,100 @@
const MONACO_VERSION = '0.52.0';
const MONACO_BASE = `https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/${MONACO_VERSION}/min/vs`;
let _monacoPromise = null;
function loadMonaco() {
if (_monacoPromise) return _monacoPromise;
_monacoPromise = new Promise((resolve, reject) => {
if (window.monaco) {
resolve(window.monaco);
return;
}
const script = document.createElement('script');
script.src = `${MONACO_BASE}/loader.js`;
script.onload = () => {
window.require.config({ paths: { vs: MONACO_BASE } });
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
};
script.onerror = () => reject(new Error('Failed to load Monaco editor'));
document.head.appendChild(script);
});
return _monacoPromise;
}
export async function createEditor(container, value, language, readOnly, dotNetRef, autoHeight, maxHeightPx) {
const monaco = await loadMonaco();
const editor = monaco.editor.create(container, {
value: value ?? '',
language: language ?? 'yaml',
theme: 'vs',
readOnly: readOnly,
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
fontSize: 13,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
tabSize: 2,
lineNumbers: 'on',
wordWrap: 'off',
fixedOverflowWidgets: true,
overviewRulerLanes: 0,
padding: { top: 8, bottom: 8 },
scrollbar: {
vertical: 'auto',
horizontal: 'auto',
useShadows: false,
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
},
renderLineHighlight: 'line',
smoothScrolling: true,
});
editor.onDidChangeModelContent(() => {
dotNetRef.invokeMethodAsync('OnValueChanged', editor.getValue());
});
if (autoHeight) {
function updateAutoHeight() {
const contentHeight = editor.getContentHeight();
const h = maxHeightPx > 0 ? Math.min(contentHeight, maxHeightPx) : contentHeight;
container.style.height = Math.max(100, h) + 'px';
editor.layout();
}
editor._updateAutoHeight = updateAutoHeight;
editor.onDidContentSizeChange(updateAutoHeight);
updateAutoHeight();
}
return editor;
}
export function relayoutEditor(editor, container, isFullscreen) {
if (isFullscreen) {
container.style.height = '100%';
} else if (editor._updateAutoHeight) {
editor._updateAutoHeight();
return;
}
requestAnimationFrame(() => editor.layout());
}
export function setEditorValue(editor, value) {
if (editor.getValue() !== value) {
const model = editor.getModel();
model.pushEditOperations(
[],
[{ range: model.getFullModelRange(), text: value }],
() => null
);
}
}
export function disposeEditor(editor) {
editor.dispose();
}

View File

@@ -13,7 +13,6 @@
@inject GitRepositoryService GitRepoService @inject GitRepositoryService GitRepoService
@inject GitSyncService GitSyncService @inject GitSyncService GitSyncService
@inject IDbContextFactory<ApplicationDbContext> DbFactory @inject IDbContextFactory<ApplicationDbContext> DbFactory
@inject IJSRuntime JS
@* ═══════════════════════════════════════════════════════════════════ @* ═══════════════════════════════════════════════════════════════════
App Detail — a full-page view for a single application. App Detail — a full-page view for a single application.
@@ -394,13 +393,16 @@
<span>Apply to Cluster</span> <span>Apply to Cluster</span>
} }
</button> </button>
@if (!IsGitManaged)
{
<button class="btn btn-sm btn-outline-primary" @onclick="() => showAddManifest = !showAddManifest"> <button class="btn btn-sm btn-outline-primary" @onclick="() => showAddManifest = !showAddManifest">
<i class="bi bi-plus me-1"></i>Add Manifest <i class="bi bi-plus me-1"></i>Add Manifest
</button> </button>
}
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
@if (showAddManifest) @if (showAddManifest && !IsGitManaged)
{ {
<div class="card border-primary mb-3"> <div class="card border-primary mb-3">
<div class="card-body"> <div class="card-body">
@@ -421,9 +423,7 @@
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">YAML Content</label> <label class="form-label small">YAML Content</label>
<textarea id="new-manifest-yaml" class="form-control form-control-sm font-monospace" rows="8" <YamlEditor @bind-Value="newManifestYaml" />
@bind="newManifestYaml"
placeholder="apiVersion: v1&#10;kind: Service&#10;..."></textarea>
</div> </div>
<div class="d-flex gap-1"> <div class="d-flex gap-1">
<button class="btn btn-sm btn-primary" @onclick="AddManifest" <button class="btn btn-sm btn-primary" @onclick="AddManifest"
@@ -454,6 +454,8 @@
<span class="fw-medium">@manifest.Name</span> <span class="fw-medium">@manifest.Name</span>
<span class="text-muted small ms-2">#@manifest.SortOrder</span> <span class="text-muted small ms-2">#@manifest.SortOrder</span>
</div> </div>
@if (!IsGitManaged)
{
<div class="d-flex gap-1"> <div class="d-flex gap-1">
@if (editingManifestId == manifest.Id) @if (editingManifestId == manifest.Id)
{ {
@@ -480,19 +482,19 @@
</button> </button>
} }
</div> </div>
}
</div> </div>
@if (editingManifestId == manifest.Id) @if (editingManifestId == manifest.Id)
{ {
<div class="card-body p-2"> <div class="card-body p-2">
<textarea id="edit-manifest-yaml-@manifest.Id" class="form-control form-control-sm font-monospace" rows="10" <YamlEditor @bind-Value="editManifestYaml" />
@bind="editManifestYaml"></textarea>
</div> </div>
} }
else else
{ {
<div class="card-body p-2"> <div class="card-body p-0" style="border-radius: 0 0 var(--bs-card-border-radius) var(--bs-card-border-radius); overflow: hidden;">
<pre class="mb-0 small" style="max-height: 200px; overflow-y: auto;">@manifest.YamlContent</pre> <YamlEditor Value="@manifest.YamlContent" ReadOnly="true" Borderless="true" />
</div> </div>
} }
</div> </div>
@@ -534,9 +536,7 @@
{ {
<div class="alert alert-danger py-1 small mb-2">@helmValuesError</div> <div class="alert alert-danger py-1 small mb-2">@helmValuesError</div>
} }
<textarea id="helm-values-editor" class="form-control font-monospace" rows="12" <YamlEditor @bind-Value="helmValuesYaml" />
@bind="helmValuesYaml"
placeholder="replicas: 3&#10;persistence:&#10; size: 10Gi"></textarea>
<div class="d-flex align-items-center gap-2 mt-2 flex-wrap"> <div class="d-flex align-items-center gap-2 mt-2 flex-wrap">
<button class="btn btn-sm btn-primary" @onclick="SaveHelmValues" disabled="@(savingHelmValues || installingHelm)"> <button class="btn btn-sm btn-primary" @onclick="SaveHelmValues" disabled="@(savingHelmValues || installingHelm)">
@if (savingHelmValues) { <span class="spinner-border spinner-border-sm me-1"></span> } @if (savingHelmValues) { <span class="spinner-border spinner-border-sm me-1"></span> }
@@ -594,7 +594,9 @@
Resources="@resourceTree" Resources="@resourceTree"
Namespace="@selectedDeployment?.Namespace" Namespace="@selectedDeployment?.Namespace"
DeploymentId="@(selectedDeployment?.Id ?? Guid.Empty)" DeploymentId="@(selectedDeployment?.Id ?? Guid.Empty)"
AccessRole="@null" /> AccessRole="@null"
OnRefresh="OpenResourcesTab"
LastRefreshed="@resourceTreeRefreshedAt" />
</div> </div>
</div> </div>
} }
@@ -1249,6 +1251,8 @@
private List<KubernetesCluster>? clusters; private List<KubernetesCluster>? clusters;
private AppDeployment? selectedDeployment; private AppDeployment? selectedDeployment;
private string deploymentSection = "manifests"; private string deploymentSection = "manifests";
private bool IsGitManaged => selectedDeployment?.Type is
DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps;
private bool confirmDeleteDeployment; private bool confirmDeleteDeployment;
// Create deployment form // Create deployment form
@@ -1311,12 +1315,10 @@
private bool? yamlApplySuccess; private bool? yamlApplySuccess;
private string? yamlApplyOutput; private string? yamlApplyOutput;
// JS module for reading live textarea values
private IJSObjectReference? jsModule;
// Resource tree // Resource tree
private List<DeploymentResource>? resourceTree; private List<DeploymentResource>? resourceTree;
private string? resourcesError; private string? resourcesError;
private DateTime? resourceTreeRefreshedAt;
// Metrics // Metrics
private DeploymentMetricsSummary? deploymentMetrics; private DeploymentMetricsSummary? deploymentMetrics;
@@ -1359,20 +1361,7 @@
await LoadEnvironments(); await LoadEnvironments();
} }
protected override async Task OnAfterRenderAsync(bool firstRender) public ValueTask DisposeAsync() => ValueTask.CompletedTask;
{
if (firstRender)
{
jsModule = await JS.InvokeAsync<IJSObjectReference>(
"import", "./Components/Pages/Tenants/AppDetail.razor.js");
}
}
public async ValueTask DisposeAsync()
{
if (jsModule is not null)
await jsModule.DisposeAsync();
}
// ──────── App ──────── // ──────── App ────────
@@ -1609,9 +1598,6 @@
{ {
if (selectedDeployment is null) return; if (selectedDeployment is null) return;
if (jsModule is not null)
newManifestYaml = await jsModule.InvokeAsync<string>("getElementValue", "new-manifest-yaml");
savingManifest = true; savingManifest = true;
try try
{ {
@@ -1635,9 +1621,6 @@
private async Task SaveManifest(Guid manifestId) private async Task SaveManifest(Guid manifestId)
{ {
if (jsModule is not null)
editManifestYaml = await jsModule.InvokeAsync<string>("getElementValue", $"edit-manifest-yaml-{manifestId}");
savingManifest = true; savingManifest = true;
try try
{ {
@@ -1701,11 +1684,6 @@
private async Task SaveHelmValues() private async Task SaveHelmValues()
{ {
// Read the live DOM value so we always have what the user actually typed,
// regardless of whether @bind's onchange fired before this click.
if (jsModule is not null)
helmValuesYaml = await jsModule.InvokeAsync<string>("getElementValue", "helm-values-editor");
savingHelmValues = true; savingHelmValues = true;
helmValuesSaved = false; helmValuesSaved = false;
helmValuesError = null; helmValuesError = null;
@@ -1738,6 +1716,7 @@
if (result.IsSuccess) if (result.IsSuccess)
{ {
resourceTree = result.Data ?? []; resourceTree = result.Data ?? [];
resourceTreeRefreshedAt = DateTime.UtcNow;
// Derive live status from the resource tree and persist it so // Derive live status from the resource tree and persist it so
// the header badges (and the deployment list card) show real values. // the header badges (and the deployment list card) show real values.

View File

@@ -159,7 +159,9 @@ else
Resources="@tree" Resources="@tree"
Namespace="@selectedDeployment.Namespace" Namespace="@selectedDeployment.Namespace"
DeploymentId="@selectedDeployment.Id" DeploymentId="@selectedDeployment.Id"
AccessRole="@null" /> AccessRole="@null"
OnRefresh="RefreshSelected"
LastRefreshed="@_lastRefreshed" />
} }
@code { @code {
@@ -172,6 +174,7 @@ else
private bool treeLoading; private bool treeLoading;
private string? treeError; private string? treeError;
private List<DeploymentResource>? tree; private List<DeploymentResource>? tree;
private DateTime? _lastRefreshed;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -181,23 +184,36 @@ else
private async Task SelectDeployment(AppDeployment d) private async Task SelectDeployment(AppDeployment d)
{ {
selectedDeployment = d; selectedDeployment = d;
await LoadTreeForSelected();
}
private async Task RefreshSelected()
{
await LoadTreeForSelected();
}
private async Task LoadTreeForSelected()
{
if (selectedDeployment is null) return;
treeLoading = true; treeLoading = true;
treeError = null; treeError = null;
tree = null; tree = null;
StateHasChanged(); StateHasChanged();
KubernetesOperationResult<List<DeploymentResource>> result = KubernetesOperationResult<List<DeploymentResource>> result =
await K8sOps.GetLiveResourcesAsync(d.Id); await K8sOps.GetLiveResourcesAsync(selectedDeployment.Id);
treeLoading = false; treeLoading = false;
_lastRefreshed = DateTime.UtcNow;
if (result.IsSuccess && result.Data is not null) if (result.IsSuccess && result.Data is not null)
{ {
tree = result.Data; tree = result.Data;
(SyncStatus sync, HealthStatus health) = (SyncStatus sync, HealthStatus health) =
KubernetesOperationsService.ComputeStatusFromResources(result.Data); KubernetesOperationsService.ComputeStatusFromResources(result.Data);
d.SyncStatus = sync; selectedDeployment.SyncStatus = sync;
d.HealthStatus = health; selectedDeployment.HealthStatus = health;
} }
else else
{ {
@@ -210,6 +226,7 @@ else
selectedDeployment = null; selectedDeployment = null;
tree = null; tree = null;
treeError = null; treeError = null;
_lastRefreshed = null;
} }
// ── Visual helpers ──────────────────────────────────────────────────────── // ── Visual helpers ────────────────────────────────────────────────────────

View File

@@ -227,9 +227,7 @@ else if (section == "components")
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label small mb-0">Helm values (YAML)</label> <label class="form-label small mb-0">Helm values (YAML)</label>
<textarea class="form-control form-control-sm font-monospace" rows="4" <YamlEditor @bind-Value="regValues" />
placeholder="# Custom values..."
@bind="regValues" @bind:event="oninput"></textarea>
</div> </div>
<button class="btn btn-sm btn-success" @onclick="RegisterCustomComponent" <button class="btn btn-sm btn-success" @onclick="RegisterCustomComponent"
disabled="@(string.IsNullOrWhiteSpace(regName) || string.IsNullOrWhiteSpace(regChartName))"> disabled="@(string.IsNullOrWhiteSpace(regName) || string.IsNullOrWhiteSpace(regChartName))">
@@ -434,8 +432,7 @@ else if (section == "components")
@if (showAdvancedYaml) @if (showAdvancedYaml)
{ {
<div class="accordion-body p-2"> <div class="accordion-body p-2">
<textarea class="form-control form-control-sm font-monospace" rows="12" <YamlEditor @bind-Value="regValues" />
@bind="regValues" @bind:event="oninput"></textarea>
</div> </div>
} }
</div> </div>
@@ -779,6 +776,15 @@ else if (section == "components")
<i class="bi bi-sliders me-1"></i>Configure <i class="bi bi-sliders me-1"></i>Configure
</button> </button>
</li> </li>
<li class="nav-item">
<button class="nav-link py-1 px-2 @(compDetailTab == "values" ? "active" : "")" @onclick='() => compDetailTab = "values"'>
<i class="bi bi-code-slash me-1"></i>Values
@if (!string.IsNullOrWhiteSpace(editValues))
{
<span class="badge bg-secondary ms-1">customized</span>
}
</button>
</li>
<li class="nav-item"> <li class="nav-item">
<button class="nav-link py-1 px-2 @(compDetailTab == "secrets" ? "active" : "")" @onclick="() => ShowSecrets(comp.Id)"> <button class="nav-link py-1 px-2 @(compDetailTab == "secrets" ? "active" : "")" @onclick="() => ShowSecrets(comp.Id)">
<i class="bi bi-key me-1"></i>Secrets <i class="bi bi-key me-1"></i>Secrets
@@ -963,47 +969,10 @@ else if (section == "components")
</div> </div>
</div> </div>
@* Advanced YAML editor *@
<div class="accordion mb-3">
<div class="accordion-item border-0 bg-light rounded">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2 px-3 small bg-light rounded" type="button"
@onclick="() => showAdvancedYaml = !showAdvancedYaml">
<i class="bi bi-code-slash me-1"></i>Values YAML
@if (!string.IsNullOrWhiteSpace(editValues))
{
<span class="badge bg-secondary ms-2">customized</span>
} }
</button> else if (compDetailTab == "values")
</h2>
@if (showAdvancedYaml)
{ {
<div class="p-3"> <YamlEditor @bind-Value="editValues" />
<textarea class="form-control form-control-sm font-monospace" rows="10"
@bind="editValues" @bind:event="oninput"></textarea>
</div>
}
</div>
</div>
@* Action bar *@
<div class="d-flex align-items-center gap-2 pt-2 border-top">
<button class="btn btn-sm btn-primary" @onclick="() => SaveConfiguration(comp.Id)">
<i class="bi bi-check-lg me-1"></i>Save
</button>
@if (comp.Status == ComponentStatus.NotInstalled || comp.Status == ComponentStatus.Failed)
{
<button class="btn btn-sm btn-success" @onclick="() => SaveAndInstall(comp.Id)">
<i class="bi bi-play-fill me-1"></i>Save &amp; Install
</button>
}
else if (comp.Status == ComponentStatus.Installed)
{
<button class="btn btn-sm btn-success" @onclick="() => SaveAndApply(comp.Id)">
<i class="bi bi-arrow-clockwise me-1"></i>Save &amp; Apply
</button>
}
</div>
} }
else if (compDetailTab == "secrets") else if (compDetailTab == "secrets")
{ {
@@ -1325,6 +1294,27 @@ else if (section == "components")
} }
</div> </div>
} }
@if (compDetailTab is "config" or "values")
{
<div class="d-flex align-items-center gap-2 pt-2 mt-2 border-top">
<button class="btn btn-sm btn-primary" @onclick="() => SaveConfiguration(comp.Id)">
<i class="bi bi-check-lg me-1"></i>Save
</button>
@if (comp.Status == ComponentStatus.NotInstalled || comp.Status == ComponentStatus.Failed)
{
<button class="btn btn-sm btn-success" @onclick="() => SaveAndInstall(comp.Id)">
<i class="bi bi-play-fill me-1"></i>Save &amp; Install
</button>
}
else if (comp.Status == ComponentStatus.Installed)
{
<button class="btn btn-sm btn-success" @onclick="() => SaveAndApply(comp.Id)">
<i class="bi bi-arrow-clockwise me-1"></i>Save &amp; Apply
</button>
}
</div>
}
</div> </div>
} }
</div> </div>

View File

@@ -52,9 +52,7 @@ else if (selectedEnv is not null)
{ {
<p class="text-muted small mb-2">Paste your kubeconfig YAML or upload a file to auto-detect clusters and API server URLs.</p> <p class="text-muted small mb-2">Paste your kubeconfig YAML or upload a file to auto-detect clusters and API server URLs.</p>
<textarea class="form-control mb-2" rows="5" <YamlEditor @bind-Value="kubeconfigInput" />
placeholder="apiVersion: v1&#10;kind: Config&#10;clusters:&#10; - cluster:&#10; server: https://..."
@bind="kubeconfigInput" @bind:event="oninput"></textarea>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<label class="btn btn-sm btn-outline-secondary mb-0"> <label class="btn btn-sm btn-outline-secondary mb-0">

View File

@@ -180,9 +180,7 @@ else
{ {
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Custom NetworkPolicy YAML</label> <label class="form-label small">Custom NetworkPolicy YAML</label>
<textarea class="form-control form-control-sm font-monospace" rows="8" <YamlEditor @bind-Value="newNpCustomYaml" />
@bind="newNpCustomYaml"
placeholder="apiVersion: networking.k8s.io/v1&#10;kind: NetworkPolicy&#10;..." />
</div> </div>
} }
<div class="d-flex gap-1"> <div class="d-flex gap-1">

View File

@@ -456,8 +456,7 @@
<div> <div>
<h6 class="text-muted"><i class="bi bi-shield-lock me-1"></i>Bucket Policy (JSON)</h6> <h6 class="text-muted"><i class="bi bi-shield-lock me-1"></i>Bucket Policy (JSON)</h6>
<div class="mb-2"> <div class="mb-2">
<textarea class="form-control form-control-sm font-monospace" rows="8" @bind="bucketPolicyJson" <YamlEditor @bind-Value="bucketPolicyJson" Language="json" />
placeholder='{"Version": "2012-10-17", "Statement": [...]}'></textarea>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" @onclick="SavePolicy" disabled="@manageSaving"> <button class="btn btn-sm btn-primary" @onclick="SavePolicy" disabled="@manageSaving">

View File

@@ -0,0 +1,9 @@
namespace EntKube.Web.Data;
public enum AccessLevel
{
None = 0,
View = 1,
Edit = 2,
Manage = 3,
}

View File

@@ -0,0 +1,26 @@
// <auto-generated />
using System;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
[DbContext(typeof(PostgresApplicationDbContext))]
[Migration("20260607000000_AddTenantRolePermissions")]
partial class AddTenantRolePermissions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddTenantRolePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PermissionsJson",
table: "TenantRoles",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PermissionsJson",
table: "TenantRoles");
}
}
}

View File

@@ -2587,6 +2587,9 @@ namespace EntKube.Web.Data.Migrations.Postgres
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
b.Property<string>("PermissionsJson")
.HasColumnType("text");
b.Property<Guid>("TenantId") b.Property<Guid>("TenantId")
.HasColumnType("uuid"); .HasColumnType("uuid");

View File

@@ -0,0 +1,26 @@
// <auto-generated />
using System;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EntKube.Web.Data.Migrations.SqlServer
{
[DbContext(typeof(SqlServerApplicationDbContext))]
[Migration("20260607000000_AddTenantRolePermissions")]
partial class AddTenantRolePermissions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.SqlServer
{
/// <inheritdoc />
public partial class AddTenantRolePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PermissionsJson",
table: "TenantRoles",
type: "nvarchar(max)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PermissionsJson",
table: "TenantRoles");
}
}
}

View File

@@ -2588,6 +2588,9 @@ namespace EntKube.Web.Data.Migrations.SqlServer
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("nvarchar(100)"); .HasColumnType("nvarchar(100)");
b.Property<string>("PermissionsJson")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("TenantId") b.Property<Guid>("TenantId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");

View File

@@ -0,0 +1,25 @@
// <auto-generated />
using System;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EntKube.Web.Data.Migrations.Sqlite
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260607000000_AddTenantRolePermissions")]
partial class AddTenantRolePermissions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Sqlite
{
/// <inheritdoc />
public partial class AddTenantRolePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PermissionsJson",
table: "TenantRoles",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PermissionsJson",
table: "TenantRoles");
}
}
}

View File

@@ -2582,6 +2582,9 @@ namespace EntKube.Web.Data.Migrations.Sqlite
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("PermissionsJson")
.HasColumnType("TEXT");
b.Property<Guid>("TenantId") b.Property<Guid>("TenantId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");

View File

@@ -0,0 +1,21 @@
namespace EntKube.Web.Data;
public enum TenantFeature
{
Clusters,
Environments,
Customers,
Apps,
Deployments,
Groups,
Storage,
Databases,
GitRepositories,
Keycloak,
ContainerRegistry,
Messaging,
Cache,
VPN,
Monitoring,
Audit,
}

View File

@@ -17,6 +17,12 @@ public class TenantRole
/// </summary> /// </summary>
public required string Name { get; set; } public required string Name { get; set; }
/// <summary>
/// JSON-serialized Dictionary&lt;TenantFeature, AccessLevel&gt; — permissions per feature.
/// Null means "no explicit permissions defined" (treat as all None).
/// </summary>
public string? PermissionsJson { get; set; }
// Navigation // Navigation
public Tenant Tenant { get; set; } = null!; public Tenant Tenant { get; set; } = null!;
public ICollection<TenantMembership> Memberships { get; set; } = []; public ICollection<TenantMembership> Memberships { get; set; } = [];

View File

@@ -103,6 +103,8 @@ public class Program
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddScoped<TenantService>(); builder.Services.AddScoped<TenantService>();
builder.Services.AddScoped<UserAccessService>(); builder.Services.AddScoped<UserAccessService>();
builder.Services.AddScoped<UserManagementService>();
builder.Services.AddScoped<TenantRoleService>();
// Vault encryption: the root key is loaded from configuration. // Vault encryption: the root key is loaded from configuration.
// In production this should come from a secure source (env var, key vault, etc.) // In production this should come from a secure source (env var, key vault, etc.)

View File

@@ -1449,6 +1449,7 @@ public class CnpgService(
.ThenInclude(d => d!.CnpgCluster) .ThenInclude(d => d!.CnpgCluster)
.Include(b => b.MongoDatabase) .Include(b => b.MongoDatabase)
.ThenInclude(d => d!.MongoCluster) .ThenInclude(d => d!.MongoCluster)
.Include(b => b.RegisteredPostgresDatabase)
.Where(b => b.AppDeploymentId == appDeploymentId) .Where(b => b.AppDeploymentId == appDeploymentId)
.OrderBy(b => b.CreatedAt) .OrderBy(b => b.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);

View File

@@ -1167,8 +1167,19 @@ public class KubernetesOperationsService(
|| r.Name.StartsWith(release + "-", StringComparison.Ordinal)) || r.Name.StartsWith(release + "-", StringComparison.Ordinal))
.ToList(); .ToList();
} }
// else: no manifests and not Helm — show all namespace resources (e.g. a else if (deployment.Type != DeploymentType.GitAppOfApps)
// brand-new Manual deployment whose manifests haven't been applied yet). {
// No stored manifests and not a Helm release — apply name-prefix matching
// so that resources from other workloads sharing the same namespace are
// excluded. GitAppOfApps is exempt because it intentionally owns the
// whole namespace.
string namePrefix = deployment.Name;
roots = roots
.Where(r => r.Name == namePrefix
|| r.Name.StartsWith(namePrefix + "-", StringComparison.Ordinal))
.ToList();
}
// else (GitAppOfApps): manages the entire namespace — show everything.
SortChildResources(roots); SortChildResources(roots);

View File

@@ -931,6 +931,17 @@ public class RabbitMQService(
.ToListAsync(ct); .ToListAsync(ct);
} }
public async Task<List<MessagingBinding>> GetMessagingBindingsForDeploymentAsync(
Guid appDeploymentId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
return await db.MessagingBindings
.Include(b => b.Cluster)
.Where(b => b.AppDeploymentId == appDeploymentId)
.OrderBy(b => b.Vhost).ThenBy(b => b.QueueName ?? b.ExchangeName)
.ToListAsync(ct);
}
public async Task<MessagingBinding> CreateMessagingBindingAsync( public async Task<MessagingBinding> CreateMessagingBindingAsync(
Guid tenantId, Guid clusterId, Guid appDeploymentId, Guid tenantId, Guid clusterId, Guid appDeploymentId,
string vhost, string? queueName, string? exchangeName, string vhost, string? queueName, string? exchangeName,

View File

@@ -0,0 +1,121 @@
using System.Text.Json;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Services;
public class TenantRoleService(IDbContextFactory<ApplicationDbContext> dbFactory)
{
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };
// Feature display metadata — order determines row order in the permission matrix.
public static readonly IReadOnlyList<(TenantFeature Feature, string Label, string Icon)> Features =
[
(TenantFeature.Clusters, "Clusters", "bi-hdd-network"),
(TenantFeature.Environments, "Environments", "bi-layers"),
(TenantFeature.Customers, "Customers", "bi-building"),
(TenantFeature.Apps, "Apps", "bi-box-seam"),
(TenantFeature.Deployments, "Deployments", "bi-rocket"),
(TenantFeature.Groups, "Groups", "bi-diagram-3"),
(TenantFeature.Databases, "Databases", "bi-database"),
(TenantFeature.Storage, "Storage", "bi-cloud-arrow-up"),
(TenantFeature.GitRepositories, "Git Repositories", "bi-git"),
(TenantFeature.Keycloak, "Keycloak / Identity","bi-shield-lock"),
(TenantFeature.ContainerRegistry, "Container Registry", "bi-box2-heart"),
(TenantFeature.Messaging, "Messaging", "bi-send"),
(TenantFeature.Cache, "Cache", "bi-lightning"),
(TenantFeature.VPN, "VPN", "bi-shield-shaded"),
(TenantFeature.Monitoring, "Monitoring", "bi-activity"),
(TenantFeature.Audit, "Audit Log", "bi-journal-text"),
];
// ── Role CRUD ──────────────────────────────────────────────────────────────
public async Task<List<TenantRole>> GetRolesAsync(Guid tenantId)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
return await db.TenantRoles
.Include(r => r.Memberships)
.Where(r => r.TenantId == tenantId)
.OrderBy(r => r.Name)
.ToListAsync();
}
public async Task<TenantRole?> GetRoleAsync(Guid roleId)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
return await db.TenantRoles.FindAsync(roleId);
}
public async Task<TenantRole> CreateRoleAsync(Guid tenantId, string name)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
TenantRole role = new()
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Name = name,
PermissionsJson = null
};
db.TenantRoles.Add(role);
await db.SaveChangesAsync();
return role;
}
public async Task<bool> DeleteRoleAsync(Guid roleId)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
TenantRole? role = await db.TenantRoles.FindAsync(roleId);
if (role is null) return false;
db.TenantRoles.Remove(role);
await db.SaveChangesAsync();
return true;
}
public async Task RenameRoleAsync(Guid roleId, string newName)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
TenantRole? role = await db.TenantRoles.FindAsync(roleId);
if (role is null) return;
role.Name = newName;
await db.SaveChangesAsync();
}
// ── Permissions ────────────────────────────────────────────────────────────
public static Dictionary<TenantFeature, AccessLevel> DecodePermissions(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<Dictionary<TenantFeature, AccessLevel>>(json, JsonOpts) ?? [];
}
catch
{
return [];
}
}
public static string EncodePermissions(Dictionary<TenantFeature, AccessLevel> permissions)
=> JsonSerializer.Serialize(permissions, JsonOpts);
public async Task SavePermissionsAsync(Guid roleId, Dictionary<TenantFeature, AccessLevel> permissions)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
TenantRole? role = await db.TenantRoles.FindAsync(roleId);
if (role is null) return;
// Strip out None entries — they're the default anyway and keep the JSON compact.
Dictionary<TenantFeature, AccessLevel> nonDefault = permissions
.Where(kv => kv.Value != AccessLevel.None)
.ToDictionary(kv => kv.Key, kv => kv.Value);
role.PermissionsJson = nonDefault.Count == 0 ? null : EncodePermissions(nonDefault);
await db.SaveChangesAsync();
}
public static AccessLevel GetPermission(Dictionary<TenantFeature, AccessLevel> permissions, TenantFeature feature)
=> permissions.TryGetValue(feature, out AccessLevel level) ? level : AccessLevel.None;
}

View File

@@ -36,8 +36,17 @@ public class TenantService(IDbContextFactory<ApplicationDbContext> dbFactory)
string slug = GenerateSlug(name); string slug = GenerateSlug(name);
Tenant tenant = new() { Id = Guid.NewGuid(), Name = name, Slug = slug }; Guid tenantId = Guid.NewGuid();
Tenant tenant = new() { Id = tenantId, Name = name, Slug = slug };
db.Tenants.Add(tenant); db.Tenants.Add(tenant);
// Seed default roles so tenant memberships can be assigned immediately.
db.TenantRoles.AddRange(
new TenantRole { Id = Guid.NewGuid(), TenantId = tenantId, Name = "Administrator" },
new TenantRole { Id = Guid.NewGuid(), TenantId = tenantId, Name = "Member" },
new TenantRole { Id = Guid.NewGuid(), TenantId = tenantId, Name = "Viewer" }
);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return tenant; return tenant;
} }

View File

@@ -0,0 +1,163 @@
using EntKube.Web.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Services;
public class UserManagementService(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IDbContextFactory<ApplicationDbContext> dbFactory)
{
public async Task<List<ApplicationUser>> GetAllUsersAsync()
{
return await userManager.Users.OrderBy(u => u.Email).ToListAsync();
}
public async Task<ApplicationUser?> GetUserByIdAsync(string userId)
{
return await userManager.FindByIdAsync(userId);
}
public async Task<IdentityResult> CreateUserAsync(string email, string password)
{
ApplicationUser user = new();
await userManager.SetUserNameAsync(user, email);
await userManager.SetEmailAsync(user, email);
IdentityResult result = await userManager.CreateAsync(user, password);
if (!result.Succeeded) return result;
// Admin-created accounts are pre-confirmed — no email flow needed.
string token = await userManager.GenerateEmailConfirmationTokenAsync(user);
await userManager.ConfirmEmailAsync(user, token);
return result;
}
public async Task<IdentityResult> DeleteUserAsync(string userId)
{
ApplicationUser? user = await userManager.FindByIdAsync(userId);
if (user is null)
return IdentityResult.Failed(new IdentityError { Description = "User not found." });
return await userManager.DeleteAsync(user);
}
public async Task<List<IdentityRole>> GetAllRolesAsync()
{
return await roleManager.Roles.OrderBy(r => r.Name).ToListAsync();
}
public async Task<IList<string>> GetUserRolesAsync(string userId)
{
ApplicationUser? user = await userManager.FindByIdAsync(userId);
if (user is null) return [];
return await userManager.GetRolesAsync(user);
}
public async Task<IdentityResult> AddToRoleAsync(string userId, string role)
{
ApplicationUser? user = await userManager.FindByIdAsync(userId);
if (user is null)
return IdentityResult.Failed(new IdentityError { Description = "User not found." });
return await userManager.AddToRoleAsync(user, role);
}
public async Task<IdentityResult> RemoveFromRoleAsync(string userId, string role)
{
ApplicationUser? user = await userManager.FindByIdAsync(userId);
if (user is null)
return IdentityResult.Failed(new IdentityError { Description = "User not found." });
return await userManager.RemoveFromRoleAsync(user, role);
}
public async Task<List<TenantMembership>> GetUserTenantMembershipsAsync(string userId)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
return await db.TenantMemberships
.Include(m => m.Tenant)
.Include(m => m.Role)
.Where(m => m.UserId == userId)
.OrderBy(m => m.Tenant.Name)
.ToListAsync();
}
public async Task<List<Tenant>> GetAllTenantsAsync()
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
return await db.Tenants
.Include(t => t.Roles)
.OrderBy(t => t.Name)
.ToListAsync();
}
public async Task AddTenantMembershipAsync(string userId, Guid tenantId, Guid roleId)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
db.TenantMemberships.Add(new TenantMembership
{
UserId = userId,
TenantId = tenantId,
RoleId = roleId,
JoinedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
}
public async Task RemoveTenantMembershipAsync(string userId, Guid tenantId)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
TenantMembership? m = await db.TenantMemberships
.FirstOrDefaultAsync(x => x.UserId == userId && x.TenantId == tenantId);
if (m is not null)
{
db.TenantMemberships.Remove(m);
await db.SaveChangesAsync();
}
}
public async Task<List<GroupMembership>> GetUserGroupMembershipsAsync(string userId)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
return await db.GroupMemberships
.Include(gm => gm.Group)
.ThenInclude(g => g.Tenant)
.Where(gm => gm.UserId == userId)
.OrderBy(gm => gm.Group.Tenant.Name)
.ThenBy(gm => gm.Group.Name)
.ToListAsync();
}
public async Task<List<Group>> GetGroupsForTenantAsync(Guid tenantId)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
return await db.Groups
.Where(g => g.TenantId == tenantId)
.OrderBy(g => g.Name)
.ToListAsync();
}
public async Task AddGroupMembershipAsync(string userId, Guid groupId)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
db.GroupMemberships.Add(new GroupMembership
{
UserId = userId,
GroupId = groupId,
JoinedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
}
public async Task RemoveGroupMembershipAsync(string userId, Guid groupId)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
GroupMembership? gm = await db.GroupMemberships
.FirstOrDefaultAsync(x => x.UserId == userId && x.GroupId == groupId);
if (gm is not null)
{
db.GroupMemberships.Remove(gm);
await db.SaveChangesAsync();
}
}
}

View File

@@ -1,5 +1,8 @@
{ {
"DatabaseProvider": "Sqlite", "DatabaseProvider": "Sqlite",
"Auth": {
"AllowRegistration": true
},
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "DataSource=Data/app.db;Cache=Shared" "DefaultConnection": "DataSource=Data/app.db;Cache=Shared"
}, },