publish1
This commit is contained in:
@@ -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
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -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
59
docker-compose.yml
Normal 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:
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
365
src/EntKube.Web/Components/Pages/Admin/AdminRoles.razor
Normal file
365
src/EntKube.Web/Components/Pages/Admin/AdminRoles.razor
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
462
src/EntKube.Web/Components/Pages/Admin/AdminUserDetail.razor
Normal file
462
src/EntKube.Web/Components/Pages/Admin/AdminUserDetail.razor
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/EntKube.Web/Components/Pages/Admin/AdminUsers.razor
Normal file
153
src/EntKube.Web/Components/Pages/Admin/AdminUsers.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 kind: Service ..."></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 persistence: 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>
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "—";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
108
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor
Normal file
108
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor.css
Normal file
43
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor.css
Normal 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;
|
||||||
|
}
|
||||||
100
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor.js
Normal file
100
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor.js
Normal 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();
|
||||||
|
}
|
||||||
@@ -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 kind: Service ..."></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 persistence: 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.
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 & 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 & 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 & 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 & Apply
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 kind: Config clusters: - cluster: 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">
|
||||||
|
|||||||
@@ -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 kind: NetworkPolicy ..." />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex gap-1">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
9
src/EntKube.Web/Data/AccessLevel.cs
Normal file
9
src/EntKube.Web/Data/AccessLevel.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace EntKube.Web.Data;
|
||||||
|
|
||||||
|
public enum AccessLevel
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
View = 1,
|
||||||
|
Edit = 2,
|
||||||
|
Manage = 3,
|
||||||
|
}
|
||||||
26
src/EntKube.Web/Data/Migrations/Postgres/20260607000000_AddTenantRolePermissions.Designer.cs
generated
Normal file
26
src/EntKube.Web/Data/Migrations/Postgres/20260607000000_AddTenantRolePermissions.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
26
src/EntKube.Web/Data/Migrations/SqlServer/20260607000000_AddTenantRolePermissions.Designer.cs
generated
Normal file
26
src/EntKube.Web/Data/Migrations/SqlServer/20260607000000_AddTenantRolePermissions.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
25
src/EntKube.Web/Data/Migrations/Sqlite/20260607000000_AddTenantRolePermissions.Designer.cs
generated
Normal file
25
src/EntKube.Web/Data/Migrations/Sqlite/20260607000000_AddTenantRolePermissions.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
21
src/EntKube.Web/Data/TenantFeature.cs
Normal file
21
src/EntKube.Web/Data/TenantFeature.cs
Normal 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,
|
||||||
|
}
|
||||||
@@ -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<TenantFeature, AccessLevel> — 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; } = [];
|
||||||
|
|||||||
@@ -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.)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
121
src/EntKube.Web/Services/TenantRoleService.cs
Normal file
121
src/EntKube.Web/Services/TenantRoleService.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
163
src/EntKube.Web/Services/UserManagementService.cs
Normal file
163
src/EntKube.Web/Services/UserManagementService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user